diff --git a/.changes/.editorconfig b/.changes/.editorconfig new file mode 100644 index 0000000000..d37276d2b0 --- /dev/null +++ b/.changes/.editorconfig @@ -0,0 +1,2 @@ +[*.json] +insert_final_newline = false \ No newline at end of file diff --git a/.changes/1.18.json b/.changes/1.18.json new file mode 100644 index 0000000000..780dfd9c33 --- /dev/null +++ b/.changes/1.18.json @@ -0,0 +1,62 @@ +{ + "date" : "2020-09-21", + "version" : "1.18", + "entries" : [ { + "type" : "feature", + "description" : "Add support for AWS SSO based credential profiles" + }, { + "type" : "feature", + "description" : "Support colons (`:`) in credential profile names" + }, { + "type" : "feature", + "description" : "Add support for Lambda runtime java8.al2" + }, { + "type" : "feature", + "description" : "Allow connecting to RDS/Redshift databases with temporary IAM AWS credentials or a SecretsManager secret" + }, { + "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" + }, { + "type" : "feature", + "description" : "Save update Lambda code settings" + }, { + "type" : "bugfix", + "description" : "Fix several cases where features not supported by the host IDE are shown (#1980)" + }, { + "type" : "bugfix", + "description" : "Start generating SAM project before the IDE is done indexing" + }, { + "type" : "bugfix", + "description" : "Fix several uncaught exceptions caused by plugins being installed but not enabled" + }, { + "type" : "bugfix", + "description" : "Fix removing a source_profile leading to an IDE error on profile file refresh" + }, { + "type" : "bugfix", + "description" : "Fix issue where templates > 51200 bytes would not deploy with \"Deploy Serverless Application\" (#1973)" + }, { + "type" : "bugfix", + "description" : "Fix the function selection panel not reloading when changing SAM templates (#955)" + }, { + "type" : "bugfix", + "description" : "Fix remote terminal start issue on 2020.2" + }, { + "type" : "bugfix", + "description" : "Fix Rider building Lambda into incorrect folders" + }, { + "type" : "bugfix", + "description" : "Improved rendering speed of wrapped text in CloudWatch logs and CloudFormation events tables" + }, { + "type" : "bugfix", + "description" : "Fix the CloudWatch Logs table breaking when the service returns an exception during loading more entries (#1951)" + }, { + "type" : "bugfix", + "description" : "Improve watching of the AWS profile files to incorporate changes made to the files outisde of the IDE" + }, { + "type" : "bugfix", + "description" : "Fix SAM Gradle Hello World syncing twice (#2003)" + }, { + "type" : "bugfix", + "description" : "Quote template parameters when deploying a cloudformation template" + } ] +} \ No newline at end of file diff --git a/.changes/1.19.json b/.changes/1.19.json new file mode 100644 index 0000000000..91ecb2cbf0 --- /dev/null +++ b/.changes/1.19.json @@ -0,0 +1,11 @@ +{ + "date" : "2020-10-07", + "version" : "1.19", + "entries" : [ { + "type" : "feature", + "description" : "Add the ability to copy the URL to an S3 object" + }, { + "type" : "feature", + "description" : "Add support for debugging dotnet 3.1 local lambdas (requires minimum SAM CLI version of 1.4.0)" + } ] +} \ No newline at end of file diff --git a/.changes/1.20.json b/.changes/1.20.json new file mode 100644 index 0000000000..ebbedeaf0d --- /dev/null +++ b/.changes/1.20.json @@ -0,0 +1,20 @@ +{ + "date" : "2020-10-22", + "version" : "1.20", + "entries" : [ { + "type" : "feature", + "description" : "Add support for `+` in AWS profile names" + }, { + "type" : "bugfix", + "description" : "Fix being unable to use a SSO profile in a credential chain" + }, { + "type" : "bugfix", + "description" : "Fix Aurora MySQL 5.7 not showing up in the AWS Explorer" + }, { + "type" : "bugfix", + "description" : "Improve IAM RDS connection: Fix Aurora MySQL, detect more error cases, fix database configuration validation throwing when there is no DB name" + }, { + "type" : "deprecation", + "description" : "2019.3 support will be removed in the next release" + } ] +} \ No newline at end of file diff --git a/.changes/1.21.json b/.changes/1.21.json new file mode 100644 index 0000000000..306df98989 --- /dev/null +++ b/.changes/1.21.json @@ -0,0 +1,29 @@ +{ + "date" : "2020-11-24", + "version" : "1.21", + "entries" : [ { + "type" : "breaking", + "description" : "Remove support for 2019.3, 2020.1 is the new minimum version" + }, { + "type" : "feature", + "description" : "Add copy Logical/Physical ID actions to Stack View #2165" + }, { + "type" : "feature", + "description" : "Add SQS AWS Explorer node and the ability to send/poll for messages" + }, { + "type" : "feature", + "description" : "Add the ability to search CloudWatch Logs using CloudWatch Logs Insights" + }, { + "type" : "feature", + "description" : "Add copy actions to CloudFormation outputs (#2179)" + }, { + "type" : "feature", + "description" : "Support for the 2020.3 family of IDEs" + }, { + "type" : "feature", + "description" : "Add an AWS Explorer ECR node" + }, { + "type" : "bugfix", + "description" : "Significantly speed up loading the list of S3 buckets (#2174)" + } ] +} \ No newline at end of file diff --git a/.changes/1.22.json b/.changes/1.22.json new file mode 100644 index 0000000000..a77215a6fd --- /dev/null +++ b/.changes/1.22.json @@ -0,0 +1,11 @@ +{ + "date" : "2020-12-01", + "version" : "1.22", + "entries" : [ { + "type" : "feature", + "description" : "Container Image Support in Lambda" + }, { + "type" : "bugfix", + "description" : "Fix update Lambda code for compiled languages (#2231)" + } ] +} \ No newline at end of file diff --git a/.changes/1.23.json b/.changes/1.23.json new file mode 100644 index 0000000000..6717892b36 --- /dev/null +++ b/.changes/1.23.json @@ -0,0 +1,47 @@ +{ + "date" : "2021-02-04", + "version" : "1.23", + "entries" : [ { + "type" : "feature", + "description" : "Add \"Copy S3 URI\" to S3 objects (#2208)" + }, { + "type" : "feature", + "description" : "Add Dotnet5 Lambda support (Image only)" + }, { + "type" : "feature", + "description" : "Add option to view past object versions in S3 file editor" + }, { + "type" : "feature", + "description" : "Nodejs14.x Lambda support" + }, { + "type" : "feature", + "description" : "Update Lambda max memory to 10240" + }, { + "type" : "bugfix", + "description" : "Re-add environment variable settings to SAM template based run configurations (#2282)" + }, { + "type" : "bugfix", + "description" : "Fix error thrown on profile refresh if removing a profile that uses source_profile (#2309)" + }, { + "type" : "bugfix", + "description" : "Fix NodeJS and Python breakpoints failing to hit sometimes" + }, { + "type" : "bugfix", + "description" : "Speed up loading CloudFormation resources" + }, { + "type" : "bugfix", + "description" : "Fix not invalidating credentials when a `source_profile` is updated" + }, { + "type" : "bugfix", + "description" : "Fix cell based copying in CloudWatch Logs (#2333)" + }, { + "type" : "bugfix", + "description" : "Fix certain S3 buckets being unable to be shown in the explorer (#2342)" + }, { + "type" : "bugfix", + "description" : "Fix exception thrown in the new project wizard when run immediately after the toolkit is installed" + }, { + "type" : "bugfix", + "description" : "Fixing issue with SSO refresh locking UI thread (#2224)" + } ] +} \ No newline at end of file diff --git a/.changes/1.24.json b/.changes/1.24.json new file mode 100644 index 0000000000..3cc7392489 --- /dev/null +++ b/.changes/1.24.json @@ -0,0 +1,23 @@ +{ + "date" : "2021-02-17", + "version" : "1.24", + "entries" : [ { + "type" : "feature", + "description" : "RDS serverless databases are now visible in the RDS node in the explorer" + }, { + "type" : "bugfix", + "description" : "Fix transient 'Aborted!' message on successful SAM CLI local Lambda execution" + }, { + "type" : "bugfix", + "description" : "Fix being unable to open the file browser in the Schemas download panel" + }, { + "type" : "bugfix", + "description" : "Fix being unable to type/copy paste into the SAM local run config's template path textbox" + }, { + "type" : "bugfix", + "description" : "Fix Secrets Manager-based databse auth throwing NullPointer when editing settings in 2020.3.2 (Fixes #2403)" + }, { + "type" : "bugfix", + "description" : "Fix making an un-needed service call on IDE startup (#2426)" + } ] +} \ No newline at end of file diff --git a/.changes/1.25.json b/.changes/1.25.json new file mode 100644 index 0000000000..6abdcac1bb --- /dev/null +++ b/.changes/1.25.json @@ -0,0 +1,47 @@ +{ + "date" : "2021-03-10", + "version" : "1.25", + "entries" : [ { + "type" : "breaking", + "description" : "Minimum SAM CLI version is now 1.0.0" + }, { + "type" : "feature", + "description" : "Debugging Python based Lambdas locally now have the Python interactive console enabled (Fixes #1165)" + }, { + "type" : "feature", + "description" : "Add a setting for how the AWS profiles notification is shown (#2408)" + }, { + "type" : "feature", + "description" : "Deleting resources now requires typing \"delete me\" instead of the resource name" + }, { + "type" : "feature", + "description" : "Add support for 2021.1" + }, { + "type" : "feature", + "description" : "Allow deploying SAM templates from the CloudFormaton node (#2166)" + }, { + "type" : "bugfix", + "description" : "Improve error messages when properties are not found in templates (#2449)" + }, { + "type" : "bugfix", + "description" : "Fix resource selectors assuming every region has every service (#2435)" + }, { + "type" : "bugfix", + "description" : "Docker is now validated before building the Lambda when running and debugging locally (Fixes #2418)" + }, { + "type" : "bugfix", + "description" : "Fixed several UI inconsistencies in the S3 bucket viewer actions" + }, { + "type" : "bugfix", + "description" : "Fix showing stack status notification on opening existing CloudFormation stack (#2157)" + }, { + "type" : "bugfix", + "description" : "Processes using the Step system (e.g. SAM build) can now be stopped (#2418)" + }, { + "type" : "bugfix", + "description" : "Fixed the Remote Lambda Run Configuration failing to load the list of functions if not in active region" + }, { + "type" : "deprecation", + "description" : "2020.1 support will be removed in the next release" + } ] +} \ No newline at end of file diff --git a/.changes/1.26.json b/.changes/1.26.json new file mode 100644 index 0000000000..75c69d5b2f --- /dev/null +++ b/.changes/1.26.json @@ -0,0 +1,29 @@ +{ + "date" : "2021-04-14", + "version" : "1.26", + "entries" : [ { + "type" : "feature", + "description" : "Add support for creating/debugging Golang Lambdas (#649)" + }, { + "type" : "bugfix", + "description" : "Fix breaking run configuration gutter icons when the IDE has no languages installed that support Lambda local runtime (#2504)" + }, { + "type" : "bugfix", + "description" : "Fix issue preventing deployment of CloudFormation templates with empty values (#1498)" + }, { + "type" : "bugfix", + "description" : "Fix cloudformation stack events failing to update after reaching a final state (#2519)" + }, { + "type" : "bugfix", + "description" : "Fix the Local Lambda run configuration always reseting the environemnt variables to defaults when using templates (#2509)" + }, { + "type" : "bugfix", + "description" : "Fix being able to interact with objects from deleted buckets (#1601)" + }, { + "type" : "removal", + "description" : "Remove support for 2020.1" + }, { + "type" : "removal", + "description" : "Lambda gutter icons no longer take deployed Lambdas into account due to accuracy and performance issues" + } ] +} \ No newline at end of file diff --git a/.changes/1.27.json b/.changes/1.27.json new file mode 100644 index 0000000000..5b58fa28fb --- /dev/null +++ b/.changes/1.27.json @@ -0,0 +1,20 @@ +{ + "date" : "2021-05-24", + "version" : "1.27", + "entries" : [ { + "type" : "feature", + "description" : "Add support for AppRunner. Create/delete/pause/resume/deploy and view logs for your AppRunner services." + }, { + "type" : "feature", + "description" : "Add support for building and pushing local images to ECR" + }, { + "type" : "feature", + "description" : "Add support for running/debugging Typescript Lambdas" + }, { + "type" : "bugfix", + "description" : "Fix Rider locking up when right clicking a Lambda in the AWS Explorer with a dotnet runtime in 2021.1" + }, { + "type" : "bugfix", + "description" : "While debugging a Lambda function locally, make sure stopping the debugger will always stop the underlying SAM cli process (#2564)" + } ] +} \ No newline at end of file diff --git a/.changes/1.28.json b/.changes/1.28.json new file mode 100644 index 0000000000..8b6b0d3bcb --- /dev/null +++ b/.changes/1.28.json @@ -0,0 +1,47 @@ +{ + "date" : "2021-07-12", + "version" : "1.28", + "entries" : [ { + "type" : "breaking", + "description" : "Python 2.7 Lambda template removed from New Project Wizard" + }, { + "type" : "feature", + "description" : "Adding the ability to inject credentials/region into existing IntelliJ IDEA and PyCharm Run Configurations (e.g Application, JUnit, Python, PyTest). This requires experiments `aws.feature.javaRunConfigurationExtension` / `aws.feature.pythonRunConfigurationExtension`, see [Enabling Experiments](https://github.com/aws/aws-toolkit-jetbrains/blob/master/README.md#experimental-features)" + }, { + "type" : "feature", + "description" : "Add support for updating tags during SAM deployment" + }, { + "type" : "feature", + "description" : "(Experimental) Adding ability to create a local terminal using the currently selected AWS connection (experiment ID `aws.feature.connectedLocalTerminal`, see [Enabling Experiments](https://github.com/aws/aws-toolkit-jetbrains/blob/master/README.md#experimental-features)) #2151" + }, { + "type" : "feature", + "description" : "Add support for pulling images from ECR" + }, { + "type" : "bugfix", + "description" : "Fix missing text in the View S3 bucket with prefix dialog" + }, { + "type" : "bugfix", + "description" : "Improved performance of listing S3 buckets in certain situations" + }, { + "type" : "bugfix", + "description" : "Fix copying action in CloudWatch Logs Stream and Event Time providing epoch time instead of displayed value" + }, { + "type" : "bugfix", + "description" : "Fix using message bus after project has been closed (Fixes #2615)" + }, { + "type" : "bugfix", + "description" : "Fix S3 bucket viewer actions being triggered by short cuts even if it is not focused" + }, { + "type" : "bugfix", + "description" : "Don't show Lambda run configuration suggestions on Go test code" + }, { + "type" : "bugfix", + "description" : "Fix being unable to create Python 3.8 Image-based Lambdas in New Project wizard" + }, { + "type" : "bugfix", + "description" : "Fixed showing templates that were not for Image-based Lambdas when Image is selected in New Project wizard" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for IDEs based on the 2020.2 platform" + } ] +} \ No newline at end of file diff --git a/.changes/1.29.json b/.changes/1.29.json new file mode 100644 index 0000000000..557175c358 --- /dev/null +++ b/.changes/1.29.json @@ -0,0 +1,11 @@ +{ + "date" : "2021-07-20", + "version" : "1.29", + "entries" : [ { + "type" : "feature", + "description" : "When uploading a file to S3, the content type is now set accoriding to the files extension" + }, { + "type" : "bugfix", + "description" : "Fix being unable to update Lambda configuration if the Image packaging type" + } ] +} \ No newline at end of file diff --git a/.changes/1.30.json b/.changes/1.30.json new file mode 100644 index 0000000000..00d1f3a65b --- /dev/null +++ b/.changes/1.30.json @@ -0,0 +1,23 @@ +{ + "date" : "2021-08-05", + "version" : "1.30", + "entries" : [ { + "type" : "feature", + "description" : "Add ability to view bucket by entering bucket name/URI" + }, { + "type" : "bugfix", + "description" : "Fix CWL last event sorting (#2737)" + }, { + "type" : "bugfix", + "description" : "Fix Go Lambda handler resolving into Go standard library (#2730)" + }, { + "type" : "bugfix", + "description" : "Fix `ActionPlaces.isPopupPlace` error after opening the AWS connection settings menu (#2736)" + }, { + "type" : "bugfix", + "description" : "Fix some warnings due to slow operations on EDT (#2735)" + }, { + "type" : "bugfix", + "description" : "Fix Java Lambda run marker issues and disable runmarker processing in tests and language-injected text fragments" + } ] +} \ No newline at end of file diff --git a/.changes/1.31.json b/.changes/1.31.json new file mode 100644 index 0000000000..3ff21e1b6f --- /dev/null +++ b/.changes/1.31.json @@ -0,0 +1,14 @@ +{ + "date" : "2021-08-17", + "version" : "1.31", + "entries" : [ { + "type" : "feature", + "description" : "Add support for Python 3.9 Lambdas" + }, { + "type" : "bugfix", + "description" : "Fix regression in SAM run configurations using file-based input (#2762)" + }, { + "type" : "bugfix", + "description" : "Fix CloudWatch sorting (#2737)" + } ] +} \ No newline at end of file diff --git a/.changes/1.32.json b/.changes/1.32.json new file mode 100644 index 0000000000..4ecef1d82a --- /dev/null +++ b/.changes/1.32.json @@ -0,0 +1,11 @@ +{ + "date" : "2021-09-07", + "version" : "1.32", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix IDE error about context.module being null (#2776)" + }, { + "type" : "bugfix", + "description" : "Fix NullPointerException calling isInTestSourceContent (#2752)" + } ] +} \ No newline at end of file diff --git a/.changes/1.33.json b/.changes/1.33.json new file mode 100644 index 0000000000..89adf4c5e2 --- /dev/null +++ b/.changes/1.33.json @@ -0,0 +1,32 @@ +{ + "date" : "2021-10-14", + "version" : "1.33", + "entries" : [ { + "type" : "feature", + "description" : "Surface read-only support for hundreds of resources under the Resources node in the AWS Explorer" + }, { + "type" : "feature", + "description" : "Amazon DynamoDB table viewer" + }, { + "type" : "bugfix", + "description" : "Changed error message 'Command did not exist successfully' to 'Command did not exit successfully'" + }, { + "type" : "bugfix", + "description" : "Fixed spelling and grammar in MessagesBundle.properties" + }, { + "type" : "bugfix", + "description" : "Fix not being able to start Rider debugger against a Lambda running on a host ARM machine" + }, { + "type" : "bugfix", + "description" : "Fix SSO login not being triggered when the auth code is invalid (#2796)" + }, { + "type" : "removal", + "description" : "Removed support for 2020.2.x IDEs" + }, { + "type" : "removal", + "description" : "Dropped support for the no longer supported Lambda runtime Python 2.7" + }, { + "type" : "removal", + "description" : "Dropped support for the no longer supported Lambda runtime Node.js 10.x" + } ] +} \ No newline at end of file diff --git a/.changes/1.34.json b/.changes/1.34.json new file mode 100644 index 0000000000..b762d1043f --- /dev/null +++ b/.changes/1.34.json @@ -0,0 +1,14 @@ +{ + "date" : "2021-10-21", + "version" : "1.34", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix issue in Resources where some S3 Buckets fail to open" + }, { + "type" : "bugfix", + "description" : "Fix null exception when view documentation action executed for types with missing doc urls" + }, { + "type" : "bugfix", + "description" : "Fix uncaught exception when a resource does not support LIST in a certain region." + } ] +} \ No newline at end of file diff --git a/.changes/1.35.json b/.changes/1.35.json new file mode 100644 index 0000000000..7cd1d7902e --- /dev/null +++ b/.changes/1.35.json @@ -0,0 +1,35 @@ +{ + "date" : "2021-11-18", + "version" : "1.35", + "entries" : [ { + "type" : "feature", + "description" : "Respect the `duration_seconds` property when assuming a role if set on the profile" + }, { + "type" : "feature", + "description" : "Added 2021.3 support" + }, { + "type" : "feature", + "description" : "Added support for AWS profiles that use the `credential_source` key" + }, { + "type" : "bugfix", + "description" : "Fix Python Lambda gutter icons not generating handler paths relative to the requirements.txt file (#2853)" + }, { + "type" : "bugfix", + "description" : "Fix file changes not being saved before running Local Lambda run configurations (#2889)" + }, { + "type" : "bugfix", + "description" : "Fix incorrect behavior with RDS Secrets Manager Auth when SSH tunneling is enabled (#2781)" + }, { + "type" : "bugfix", + "description" : "Fix copying out of the DynamoDB table viewer copying the in-memory representation instead of displayed value" + }, { + "type" : "bugfix", + "description" : "Fix error about write actions when opening files from the S3 browser (#2913)" + }, { + "type" : "bugfix", + "description" : "Fix NullPointerException on combobox browse components (#2866)" + }, { + "type" : "removal", + "description" : "Dropped support for the no longer supported Lambda runtime .NET Core 2.1" + } ] +} \ No newline at end of file diff --git a/.changes/1.36.json b/.changes/1.36.json new file mode 100644 index 0000000000..bc53f65230 --- /dev/null +++ b/.changes/1.36.json @@ -0,0 +1,5 @@ +{ + "date" : "2021-11-23", + "version" : "1.36", + "entries" : [ ] +} \ No newline at end of file diff --git a/.changes/1.37.json b/.changes/1.37.json new file mode 100644 index 0000000000..319122496b --- /dev/null +++ b/.changes/1.37.json @@ -0,0 +1,14 @@ +{ + "date" : "2022-01-06", + "version" : "1.37", + "entries" : [ { + "type" : "feature", + "description" : "Add SAM Lambda ARM support" + }, { + "type" : "bugfix", + "description" : "Fix plugin deprecation warning in DynamoDB viewer (#2987)" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for IDEs based on the 2020.3 platform" + } ] +} \ No newline at end of file diff --git a/.changes/1.38.json b/.changes/1.38.json new file mode 100644 index 0000000000..a5aea75e33 --- /dev/null +++ b/.changes/1.38.json @@ -0,0 +1,20 @@ +{ + "date" : "2022-02-17", + "version" : "1.38", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix StringIndexOutOfBoundsException (#3025)" + }, { + "type" : "bugfix", + "description" : "Fix regression preventing ECR repository creation" + }, { + "type" : "bugfix", + "description" : "Fix Lambda run configuration exception while setting handler architecture" + }, { + "type" : "bugfix", + "description" : "Fix image-based Lambda debugging for Python 3.6" + }, { + "type" : "removal", + "description" : "Removed support for 2020.3.x IDEs" + } ] +} \ No newline at end of file diff --git a/.changes/1.39.json b/.changes/1.39.json new file mode 100644 index 0000000000..3e9b50a277 --- /dev/null +++ b/.changes/1.39.json @@ -0,0 +1,14 @@ +{ + "date" : "2022-03-03", + "version" : "1.39", + "entries" : [ { + "type" : "feature", + "description" : "Added in 1.37: The toolkit will now offer to open ARNs present in your code editor in your browser" + }, { + "type" : "feature", + "description" : "Added support for .NET 6 runtime for creating and debugging SAM functions" + }, { + "type" : "bugfix", + "description" : "Fix issue where console federation with long-term credentails results in session with no permissions" + } ] +} \ No newline at end of file diff --git a/.changes/1.40.json b/.changes/1.40.json new file mode 100644 index 0000000000..f54b2ace0d --- /dev/null +++ b/.changes/1.40.json @@ -0,0 +1,8 @@ +{ + "date" : "2022-03-07", + "version" : "1.40", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix logged error due to ARN contributor taking too long (#3085)" + } ] +} \ No newline at end of file diff --git a/.changes/1.41.json b/.changes/1.41.json new file mode 100644 index 0000000000..fbbb4b7d96 --- /dev/null +++ b/.changes/1.41.json @@ -0,0 +1,8 @@ +{ + "date" : "2022-03-25", + "version" : "1.41", + "entries" : [ { + "type" : "feature", + "description" : "Adding Go (Golang) as a supported language for code binding generation through the EventBridge Schemas service" + } ] +} \ No newline at end of file diff --git a/.changes/1.42.json b/.changes/1.42.json new file mode 100644 index 0000000000..c0e06d0ac0 --- /dev/null +++ b/.changes/1.42.json @@ -0,0 +1,8 @@ +{ + "date" : "2022-04-13", + "version" : "1.42", + "entries" : [ { + "type" : "feature", + "description" : "Add support for 2022.1" + } ] +} \ No newline at end of file diff --git a/.changes/1.43.json b/.changes/1.43.json new file mode 100644 index 0000000000..c09d0a33c6 --- /dev/null +++ b/.changes/1.43.json @@ -0,0 +1,8 @@ +{ + "date" : "2022-04-14", + "version" : "1.43", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix regression in DataGrip 2022.1 caused by new APIs in the platform (#3125)" + } ] +} \ No newline at end of file diff --git a/.changes/1.44.json b/.changes/1.44.json new file mode 100644 index 0000000000..a2fc7e50da --- /dev/null +++ b/.changes/1.44.json @@ -0,0 +1,32 @@ +{ + "date" : "2022-06-01", + "version" : "1.44", + "entries" : [ { + "type" : "feature", + "description" : "Add warning to indicate time delay in SQS queue deletion" + }, { + "type" : "bugfix", + "description" : "Fixed issue with uncaught exception in resource cache (#3098)" + }, { + "type" : "bugfix", + "description" : "Don't attempt to setup run configurations for test code (#3075)" + }, { + "type" : "bugfix", + "description" : "Fix toolWindow not running in EDT" + }, { + "type" : "bugfix", + "description" : "Handle Lambda pending states while updating function (#2984)" + }, { + "type" : "bugfix", + "description" : "Fix modality issue when opening a CloudWatch log stream in editor (#2991)" + }, { + "type" : "bugfix", + "description" : "Workaround regression with ARN console navigation in JSON files" + }, { + "type" : "bugfix", + "description" : "Fix 'The project directory does not exist!' when creating SAM/Gradle projects when the Android plugin is also installed" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for IDEs based on the 2021.1 platform" + } ] +} \ No newline at end of file diff --git a/.changes/1.45.json b/.changes/1.45.json new file mode 100644 index 0000000000..ae7f916b05 --- /dev/null +++ b/.changes/1.45.json @@ -0,0 +1,17 @@ +{ + "date" : "2022-06-23", + "version" : "1.45", + "entries" : [ { + "type" : "feature", + "description" : "[CodeWhisperer](https://aws.amazon.com/codewhisperer) uses machine learning to generate code suggestions from the existing code and comments in your IDE. Supported languages include: Java, Python, and JavaScript." + }, { + "type" : "feature", + "description" : "Added 2022.2 support" + }, { + "type" : "bugfix", + "description" : "Fix .NET Lambda debugging regression in 2022.1.1" + }, { + "type" : "removal", + "description" : "Removed support for 2021.1.x IDEs" + } ] +} \ No newline at end of file diff --git a/.changes/1.46.json b/.changes/1.46.json new file mode 100644 index 0000000000..8af8364612 --- /dev/null +++ b/.changes/1.46.json @@ -0,0 +1,14 @@ +{ + "date" : "2022-06-28", + "version" : "1.46", + "entries" : [ { + "type" : "feature", + "description" : "Nodejs16.x Lambda runtime support" + }, { + "type" : "bugfix", + "description" : "Fix broken user UI due to 'Enter' handler override (#3193)" + }, { + "type" : "bugfix", + "description" : "Fix SSM plugin install on deb/rpm systems (#3130)" + } ] +} \ No newline at end of file diff --git a/.changes/1.47.json b/.changes/1.47.json new file mode 100644 index 0000000000..7360cf9f86 --- /dev/null +++ b/.changes/1.47.json @@ -0,0 +1,8 @@ +{ + "date" : "2022-07-08", + "version" : "1.47", + "entries" : [ { + "type" : "removal", + "description" : "Remove Cloud Debugging of ECS Services (beta)" + } ] +} \ No newline at end of file diff --git a/.changes/1.48.json b/.changes/1.48.json new file mode 100644 index 0000000000..a13ebf3462 --- /dev/null +++ b/.changes/1.48.json @@ -0,0 +1,8 @@ +{ + "date" : "2022-07-26", + "version" : "1.48", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix to display appropriate error messaging for filtering Cloudwatch Streams using search patterns failures" + } ] +} \ No newline at end of file diff --git a/.changes/1.49.json b/.changes/1.49.json new file mode 100644 index 0000000000..42465d2743 --- /dev/null +++ b/.changes/1.49.json @@ -0,0 +1,14 @@ +{ + "date" : "2022-08-11", + "version" : "1.49", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix IllegalCallableAccessException thrown in several UI panels (#3228)" + }, { + "type" : "bugfix", + "description" : "Fix to stop showing CodeWhisperer's welcome page every time on project start" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for IDEs based on the 2021.2 platform" + } ] +} \ No newline at end of file diff --git a/.changes/1.50.json b/.changes/1.50.json new file mode 100644 index 0000000000..69646898c9 --- /dev/null +++ b/.changes/1.50.json @@ -0,0 +1,17 @@ +{ + "date" : "2022-08-23", + "version" : "1.50", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix opening toolwindow tabs in incorrect thread in Cloudwatch Logs" + }, { + "type" : "bugfix", + "description" : "Fix hitting enter inside braces will produce an extra newline (#3270)" + }, { + "type" : "deprecation", + "description" : "Remove support for deprecated Lambda runtime Python 3.6" + }, { + "type" : "removal", + "description" : "Removed support for 2021.2.x IDEs" + } ] +} \ No newline at end of file diff --git a/.changes/1.51.json b/.changes/1.51.json new file mode 100644 index 0000000000..551d0bfc72 --- /dev/null +++ b/.changes/1.51.json @@ -0,0 +1,14 @@ +{ + "date" : "2022-09-22", + "version" : "1.51", + "entries" : [ { + "type" : "feature", + "description" : "Resources (in AWS Explorer) can list more resource types for EC2, IoT, RDS, Redshift, NetworkManager, and other services" + }, { + "type" : "feature", + "description" : "CodeWhisperer now supports .jsx files" + }, { + "type" : "bugfix", + "description" : "CodeWhisperer fixes" + } ] +} \ No newline at end of file diff --git a/.changes/1.52.json b/.changes/1.52.json new file mode 100644 index 0000000000..415afd81c4 --- /dev/null +++ b/.changes/1.52.json @@ -0,0 +1,17 @@ +{ + "date" : "2022-10-19", + "version" : "1.52", + "entries" : [ { + "type" : "feature", + "description" : "Added 2022.3 support" + }, { + "type" : "bugfix", + "description" : "Fix `credential_process` retrieval when command contains quoted arguments on Windows (#3322)" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for IDEs based on the 2021.3 platform" + }, { + "type" : "bugfix", + "description" : "Fix `java.lang.IllegalStateException: Region provider data is missing default data` (#3264)" + } ] +} diff --git a/.changes/1.53.json b/.changes/1.53.json new file mode 100644 index 0000000000..2deb702ca3 --- /dev/null +++ b/.changes/1.53.json @@ -0,0 +1,14 @@ +{ + "date" : "2022-11-23", + "version" : "1.53", + "entries" : [ { + "type" : "feature", + "description" : "Sync Serverless Application(SAM Accelerate)" + }, { + "type" : "feature", + "description" : "New experiment to allow injection of AWS Connection details (region/credentials) into Golang Run Configurations" + }, { + "type" : "removal", + "description" : "Removed support for 2021.3.x IDEs" + } ] +} \ No newline at end of file diff --git a/.changes/1.54.json b/.changes/1.54.json new file mode 100644 index 0000000000..52cf5be90f --- /dev/null +++ b/.changes/1.54.json @@ -0,0 +1,20 @@ +{ + "date" : "2022-11-28", + "version" : "1.54", + "entries" : [ { + "type" : "feature", + "description" : "Amazon CodeWhisperer now supports JavaScript for Security Scan to catch security vulnerabilities." + }, { + "type" : "feature", + "description" : "Amazon CodeWhisperer recommendations are more context aware. We are removing the overlaps from CodeWhisperer suggestions specifically when the cursor is inside a code block." + }, { + "type" : "feature", + "description" : "Amazon CodeWhisperer now supports TypeScript and C# programming languages." + }, { + "type" : "feature", + "description" : "Amazon CodeWhisperer is now available as a supported feature and no longer an experimental feature." + }, { + "type" : "feature", + "description" : "Amazon CodeWhisperer now adds new access methods with AWS Builder ID and AWS IAM Identity Center to enable and get started." + } ] +} \ No newline at end of file diff --git a/.changes/1.55.json b/.changes/1.55.json new file mode 100644 index 0000000000..df43c7b0b6 --- /dev/null +++ b/.changes/1.55.json @@ -0,0 +1,14 @@ +{ + "date" : "2022-12-01", + "version" : "1.55", + "entries" : [ { + "type" : "feature", + "description" : "Amazon CodeCatalyst: Connect JetBrains to your remote Dev Environments." + }, { + "type" : "feature", + "description" : "Amazon CodeCatalyst: Clone your repositories to your local machine." + }, { + "type" : "feature", + "description" : "Amazon CodeCatalyst: Connect using your AWS Builder ID." + } ] +} \ No newline at end of file diff --git a/.changes/1.56.json b/.changes/1.56.json new file mode 100644 index 0000000000..d938623917 --- /dev/null +++ b/.changes/1.56.json @@ -0,0 +1,20 @@ +{ + "date" : "2022-12-08", + "version" : "1.56", + "entries" : [ { + "type" : "bugfix", + "description" : "Remove redundant calls in certain Gateway UI panels" + }, { + "type" : "bugfix", + "description" : "Fix threading issue while attempting to login to CodeCatalyst" + }, { + "type" : "bugfix", + "description" : "Only list dev environments under projects that users are a member of" + }, { + "type" : "bugfix", + "description" : "Fix 'Learn more' link in Gateway 2022.2" + }, { + "type" : "bugfix", + "description" : "Fix connection issue with CodeCatalyst when user is already logged into CodeWhisperer" + } ] +} \ No newline at end of file diff --git a/.changes/1.57.json b/.changes/1.57.json new file mode 100644 index 0000000000..ec81e16e74 --- /dev/null +++ b/.changes/1.57.json @@ -0,0 +1,14 @@ +{ + "date" : "2022-12-15", + "version" : "1.57", + "entries" : [ { + "type" : "feature", + "description" : "Change reauthentication prompt to be non-distruptive notification." + }, { + "type" : "bugfix", + "description" : "Add do not show again button for CodeWhisperer accountless usage notification" + }, { + "type" : "bugfix", + "description" : "Fix CodeWhisperer status widget is shown even when users are disconnected" + } ] +} \ No newline at end of file diff --git a/.changes/1.58.json b/.changes/1.58.json new file mode 100644 index 0000000000..a023a74495 --- /dev/null +++ b/.changes/1.58.json @@ -0,0 +1,20 @@ +{ + "date" : "2023-01-12", + "version" : "1.58", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer: more responsive Auto-Suggestions" + }, { + "type" : "feature", + "description" : "Added Nodejs18.x Lambda runtime support" + }, { + "type" : "bugfix", + "description" : "Fix regression in requirements.txt detection (#3041)" + }, { + "type" : "bugfix", + "description" : "Fix `com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException when choosing an input template in Lambda Run Configurations` (#3359)" + }, { + "type" : "bugfix", + "description" : "Fix Lambda Python console encoding issue (#2802)" + } ] +} \ No newline at end of file diff --git a/.changes/1.59.json b/.changes/1.59.json new file mode 100644 index 0000000000..a2007dd2ea --- /dev/null +++ b/.changes/1.59.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-01-27", + "version" : "1.59", + "entries" : [ { + "type" : "feature", + "description" : "Added an option to submit feedback for the AWS Toolkit in JetBrains Gateway" + } ] +} \ No newline at end of file diff --git a/.changes/1.60.json b/.changes/1.60.json new file mode 100644 index 0000000000..02c312244e --- /dev/null +++ b/.changes/1.60.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-02-01", + "version" : "1.60", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix Small Dev Environment instance sizes not connecting to the thin clients" + } ] +} \ No newline at end of file diff --git a/.changes/1.61.json b/.changes/1.61.json new file mode 100644 index 0000000000..906d7770bd --- /dev/null +++ b/.changes/1.61.json @@ -0,0 +1,14 @@ +{ + "date" : "2023-02-17", + "version" : "1.61", + "entries" : [ { + "type" : "bugfix", + "description" : "Authenticating through the browser now requires users to manually enter a user verification code for SSO/AWS Builder ID" + }, { + "type" : "bugfix", + "description" : "Fix NPE that may occur when installing the toolkit for the first time (#3433)" + }, { + "type" : "bugfix", + "description" : "Fix network calls cant be made inside read/write action exception thrown from CodeWhisperer (#3423)" + } ] +} \ No newline at end of file diff --git a/.changes/1.62.json b/.changes/1.62.json new file mode 100644 index 0000000000..ea0331729f --- /dev/null +++ b/.changes/1.62.json @@ -0,0 +1,11 @@ +{ + "date" : "2023-03-20", + "version" : "1.62", + "entries" : [ { + "type" : "bugfix", + "description" : "Show friendlier application name when signing in using SSO" + }, { + "type" : "bugfix", + "description" : "Fix confusing experience when attempting to sign in to multiple Builder IDs" + } ] +} \ No newline at end of file diff --git a/.changes/1.63.json b/.changes/1.63.json new file mode 100644 index 0000000000..3103c88afe --- /dev/null +++ b/.changes/1.63.json @@ -0,0 +1,14 @@ +{ + "date" : "2023-03-24", + "version" : "1.63", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix issue where multiple Builder ID entries show up in connection list" + }, { + "type" : "bugfix", + "description" : "Fix temporary deadlock when user fails to complete reauthentication request" + }, { + "type" : "bugfix", + "description" : "Only allow cloning a repository from CodeCatalyst if it's hosted on CodeCatalyst" + } ] +} \ No newline at end of file diff --git a/.changes/1.64.json b/.changes/1.64.json new file mode 100644 index 0000000000..e064c9bb6f --- /dev/null +++ b/.changes/1.64.json @@ -0,0 +1,17 @@ +{ + "date" : "2023-03-29", + "version" : "1.64", + "entries" : [ { + "type" : "breaking", + "description" : "Required SAM CLI upgrade to v1.78.0 to for using Sync Serverless Application option." + }, { + "type" : "feature", + "description" : "Support for RDS MariaDB instances (#3530)" + }, { + "type" : "feature", + "description" : "Added 2023.1 support" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for IDEs based on the 2022.1 platform" + } ] +} \ No newline at end of file diff --git a/.changes/1.65.json b/.changes/1.65.json new file mode 100644 index 0000000000..6d1da0325e --- /dev/null +++ b/.changes/1.65.json @@ -0,0 +1,38 @@ +{ + "date" : "2023-04-13", + "version" : "1.65", + "entries" : [ { + "type" : "feature", + "description" : "[CodeWhisperer]: Introducing \"Stop code scan\" feature where users will be able to stop the ongoing code scan and immediately start a new one. " + }, { + "type" : "feature", + "description" : "[CodeWhisperer]: Automatic import recommendations" + }, { + "type" : "feature", + "description" : "[CodeWhisperer]: Now supports cross region calls." + }, { + "type" : "feature", + "description" : "Attempt to download IDE thin client earlier in the CodeCatalyst Dev Environment connection process" + }, { + "type" : "feature", + "description" : "[CodeWhisperer]: New supported programming languages: C, C++, Go, Kotlin, Php, Ruby, Rust, Scala, Shell, Sql." + }, { + "type" : "bugfix", + "description" : "Include more information in the Dev Environment status tooltip" + }, { + "type" : "bugfix", + "description" : "Provide consistent UX in all Dev Environment wizard variants" + }, { + "type" : "bugfix", + "description" : "Fix 'MissingResourceException: Registry key is not defined'" + }, { + "type" : "bugfix", + "description" : "[CodeWhisperer]: Multiple bug fixes to improve user experience" + }, { + "type" : "removal", + "description" : "Drop support for the Node.js 12.x Lambda runtime" + }, { + "type" : "removal", + "description" : "Drop support for the .NET Core 3.1 Lambda runtime" + } ] +} \ No newline at end of file diff --git a/.changes/1.66.json b/.changes/1.66.json new file mode 100644 index 0000000000..e8437f0432 --- /dev/null +++ b/.changes/1.66.json @@ -0,0 +1,17 @@ +{ + "date" : "2023-04-19", + "version" : "1.66", + "entries" : [ { + "type" : "feature", + "description" : "Display current space and project name on status bar while working in a CodeCatalyst Dev Environment" + }, { + "type" : "feature", + "description" : "Add support for Lambda runtime Python 3.10" + }, { + "type" : "bugfix", + "description" : "Fix `java.lang.Throwable: Invalid html: tag inserted automatically and shouldn't be used` (#3608)" + }, { + "type" : "bugfix", + "description" : "Fix issue where nothing happens when trying to create an empty Dev Environment" + } ] +} \ No newline at end of file diff --git a/.changes/1.67.json b/.changes/1.67.json new file mode 100644 index 0000000000..3efb250b2c --- /dev/null +++ b/.changes/1.67.json @@ -0,0 +1,23 @@ +{ + "date" : "2023-04-27", + "version" : "1.67", + "entries" : [ { + "type" : "feature", + "description" : "Using the least permissive set of scopes for features during BuilderID/SSO login. Using the same connection for multiple features will request additional scopes to be used." + }, { + "type" : "feature", + "description" : "Add support for Lambda Runtime Java17" + }, { + "type" : "bugfix", + "description" : "Fix the Add Connection Dialog box references to the correct documentation pages" + }, { + "type" : "bugfix", + "description" : "Fix thread access during validation of SAM templates" + }, { + "type" : "bugfix", + "description" : "[CodeWhisperer]: login session length should increase to it's expected length. Users will now see less frequent expired sessions." + }, { + "type" : "bugfix", + "description" : "Improve handling of disk errors related to SSO and align folder permissions with AWS CLI" + } ] +} \ No newline at end of file diff --git a/.changes/1.68.json b/.changes/1.68.json new file mode 100644 index 0000000000..400f8e4419 --- /dev/null +++ b/.changes/1.68.json @@ -0,0 +1,35 @@ +{ + "date" : "2023-05-30", + "version" : "1.68", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer supports application wide connections" + }, { + "type" : "feature", + "description" : "CodeWhisperer improves auto-suggestions for java" + }, { + "type" : "bugfix", + "description" : "Fix threading issue preventing SAM Applications from opening in Rider 2023.1" + }, { + "type" : "bugfix", + "description" : "Fix issue reconnecting to CodeWhisperer using an Identity Center directory outside of us-east-1 (#3662)" + }, { + "type" : "bugfix", + "description" : "Fix 'null' is not a connection when authenticating to CodeWhisperer" + }, { + "type" : "bugfix", + "description" : "CodeWhisperer: user is sometimes required to re-login before token expiration" + }, { + "type" : "bugfix", + "description" : "Fix issue where the \"Do not ask again\" option is not respected when switching connections on CodeWhisperer/CodeCatalyst" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for JetBrains Gateway version 2022.2 and version 2022.3" + }, { + "type" : "removal", + "description" : "Remove support for Aurora MySQL v1 (#3356)" + }, { + "type" : "removal", + "description" : "Removed support for 2022.1.x IDEs" + } ] +} \ No newline at end of file diff --git a/.changes/1.69.json b/.changes/1.69.json new file mode 100644 index 0000000000..9e707673c3 --- /dev/null +++ b/.changes/1.69.json @@ -0,0 +1,29 @@ +{ + "date" : "2023-06-13", + "version" : "1.69", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer improves auto-suggestions for python csharp typescript and javascript" + }, { + "type" : "feature", + "description" : "Removed 10 secs delay when connecting to Dev environments of Small Instance Size" + }, { + "type" : "feature", + "description" : "CodeWhisperer: Improve file context fetching logic" + }, { + "type" : "bugfix", + "description" : "Inlay not supported exception in injected editor" + }, { + "type" : "bugfix", + "description" : "fix right context merging not accounting userinput, which result in cases CodeWhisperer still show recommendation where user already type the content of recommendation out thus no character is being inserted by CodeWhisperer" + }, { + "type" : "bugfix", + "description" : "Add error notification to upgrade SAM CLI v1.85-1.86.1 if on windows" + }, { + "type" : "bugfix", + "description" : "Always use AWS smile logo to reduce confusion if users are on the 'New UI' (#3636)" + }, { + "type" : "removal", + "description" : "Remove support for Gateway 2022.2 and 2022.3." + } ] +} \ No newline at end of file diff --git a/.changes/1.70.json b/.changes/1.70.json new file mode 100644 index 0000000000..4677e7481f --- /dev/null +++ b/.changes/1.70.json @@ -0,0 +1,11 @@ +{ + "date" : "2023-06-27", + "version" : "1.70", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer improves auto-suggestions for tsx and jsx" + }, { + "type" : "bugfix", + "description" : "Show re-authenticate prompt when invoking CodeWhisperer APIs while connection expired" + } ] +} \ No newline at end of file diff --git a/.changes/1.71.json b/.changes/1.71.json new file mode 100644 index 0000000000..65da63ca08 --- /dev/null +++ b/.changes/1.71.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-07-06", + "version" : "1.71", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix inproper request format when sending empty supplemental context" + } ] +} \ No newline at end of file diff --git a/.changes/1.72.json b/.changes/1.72.json new file mode 100644 index 0000000000..17dee555c0 --- /dev/null +++ b/.changes/1.72.json @@ -0,0 +1,11 @@ +{ + "date" : "2023-07-11", + "version" : "1.72", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer: Improve suggestion quality with enhanced file context fetching" + }, { + "type" : "bugfix", + "description" : "Fix AWS Lambda configuration window resize (#3657)" + } ] +} \ No newline at end of file diff --git a/.changes/1.73.json b/.changes/1.73.json new file mode 100644 index 0000000000..4841997dc9 --- /dev/null +++ b/.changes/1.73.json @@ -0,0 +1,14 @@ +{ + "date" : "2023-07-19", + "version" : "1.73", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer: Improve Java suggestion quality with enhanced file context fetching" + }, { + "type" : "bugfix", + "description" : "CodeWhisperer: Run read operation in the background thread without runReadAction" + }, { + "type" : "bugfix", + "description" : "CodeWhisperer: Fix an issue where CodeWhisperer would stuck in the invocation state indefinitely" + } ] +} \ No newline at end of file diff --git a/.changes/1.74.json b/.changes/1.74.json new file mode 100644 index 0000000000..51a267a330 --- /dev/null +++ b/.changes/1.74.json @@ -0,0 +1,14 @@ +{ + "date" : "2023-07-25", + "version" : "1.74", + "entries" : [ { + "type" : "feature", + "description" : "Explorer is automatically refreshed with new credentials when they are added to credential file." + }, { + "type" : "feature", + "description" : "Added 2023.2 support" + }, { + "type" : "bugfix", + "description" : "Fix 'No display name is specified for configurable' in 2023.2" + } ] +} \ No newline at end of file diff --git a/.changes/1.75.json b/.changes/1.75.json new file mode 100644 index 0000000000..0b2b514071 --- /dev/null +++ b/.changes/1.75.json @@ -0,0 +1,14 @@ +{ + "date" : "2023-08-03", + "version" : "1.75", + "entries" : [ { + "type" : "feature", + "description" : "Add support for Lambda runtime Python 3.11" + }, { + "type" : "bugfix", + "description" : "codewhisperer: file context fetching not considering file extension correctly" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for JetBrains Gateway version 2023.1 and for for IDEs based on the 2022.2 platform" + } ] +} \ No newline at end of file diff --git a/.changes/1.76.json b/.changes/1.76.json new file mode 100644 index 0000000000..91a42114a2 --- /dev/null +++ b/.changes/1.76.json @@ -0,0 +1,11 @@ +{ + "date" : "2023-08-15", + "version" : "1.76", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer: Improve file context fetching for Python Typescript Javascript source files" + }, { + "type" : "feature", + "description" : "CodeWhisperer: Improve file context fetching for Java test files" + } ] +} \ No newline at end of file diff --git a/.changes/1.77.json b/.changes/1.77.json new file mode 100644 index 0000000000..f9bd340e92 --- /dev/null +++ b/.changes/1.77.json @@ -0,0 +1,11 @@ +{ + "date" : "2023-08-29", + "version" : "1.77", + "entries" : [ { + "type" : "removal", + "description" : "Removed support for 2022.2.x IDEs" + }, { + "type" : "removal", + "description" : "Removed support for Gateway 2023.1" + } ] +} \ No newline at end of file diff --git a/.changes/1.78.json b/.changes/1.78.json new file mode 100644 index 0000000000..14e163cc64 --- /dev/null +++ b/.changes/1.78.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-09-08", + "version" : "1.78", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix 'not recognzied as an ... command' when connecting to CodeCatalyst Dev Environments on Windows" + } ] +} \ No newline at end of file diff --git a/.changes/1.79.json b/.changes/1.79.json new file mode 100644 index 0000000000..a0ce696af8 --- /dev/null +++ b/.changes/1.79.json @@ -0,0 +1,5 @@ +{ + "date" : "2023-09-15", + "version" : "1.79", + "entries" : [ ] +} \ No newline at end of file diff --git a/.changes/1.8-192.json b/.changes/1.8.json similarity index 100% rename from .changes/1.8-192.json rename to .changes/1.8.json diff --git a/.changes/1.80.json b/.changes/1.80.json new file mode 100644 index 0000000000..f5f68a9bca --- /dev/null +++ b/.changes/1.80.json @@ -0,0 +1,14 @@ +{ + "date" : "2023-09-29", + "version" : "1.80", + "entries" : [ { + "type" : "feature", + "description" : "Authentication: When signing in to AWS Builder Id or IAM Identity Center (SSO), verify the device code matches instead of copy-pasting it" + }, { + "type" : "feature", + "description" : "CodeWhisperer: Improve the onboarding experience by showing a new onboarding tutorial to first-time users." + }, { + "type" : "bugfix", + "description" : "Fix issue displaying SSO code on new UI in Windows" + } ] +} \ No newline at end of file diff --git a/.changes/1.81.json b/.changes/1.81.json new file mode 100644 index 0000000000..be8896abfa --- /dev/null +++ b/.changes/1.81.json @@ -0,0 +1,5 @@ +{ + "date" : "2023-09-29", + "version" : "1.81", + "entries" : [ ] +} \ No newline at end of file diff --git a/.changes/1.82.json b/.changes/1.82.json new file mode 100644 index 0000000000..9c376b8912 --- /dev/null +++ b/.changes/1.82.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-10-06", + "version" : "1.82", + "entries" : [ { + "type" : "bugfix", + "description" : "CodeWhisperer: Fixed an issue where the \"Learn CodeWhisperer\" page is shown for Gateway host" + } ] +} \ No newline at end of file diff --git a/.changes/1.83.json b/.changes/1.83.json new file mode 100644 index 0000000000..c4fb96c8d5 --- /dev/null +++ b/.changes/1.83.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-10-13", + "version" : "1.83", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer: improve auto-suggestions for additional languages" + } ] +} \ No newline at end of file diff --git a/.changes/1.84.json b/.changes/1.84.json new file mode 100644 index 0000000000..644863b333 --- /dev/null +++ b/.changes/1.84.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-10-17", + "version" : "1.84", + "entries" : [ { + "type" : "feature", + "description" : "Public preview for CodeWhisperer Enterprise: Enterprise customers can now customize CodeWhisperer to adopt and suggest code based on organization specific codebases." + } ] +} \ No newline at end of file diff --git a/.changes/1.85.json b/.changes/1.85.json new file mode 100644 index 0000000000..511551fefb --- /dev/null +++ b/.changes/1.85.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-10-27", + "version" : "1.85", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer: reduce auto-suggestions when there is immediate right context" + } ] +} \ No newline at end of file diff --git a/.changes/1.86.json b/.changes/1.86.json new file mode 100644 index 0000000000..7a04d8d63c --- /dev/null +++ b/.changes/1.86.json @@ -0,0 +1,17 @@ +{ + "date" : "2023-11-08", + "version" : "1.86", + "entries" : [ { + "type" : "feature", + "description" : "Added the 'Setup Authentication for AWS Toolkit' page" + }, { + "type" : "feature", + "description" : "Added 2023.3 support" + }, { + "type" : "feature", + "description" : "auth: support `sso_session` for profiles in AWS shared ini files" + }, { + "type" : "bugfix", + "description" : "CodeWhisperer: Fix an issue where an IndexOutOfBoundException could be thrown when using CodeWhisperer" + } ] +} \ No newline at end of file diff --git a/.changes/1.87.json b/.changes/1.87.json new file mode 100644 index 0000000000..007d2b15ba --- /dev/null +++ b/.changes/1.87.json @@ -0,0 +1,11 @@ +{ + "date" : "2023-11-10", + "version" : "1.87", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix issue where images in 'Authenticate' panel do not show up" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for JetBrains Gateway version 2023.2 and for for IDEs based on the 2022.3 platform" + } ] +} \ No newline at end of file diff --git a/.changes/1.88.json b/.changes/1.88.json new file mode 100644 index 0000000000..4d9706cd5c --- /dev/null +++ b/.changes/1.88.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-11-17", + "version" : "1.88", + "entries" : [ { + "type" : "bugfix", + "description" : "Fix issue where the toolkit calls the wrong CodeCatalyst service endpoint" + } ] +} \ No newline at end of file diff --git a/.changes/1.89.json b/.changes/1.89.json new file mode 100644 index 0000000000..a55632e863 --- /dev/null +++ b/.changes/1.89.json @@ -0,0 +1,14 @@ +{ + "date" : "2023-11-26", + "version" : "1.89", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer: Uses Generative AI and automated reasoning to rewrite lines of code flagged for security vulnerabilities during a security scan." + }, { + "type" : "feature", + "description" : "CodeWhisperer now supports new IaC languages: JSON, YAML and Terraform." + }, { + "type" : "feature", + "description" : "CodeWhisperer security scans support typescript, csharp, json, yaml, tf and hcl files." + } ] +} \ No newline at end of file diff --git a/.changes/2.0.json b/.changes/2.0.json new file mode 100644 index 0000000000..691b7b5512 --- /dev/null +++ b/.changes/2.0.json @@ -0,0 +1,8 @@ +{ + "date" : "2023-11-28", + "version" : "2.0", + "entries" : [ { + "type" : "feature", + "description" : "Support for Amazon Q, your generative AI–powered assistant designed for work that can be tailored to your business, code, data, and operations." + } ] +} \ No newline at end of file diff --git a/.changes/2.1.json b/.changes/2.1.json new file mode 100644 index 0000000000..6d03aa1369 --- /dev/null +++ b/.changes/2.1.json @@ -0,0 +1,17 @@ +{ + "date" : "2023-12-04", + "version" : "2.1", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer: Simplify Learn More page" + }, { + "type" : "bugfix", + "description" : "CodeWhisperer: Security scans for Java no longer require build artifacts" + }, { + "type" : "bugfix", + "description" : "Amazon Q Transform: Fix an issue where the IDE may freeze after clicking \"Transform\"" + }, { + "type" : "bugfix", + "description" : "Fix JetBrains Gateway specific notifications being shown in non-Gateway IDEs" + } ] +} \ No newline at end of file diff --git a/.changes/2.2.json b/.changes/2.2.json new file mode 100644 index 0000000000..565c5d6087 --- /dev/null +++ b/.changes/2.2.json @@ -0,0 +1,38 @@ +{ + "date" : "2023-12-13", + "version" : "2.2", + "entries" : [ { + "type" : "feature", + "description" : "CodeWhisperer security scans support ruby files." + }, { + "type" : "feature", + "description" : "Use MDE endpoint set by environment variable" + }, { + "type" : "feature", + "description" : "CodeWhisperer security scans now support Go files." + }, { + "type" : "bugfix", + "description" : "Normalize telemetry logging metrics for AmazonQ Transform" + }, { + "type" : "bugfix", + "description" : "Fix telemetry logging for new Amazon Q Code Transform telemetry updates" + }, { + "type" : "bugfix", + "description" : "CodeWhisperer: Increase polling frequency for security scans." + }, { + "type" : "bugfix", + "description" : "Fixed sign out button in the CodeWhisperer panel for Getting Started Page" + }, { + "type" : "bugfix", + "description" : "Fix issue where the CodeWhisperer status bar widget is visible in a remote development environment" + }, { + "type" : "bugfix", + "description" : "Amazon Q Transform: Fix to ensure backend gets necessary dependencies" + }, { + "type" : "removal", + "description" : "Removed support for 2022.3.x IDEs" + }, { + "type" : "removal", + "description" : "Removed support for Gateway 2023.2" + } ] +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-4c31acbd-6839-4b1f-a0ec-7e2c8402e6ff.json b/.changes/next-release/bugfix-4c31acbd-6839-4b1f-a0ec-7e2c8402e6ff.json new file mode 100644 index 0000000000..58f5bcbeca --- /dev/null +++ b/.changes/next-release/bugfix-4c31acbd-6839-4b1f-a0ec-7e2c8402e6ff.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description": "Improved recursion when validating projects for Q Code Transform." +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-cf15b891-593c-4493-b594-8fda9f30d031.json b/.changes/next-release/bugfix-cf15b891-593c-4493-b594-8fda9f30d031.json deleted file mode 100644 index 21a0fdcbec..0000000000 --- a/.changes/next-release/bugfix-cf15b891-593c-4493-b594-8fda9f30d031.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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 deleted file mode 100644 index 66996ce4b4..0000000000 --- a/.changes/next-release/bugfix-d2b78feb-6a6d-4e7f-81dc-af756d3d92a0.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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 deleted file mode 100644 index 4a17f11343..0000000000 --- a/.changes/next-release/bugfix-eaed8849-efa2-4aa5-b236-57ce48eb595c.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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-09c9bb6d-d79b-4bbc-9241-735f0efc1098.json b/.changes/next-release/feature-09c9bb6d-d79b-4bbc-9241-735f0efc1098.json new file mode 100644 index 0000000000..b24285a2ed --- /dev/null +++ b/.changes/next-release/feature-09c9bb6d-d79b-4bbc-9241-735f0efc1098.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "Emit additional CodeTransform telemetry" +} \ 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 deleted file mode 100644 index a30810e4f4..0000000000 --- a/.changes/next-release/feature-12cdfa65-4d7a-4c1e-9f61-04b388fce392.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "feature", - "description" : "Support colons (`:`) in credential profile names" -} \ No newline at end of file diff --git a/.changes/next-release/feature-94919c2c-0d33-4f58-9c8e-f5b8cb661d1f.json b/.changes/next-release/feature-94919c2c-0d33-4f58-9c8e-f5b8cb661d1f.json new file mode 100644 index 0000000000..de48006616 --- /dev/null +++ b/.changes/next-release/feature-94919c2c-0d33-4f58-9c8e-f5b8cb661d1f.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "Amazon Q Transform: Use the IDE Maven runner as a fallback" +} \ No newline at end of file diff --git a/.changes/next-release/feature-f6a70036-5fd8-46f5-b70f-3841a04ca0c1.json b/.changes/next-release/feature-f6a70036-5fd8-46f5-b70f-3841a04ca0c1.json deleted file mode 100644 index b92cd98f57..0000000000 --- a/.changes/next-release/feature-f6a70036-5fd8-46f5-b70f-3841a04ca0c1.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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/.editorconfig b/.editorconfig index acd01cadb9..9498da2db1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -38,11 +38,32 @@ ij_java_use_single_class_imports = true ij_java_while_brace_force = always [{*.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 -disabled_rules = import-ordering ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL ij_kotlin_name_count_to_use_star_import = 2147483647 ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = unset + +[{*.markdown,*.md}] +ij_markdown_force_one_space_after_blockquote_symbol = true +ij_markdown_force_one_space_after_header_symbol = true +ij_markdown_force_one_space_after_list_bullet = true +ij_markdown_force_one_space_between_words = true +ij_markdown_keep_indents_on_empty_lines = false +ij_markdown_max_lines_around_block_elements = 1 +ij_markdown_max_lines_around_header = 1 +ij_markdown_max_lines_between_paragraphs = 1 +ij_markdown_min_lines_around_block_elements = 1 +ij_markdown_min_lines_around_header = 1 +ij_markdown_min_lines_between_paragraphs = 1 + +[*.properties] +ij_properties_align_group_field_declarations = false +ij_properties_keep_blank_lines = false +ij_properties_key_value_delimiter = equals +ij_properties_spaces_around_key_value_delimiter = false [{*.yml,*.yaml}] indent_size = 2 + +[*.gold] +insert_final_newline = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..8d5873b61b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# force git to use unix-style line endings +* text=auto eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c3d877db91..83bac96961 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,5 @@ -* @aws/aws-ides-team \ No newline at end of file +* @aws/aws-ides-team +codewhisperer/ @aws/codewhisperer-team +amazonq/ @aws/aws-mynah +cwc/ @aws/aws-mynah +codemodernizer @aws/elastic-gumby diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..a6dffbb5d0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gradle" + directory: "/" + target-branch: "main" # Don't run on forks, staging, etc. + schedule: + interval: "daily" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0bcc6c5308..829df05f25 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,4 @@ + ## Types of changes @@ -7,30 +8,14 @@ ## Description - -## Motivation and Context - - -## Related Issue(s) - - -## Testing - - - - -## Screenshots (if appropriate) + + ## Checklist - - -- [ ] I have read the **README** document -- [ ] I have read the **CONTRIBUTING** document -- [ ] Local run of `gradlew check` succeeds - [ ] My code follows the code style of this project - [ ] I have added tests to cover my changes -- [ ] All new and existing tests passed -- [ ] A short description of the change has been added to the **CHANGELOG** - +- [ ] A short description of the change has been added to the **[CHANGELOG](https://github.com/aws/aws-toolkit-jetbrains/blob/master/CONTRIBUTING.md#contributing-via-pull-requests)** if the change is customer-facing in the IDE. +- [ ] I have added metrics for my changes (if required) + ## License I confirm that my contribution is made under the terms of the Apache 2.0 license. diff --git a/.github/workflows/mac.yml b/.github/workflows/mac.yml index a5efeef16a..7c9acb14af 100644 --- a/.github/workflows/mac.yml +++ b/.github/workflows/mac.yml @@ -5,9 +5,9 @@ name: Unit Test on: push: - branches: [ master ] + branches: [ main ] pull_request: - branches: [ master, feature/* ] + branches: [ main, feature/* ] jobs: build: @@ -17,10 +17,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build with Gradle diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml new file mode 100644 index 0000000000..bd38ec758e --- /dev/null +++ b/.github/workflows/qodana.yml @@ -0,0 +1,27 @@ +name: Qodana + +on: + workflow_dispatch: + push: + branches: [ main ] + pull_request: + branches: [ main, feature/* ] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + qodana: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: 'Qodana Scan' + uses: JetBrains/qodana-action@v2023.1.0 + with: + cache-default-branch-only: true + - uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: ${{ runner.temp }}/qodana/results/qodana.sarif.json diff --git a/.github/workflows/ssm-integ.yml b/.github/workflows/ssm-integ.yml new file mode 100644 index 0000000000..6f1b82396b --- /dev/null +++ b/.github/workflows/ssm-integ.yml @@ -0,0 +1,38 @@ +# This workflow tests the SSM plugin resolution and installation + +name: SSM Integration Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main, feature/* ] + # PRs only need to run this if the SSM plugin logic has changed + paths: + - 'jetbrains-core/src/software/aws/toolkits/jetbrains/services/ssm/SsmPlugin.kt' + - 'jetbrains-core/it/software/aws/toolkits/jetbrains/services/ssm/SsmPluginTest.kt' + +jobs: + build: + name: ${{ matrix.os }} + + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Support longpaths + run: git config --system core.longpaths true + if: ${{ matrix.os == 'windows-latest' }} + - uses: actions/checkout@v2 + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew :jetbrains-core:integrationTest --info --full-stacktrace --console plain --tests "software.aws.toolkits.jetbrains.services.ssm.SsmPluginTest" diff --git a/.github/workflows/stale-issue-cleanup.yml b/.github/workflows/stale-issue-cleanup.yml new file mode 100644 index 0000000000..ce1ed2b33c --- /dev/null +++ b/.github/workflows/stale-issue-cleanup.yml @@ -0,0 +1,52 @@ +name: "Close stale issues" + +# Controls when the action will run. +on: + # allows doing a manual trigger of the action and running it on a schedule at 10am PT everyday(converted since expression is anchored to UTC) + workflow_dispatch: + schedule: + - cron: "0 17 * * *" + +permissions: {} +jobs: + cleanup: + permissions: + issues: write # to label, comment and close issues (aws-actions/stale-issue-cleanup) + + runs-on: ubuntu-latest + name: Stale issues + steps: + - uses: aws-actions/stale-issue-cleanup@v6 + with: + # Types of issues that will be processed + issue-types: issues + # Setting messages to an empty string will cause the automation to skip + # that category + stale-issue-message: This issue is missing required information and will be closed in 7 days. To keep the issue open, leave a comment. + # this has been added as a placeholder but will likely not be used because the timeline is set as 100 years + ancient-issue-message: This issue will automatically close because it has stalled for 1 year. To keep the issue open, leave a comment. + + # These labels are required + stale-issue-label: closing-soon + exempt-issue-labels: no-autoclose + response-requested-label: needs-response + closed-for-staleness-label: closed-for-staleness + + # Issue timing + days-before-stale: 30 + days-before-close: 7 + # setting this to 100 years to avoid closing valid old issues that are yet to be triaged + days-before-ancient: 36500 + + # If you don't want to mark an issue as being ancient based on a + # threshold of "upvotes", you can set this here. An "upvote" is + # the total number of +1, heart, hooray, and rocket reactions + # on an issue. + minimum-upvotes-to-exempt: 5 + + # Leave this alone, or set to a PAT for the action to use + repo-token: ${{ secrets.GITHUB_TOKEN }} + # Testing/debugging options + loglevel: DEBUG + # Set dry-run to true to not perform label or close actions. + dry-run: false diff --git a/.gitignore b/.gitignore index 1043977aa2..0ac2cbe5b8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,27 @@ Build/ venv/ guitest.log stale_outputs_checked +# sam build directory +.aws-sam/ + +# dotnet version +global.json + +# local dev files +unload-PythonCore-* +unload-aws.toolkit-* +jetbrains-core/bin/ +jetbrains-ultimate/bin/ +resources/bin/ +core/bin +jetbrains-gateway/bin +jetbrains-rider/bin +sdk-codegen/bin +ui-tests/bin + +#CodeWhispererChat + +/jetbrains-rider/testData/NuGet.config +/jetbrains-core/ui/package-lock.json +node_modules +package-lock.json diff --git a/.run/Run IDE - Core [2019.3].run.xml b/.run/Run IDE - Core [2019.3].run.xml deleted file mode 100644 index b3666fc843..0000000000 --- a/.run/Run IDE - Core [2019.3].run.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - false - - - \ No newline at end of file diff --git a/.run/Run IDE - Core [2020.1].run.xml b/.run/Run IDE - Core [2020.1].run.xml deleted file mode 100644 index ced43c7866..0000000000 --- a/.run/Run IDE - Core [2020.1].run.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - false - - - \ No newline at end of file diff --git a/.run/Run IDE - Core [2020.2].run.xml b/.run/Run IDE - Core [2020.2].run.xml deleted file mode 100644 index cb5849e5be..0000000000 --- a/.run/Run IDE - Core [2020.2].run.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - false - - - \ No newline at end of file diff --git a/.run/Run IDE - Core [2023.1].run.xml b/.run/Run IDE - Core [2023.1].run.xml new file mode 100644 index 0000000000..2584b2295d --- /dev/null +++ b/.run/Run IDE - Core [2023.1].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + \ No newline at end of file diff --git a/.run/Run IDE - Core [2023.2].run.xml b/.run/Run IDE - Core [2023.2].run.xml new file mode 100644 index 0000000000..fec48ffc5b --- /dev/null +++ b/.run/Run IDE - Core [2023.2].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + diff --git a/.run/Run IDE - Core [2023.3].run.xml b/.run/Run IDE - Core [2023.3].run.xml new file mode 100644 index 0000000000..fefbdd6a42 --- /dev/null +++ b/.run/Run IDE - Core [2023.3].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + diff --git a/.run/Run IDE - Gateway [2023.3].run.xml b/.run/Run IDE - Gateway [2023.3].run.xml new file mode 100644 index 0000000000..8293576e16 --- /dev/null +++ b/.run/Run IDE - Gateway [2023.3].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + diff --git a/.run/Run IDE - Rider [2019.3].run.xml b/.run/Run IDE - Rider [2019.3].run.xml deleted file mode 100644 index a0a23060e7..0000000000 --- a/.run/Run IDE - Rider [2019.3].run.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - false - - - \ No newline at end of file diff --git a/.run/Run IDE - Rider [2020.1].run.xml b/.run/Run IDE - Rider [2020.1].run.xml deleted file mode 100644 index c4f8eb37a6..0000000000 --- a/.run/Run IDE - Rider [2020.1].run.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - 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 deleted file mode 100644 index f39b43810c..0000000000 --- a/.run/Run IDE - Rider [2020.2].run.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - false - - - \ No newline at end of file diff --git a/.run/Run IDE - Rider [2023.1].run.xml b/.run/Run IDE - Rider [2023.1].run.xml new file mode 100644 index 0000000000..3e13ae5d8f --- /dev/null +++ b/.run/Run IDE - Rider [2023.1].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + \ No newline at end of file diff --git a/.run/Run IDE - Rider [2023.2].run.xml b/.run/Run IDE - Rider [2023.2].run.xml new file mode 100644 index 0000000000..98b8fc97a9 --- /dev/null +++ b/.run/Run IDE - Rider [2023.2].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + diff --git a/.run/Run IDE - Rider [2023.3].run.xml b/.run/Run IDE - Rider [2023.3].run.xml new file mode 100644 index 0000000000..dca8b5a5a4 --- /dev/null +++ b/.run/Run IDE - Rider [2023.3].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + diff --git a/.run/Run IDE - Ultimate [2019.3].run.xml b/.run/Run IDE - Ultimate [2019.3].run.xml deleted file mode 100644 index 14860e668c..0000000000 --- a/.run/Run IDE - Ultimate [2019.3].run.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - false - - - \ No newline at end of file diff --git a/.run/Run IDE - Ultimate [2020.1].run.xml b/.run/Run IDE - Ultimate [2020.1].run.xml deleted file mode 100644 index 6731609977..0000000000 --- a/.run/Run IDE - Ultimate [2020.1].run.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - 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 deleted file mode 100644 index b887740f2e..0000000000 --- a/.run/Run IDE - Ultimate [2020.2].run.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - false - - - \ No newline at end of file diff --git a/.run/Run IDE - Ultimate [2023.1].run.xml b/.run/Run IDE - Ultimate [2023.1].run.xml new file mode 100644 index 0000000000..3780738e22 --- /dev/null +++ b/.run/Run IDE - Ultimate [2023.1].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + \ No newline at end of file diff --git a/.run/Run IDE - Ultimate [2023.2].run.xml b/.run/Run IDE - Ultimate [2023.2].run.xml new file mode 100644 index 0000000000..9c29e2e3a9 --- /dev/null +++ b/.run/Run IDE - Ultimate [2023.2].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + diff --git a/.run/Run IDE - Ultimate [2023.3].run.xml b/.run/Run IDE - Ultimate [2023.3].run.xml new file mode 100644 index 0000000000..0ecbfa1b87 --- /dev/null +++ b/.run/Run IDE - Ultimate [2023.3].run.xml @@ -0,0 +1,25 @@ + + + + + + + + false + true + false + false + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dddcf3fad..745b63a485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,454 @@ +# _2.1_ (2023-12-04) +- **(Feature)** CodeWhisperer: Simplify Learn More page +- **(Bug Fix)** CodeWhisperer: Security scans for Java no longer require build artifacts +- **(Bug Fix)** Amazon Q Transform: Fix an issue where the IDE may freeze after clicking "Transform" +- **(Bug Fix)** Fix JetBrains Gateway specific notifications being shown in non-Gateway IDEs + +# _2.0_ (2023-11-28) +- **(Feature)** Support for Amazon Q, your generative AI–powered assistant designed for work that can be tailored to your business, code, data, and operations. + +# _1.89_ (2023-11-26) +- **(Feature)** CodeWhisperer: Uses Generative AI and automated reasoning to rewrite lines of code flagged for security vulnerabilities during a security scan. +- **(Feature)** CodeWhisperer now supports new IaC languages: JSON, YAML and Terraform. +- **(Feature)** CodeWhisperer security scans support typescript, csharp, json, yaml, tf and hcl files. + +# _1.88_ (2023-11-17) +- **(Bug Fix)** Fix issue where the toolkit calls the wrong CodeCatalyst service endpoint + +# _1.87_ (2023-11-10) +- **(Bug Fix)** Fix issue where images in 'Authenticate' panel do not show up +- **(Deprecation)** An upcoming release will remove support for JetBrains Gateway version 2023.2 and for for IDEs based on the 2022.3 platform + +# _1.86_ (2023-11-08) +- **(Feature)** Added the 'Setup Authentication for AWS Toolkit' page +- **(Feature)** Added 2023.3 support +- **(Feature)** auth: support `sso_session` for profiles in AWS shared ini files +- **(Bug Fix)** CodeWhisperer: Fix an issue where an IndexOutOfBoundException could be thrown when using CodeWhisperer + +# _1.85_ (2023-10-27) +- **(Feature)** CodeWhisperer: reduce auto-suggestions when there is immediate right context + +# _1.84_ (2023-10-17) +- **(Feature)** Public preview for CodeWhisperer Enterprise: Enterprise customers can now customize CodeWhisperer to adopt and suggest code based on organization specific codebases. + +# _1.83_ (2023-10-13) +- **(Feature)** CodeWhisperer: improve auto-suggestions for additional languages + +# _1.82_ (2023-10-06) +- **(Bug Fix)** CodeWhisperer: Fixed an issue where the "Learn CodeWhisperer" page is shown for Gateway host + +# _1.80_ (2023-09-29) +- **(Feature)** Authentication: When signing in to AWS Builder Id or IAM Identity Center (SSO), verify the device code matches instead of copy-pasting it +- **(Feature)** CodeWhisperer: Improve the onboarding experience by showing a new onboarding tutorial to first-time users. +- **(Bug Fix)** Fix issue displaying SSO code on new UI in Windows + +# _1.81_ (2023-09-29) + +# _1.79_ (2023-09-15) + +# _1.78_ (2023-09-08) +- **(Bug Fix)** Fix 'not recognzied as an ... command' when connecting to CodeCatalyst Dev Environments on Windows + +# _1.77_ (2023-08-29) +- **(Removal)** Removed support for 2022.2.x IDEs +- **(Removal)** Removed support for Gateway 2023.1 + +# _1.76_ (2023-08-15) +- **(Feature)** CodeWhisperer: Improve file context fetching for Python Typescript Javascript source files +- **(Feature)** CodeWhisperer: Improve file context fetching for Java test files + +# _1.75_ (2023-08-03) +- **(Feature)** Add support for Lambda runtime Python 3.11 +- **(Bug Fix)** codewhisperer: file context fetching not considering file extension correctly +- **(Deprecation)** An upcoming release will remove support for JetBrains Gateway version 2023.1 and for for IDEs based on the 2022.2 platform + +# _1.74_ (2023-07-25) +- **(Feature)** Explorer is automatically refreshed with new credentials when they are added to credential file. +- **(Feature)** Added 2023.2 support +- **(Bug Fix)** Fix 'No display name is specified for configurable' in 2023.2 + +# _1.73_ (2023-07-19) +- **(Feature)** CodeWhisperer: Improve Java suggestion quality with enhanced file context fetching +- **(Bug Fix)** CodeWhisperer: Run read operation in the background thread without runReadAction +- **(Bug Fix)** CodeWhisperer: Fix an issue where CodeWhisperer would stuck in the invocation state indefinitely + +# _1.72_ (2023-07-11) +- **(Feature)** CodeWhisperer: Improve suggestion quality with enhanced file context fetching +- **(Bug Fix)** Fix AWS Lambda configuration window resize ([#3657](https://github.com/aws/aws-toolkit-jetbrains/issues/3657)) + +# _1.71_ (2023-07-06) +- **(Bug Fix)** Fix inproper request format when sending empty supplemental context + +# _1.70_ (2023-06-27) +- **(Feature)** CodeWhisperer improves auto-suggestions for tsx and jsx +- **(Bug Fix)** Show re-authenticate prompt when invoking CodeWhisperer APIs while connection expired + +# _1.69_ (2023-06-13) +- **(Feature)** CodeWhisperer improves auto-suggestions for python csharp typescript and javascript +- **(Feature)** Removed 10 secs delay when connecting to Dev environments of Small Instance Size +- **(Feature)** CodeWhisperer: Improve file context fetching logic +- **(Bug Fix)** Inlay not supported exception in injected editor +- **(Bug Fix)** fix right context merging not accounting userinput, which result in cases CodeWhisperer still show recommendation where user already type the content of recommendation out thus no character is being inserted by CodeWhisperer +- **(Bug Fix)** Add error notification to upgrade SAM CLI v1.85-1.86.1 if on windows +- **(Bug Fix)** Always use AWS smile logo to reduce confusion if users are on the 'New UI' ([#3636](https://github.com/aws/aws-toolkit-jetbrains/issues/3636)) +- **(Removal)** Remove support for Gateway 2022.2 and 2022.3. + +# _1.68_ (2023-05-30) +- **(Feature)** CodeWhisperer supports application wide connections +- **(Feature)** CodeWhisperer improves auto-suggestions for java +- **(Bug Fix)** Fix threading issue preventing SAM Applications from opening in Rider 2023.1 +- **(Bug Fix)** Fix issue reconnecting to CodeWhisperer using an Identity Center directory outside of us-east-1 ([#3662](https://github.com/aws/aws-toolkit-jetbrains/issues/3662)) +- **(Bug Fix)** Fix 'null' is not a connection when authenticating to CodeWhisperer +- **(Bug Fix)** CodeWhisperer: user is sometimes required to re-login before token expiration +- **(Bug Fix)** Fix issue where the "Do not ask again" option is not respected when switching connections on CodeWhisperer/CodeCatalyst +- **(Deprecation)** An upcoming release will remove support for JetBrains Gateway version 2022.2 and version 2022.3 +- **(Removal)** Remove support for Aurora MySQL v1 ([#3356](https://github.com/aws/aws-toolkit-jetbrains/issues/3356)) +- **(Removal)** Removed support for 2022.1.x IDEs + +# _1.67_ (2023-04-27) +- **(Feature)** Using the least permissive set of scopes for features during BuilderID/SSO login. Using the same connection for multiple features will request additional scopes to be used. +- **(Feature)** Add support for Lambda Runtime Java17 +- **(Bug Fix)** Fix the Add Connection Dialog box references to the correct documentation pages +- **(Bug Fix)** Fix thread access during validation of SAM templates +- **(Bug Fix)** [CodeWhisperer]: login session length should increase to it's expected length. Users will now see less frequent expired sessions. +- **(Bug Fix)** Improve handling of disk errors related to SSO and align folder permissions with AWS CLI + +# _1.66_ (2023-04-19) +- **(Feature)** Display current space and project name on status bar while working in a CodeCatalyst Dev Environment +- **(Feature)** Add support for Lambda runtime Python 3.10 +- **(Bug Fix)** Fix `java.lang.Throwable: Invalid html: tag inserted automatically and shouldn't be used` ([#3608](https://github.com/aws/aws-toolkit-jetbrains/issues/3608)) +- **(Bug Fix)** Fix issue where nothing happens when trying to create an empty Dev Environment + +# _1.65_ (2023-04-13) +- **(Feature)** [CodeWhisperer]: Introducing "Stop code scan" feature where users will be able to stop the ongoing code scan and immediately start a new one. +- **(Feature)** [CodeWhisperer]: Automatic import recommendations +- **(Feature)** [CodeWhisperer]: Now supports cross region calls. +- **(Feature)** Attempt to download IDE thin client earlier in the CodeCatalyst Dev Environment connection process +- **(Feature)** [CodeWhisperer]: New supported programming languages: C, C++, Go, Kotlin, Php, Ruby, Rust, Scala, Shell, Sql. +- **(Bug Fix)** Include more information in the Dev Environment status tooltip +- **(Bug Fix)** Provide consistent UX in all Dev Environment wizard variants +- **(Bug Fix)** Fix 'MissingResourceException: Registry key is not defined' +- **(Bug Fix)** [CodeWhisperer]: Multiple bug fixes to improve user experience +- **(Removal)** Drop support for the Node.js 12.x Lambda runtime +- **(Removal)** Drop support for the .NET Core 3.1 Lambda runtime + +# _1.64_ (2023-03-29) +- **(Breaking Change)** Required SAM CLI upgrade to v1.78.0 to for using Sync Serverless Application option. +- **(Feature)** Support for RDS MariaDB instances ([#3530](https://github.com/aws/aws-toolkit-jetbrains/issues/3530)) +- **(Feature)** Added 2023.1 support +- **(Deprecation)** An upcoming release will remove support for IDEs based on the 2022.1 platform + +# _1.63_ (2023-03-24) +- **(Bug Fix)** Fix issue where multiple Builder ID entries show up in connection list +- **(Bug Fix)** Fix temporary deadlock when user fails to complete reauthentication request +- **(Bug Fix)** Only allow cloning a repository from CodeCatalyst if it's hosted on CodeCatalyst + +# _1.62_ (2023-03-20) +- **(Bug Fix)** Show friendlier application name when signing in using SSO +- **(Bug Fix)** Fix confusing experience when attempting to sign in to multiple Builder IDs + +# _1.61_ (2023-02-17) +- **(Bug Fix)** Authenticating through the browser now requires users to manually enter a user verification code for SSO/AWS Builder ID +- **(Bug Fix)** Fix NPE that may occur when installing the toolkit for the first time ([#3433](https://github.com/aws/aws-toolkit-jetbrains/issues/3433)) +- **(Bug Fix)** Fix network calls cant be made inside read/write action exception thrown from CodeWhisperer ([#3423](https://github.com/aws/aws-toolkit-jetbrains/issues/3423)) + +# _1.60_ (2023-02-01) +- **(Bug Fix)** Fix Small Dev Environment instance sizes not connecting to the thin clients + +# _1.59_ (2023-01-27) +- **(Feature)** Added an option to submit feedback for the AWS Toolkit in JetBrains Gateway + +# _1.58_ (2023-01-12) +- **(Feature)** CodeWhisperer: more responsive Auto-Suggestions +- **(Feature)** Added Nodejs18.x Lambda runtime support +- **(Bug Fix)** Fix regression in requirements.txt detection ([#3041](https://github.com/aws/aws-toolkit-jetbrains/issues/3041)) +- **(Bug Fix)** Fix `com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException when choosing an input template in Lambda Run Configurations` ([#3359](https://github.com/aws/aws-toolkit-jetbrains/issues/3359)) +- **(Bug Fix)** Fix Lambda Python console encoding issue ([#2802](https://github.com/aws/aws-toolkit-jetbrains/issues/2802)) + +# _1.57_ (2022-12-15) +- **(Feature)** Change reauthentication prompt to be non-distruptive notification. +- **(Bug Fix)** Add do not show again button for CodeWhisperer accountless usage notification +- **(Bug Fix)** Fix CodeWhisperer status widget is shown even when users are disconnected + +# _1.56_ (2022-12-08) +- **(Bug Fix)** Remove redundant calls in certain Gateway UI panels +- **(Bug Fix)** Fix threading issue while attempting to login to CodeCatalyst +- **(Bug Fix)** Only list dev environments under projects that users are a member of +- **(Bug Fix)** Fix 'Learn more' link in Gateway 2022.2 +- **(Bug Fix)** Fix connection issue with CodeCatalyst when user is already logged into CodeWhisperer + +# _1.55_ (2022-12-01) +- **(Feature)** Amazon CodeCatalyst: Connect JetBrains to your remote Dev Environments. +- **(Feature)** Amazon CodeCatalyst: Clone your repositories to your local machine. +- **(Feature)** Amazon CodeCatalyst: Connect using your AWS Builder ID. + +# _1.54_ (2022-11-28) +- **(Feature)** Amazon CodeWhisperer now supports JavaScript for Security Scan to catch security vulnerabilities. +- **(Feature)** Amazon CodeWhisperer recommendations are more context aware. We are removing the overlaps from CodeWhisperer suggestions specifically when the cursor is inside a code block. +- **(Feature)** Amazon CodeWhisperer now supports TypeScript and C# programming languages. +- **(Feature)** Amazon CodeWhisperer is now available as a supported feature and no longer an experimental feature. +- **(Feature)** Amazon CodeWhisperer now adds new access methods with AWS Builder ID and AWS IAM Identity Center to enable and get started. + +# _1.53_ (2022-11-23) +- **(Feature)** Sync Serverless Application(SAM Accelerate) +- **(Feature)** New experiment to allow injection of AWS Connection details (region/credentials) into Golang Run Configurations +- **(Removal)** Removed support for 2021.3.x IDEs + +# _1.52_ (2022-10-19) +- **(Feature)** Added 2022.3 support +- **(Bug Fix)** Fix `credential_process` retrieval when command contains quoted arguments on Windows ([#3322](https://github.com/aws/aws-toolkit-jetbrains/issues/3322)) +- **(Deprecation)** An upcoming release will remove support for IDEs based on the 2021.3 platform +- **(Bug Fix)** Fix `java.lang.IllegalStateException: Region provider data is missing default data` ([#3264](https://github.com/aws/aws-toolkit-jetbrains/issues/3264)) + +# _1.51_ (2022-09-22) +- **(Feature)** Resources (in AWS Explorer) can list more resource types for EC2, IoT, RDS, Redshift, NetworkManager, and other services +- **(Feature)** CodeWhisperer now supports .jsx files +- **(Bug Fix)** CodeWhisperer fixes + +# _1.50_ (2022-08-23) +- **(Bug Fix)** Fix opening toolwindow tabs in incorrect thread in Cloudwatch Logs +- **(Bug Fix)** Fix hitting enter inside braces will produce an extra newline ([#3270](https://github.com/aws/aws-toolkit-jetbrains/issues/3270)) +- **(Deprecation)** Remove support for deprecated Lambda runtime Python 3.6 +- **(Removal)** Removed support for 2021.2.x IDEs + +# _1.49_ (2022-08-11) +- **(Bug Fix)** Fix IllegalCallableAccessException thrown in several UI panels ([#3228](https://github.com/aws/aws-toolkit-jetbrains/issues/3228)) +- **(Bug Fix)** Fix to stop showing CodeWhisperer's welcome page every time on project start +- **(Deprecation)** An upcoming release will remove support for IDEs based on the 2021.2 platform + +# _1.48_ (2022-07-26) +- **(Bug Fix)** Fix to display appropriate error messaging for filtering Cloudwatch Streams using search patterns failures + +# _1.47_ (2022-07-08) +- **(Removal)** Remove Cloud Debugging of ECS Services (beta) + +# _1.46_ (2022-06-28) +- **(Feature)** Nodejs16.x Lambda runtime support +- **(Bug Fix)** Fix broken user UI due to 'Enter' handler override ([#3193](https://github.com/aws/aws-toolkit-jetbrains/issues/3193)) +- **(Bug Fix)** Fix SSM plugin install on deb/rpm systems ([#3130](https://github.com/aws/aws-toolkit-jetbrains/issues/3130)) + +# _1.45_ (2022-06-23) +- **(Feature)** [CodeWhisperer](https://aws.amazon.com/codewhisperer) uses machine learning to generate code suggestions from the existing code and comments in your IDE. Supported languages include: Java, Python, and JavaScript. +- **(Feature)** Added 2022.2 support +- **(Bug Fix)** Fix .NET Lambda debugging regression in 2022.1.1 +- **(Removal)** Removed support for 2021.1.x IDEs + +# _1.44_ (2022-06-01) +- **(Feature)** Add warning to indicate time delay in SQS queue deletion +- **(Bug Fix)** Fixed issue with uncaught exception in resource cache ([#3098](https://github.com/aws/aws-toolkit-jetbrains/issues/3098)) +- **(Bug Fix)** Don't attempt to setup run configurations for test code ([#3075](https://github.com/aws/aws-toolkit-jetbrains/issues/3075)) +- **(Bug Fix)** Fix toolWindow not running in EDT +- **(Bug Fix)** Handle Lambda pending states while updating function ([#2984](https://github.com/aws/aws-toolkit-jetbrains/issues/2984)) +- **(Bug Fix)** Fix modality issue when opening a CloudWatch log stream in editor ([#2991](https://github.com/aws/aws-toolkit-jetbrains/issues/2991)) +- **(Bug Fix)** Workaround regression with ARN console navigation in JSON files +- **(Bug Fix)** Fix 'The project directory does not exist!' when creating SAM/Gradle projects when the Android plugin is also installed +- **(Deprecation)** An upcoming release will remove support for IDEs based on the 2021.1 platform + +# _1.43_ (2022-04-14) +- **(Bug Fix)** Fix regression in DataGrip 2022.1 caused by new APIs in the platform ([#3125](https://github.com/aws/aws-toolkit-jetbrains/issues/3125)) + +# _1.42_ (2022-04-13) +- **(Feature)** Add support for 2022.1 + +# _1.41_ (2022-03-25) +- **(Feature)** Adding Go (Golang) as a supported language for code binding generation through the EventBridge Schemas service + +# _1.40_ (2022-03-07) +- **(Bug Fix)** Fix logged error due to ARN contributor taking too long ([#3085](https://github.com/aws/aws-toolkit-jetbrains/issues/3085)) + +# _1.39_ (2022-03-03) +- **(Feature)** Added in 1.37: The toolkit will now offer to open ARNs present in your code editor in your browser +- **(Feature)** Added support for .NET 6 runtime for creating and debugging SAM functions +- **(Bug Fix)** Fix issue where console federation with long-term credentails results in session with no permissions + +# _1.38_ (2022-02-17) +- **(Bug Fix)** Fix StringIndexOutOfBoundsException ([#3025](https://github.com/aws/aws-toolkit-jetbrains/issues/3025)) +- **(Bug Fix)** Fix regression preventing ECR repository creation +- **(Bug Fix)** Fix Lambda run configuration exception while setting handler architecture +- **(Bug Fix)** Fix image-based Lambda debugging for Python 3.6 +- **(Removal)** Removed support for 2020.3.x IDEs + +# _1.37_ (2022-01-06) +- **(Feature)** Add SAM Lambda ARM support +- **(Bug Fix)** Fix plugin deprecation warning in DynamoDB viewer ([#2987](https://github.com/aws/aws-toolkit-jetbrains/issues/2987)) +- **(Deprecation)** An upcoming release will remove support for IDEs based on the 2020.3 platform + +# _1.36_ (2021-11-23) + +# _1.35_ (2021-11-18) +- **(Feature)** Respect the `duration_seconds` property when assuming a role if set on the profile +- **(Feature)** Added 2021.3 support +- **(Feature)** Added support for AWS profiles that use the `credential_source` key +- **(Bug Fix)** Fix Python Lambda gutter icons not generating handler paths relative to the requirements.txt file ([#2853](https://github.com/aws/aws-toolkit-jetbrains/issues/2853)) +- **(Bug Fix)** Fix file changes not being saved before running Local Lambda run configurations ([#2889](https://github.com/aws/aws-toolkit-jetbrains/issues/2889)) +- **(Bug Fix)** Fix incorrect behavior with RDS Secrets Manager Auth when SSH tunneling is enabled ([#2781](https://github.com/aws/aws-toolkit-jetbrains/issues/2781)) +- **(Bug Fix)** Fix copying out of the DynamoDB table viewer copying the in-memory representation instead of displayed value +- **(Bug Fix)** Fix error about write actions when opening files from the S3 browser ([#2913](https://github.com/aws/aws-toolkit-jetbrains/issues/2913)) +- **(Bug Fix)** Fix NullPointerException on combobox browse components ([#2866](https://github.com/aws/aws-toolkit-jetbrains/issues/2866)) +- **(Removal)** Dropped support for the no longer supported Lambda runtime .NET Core 2.1 + +# _1.34_ (2021-10-21) +- **(Bug Fix)** Fix issue in Resources where some S3 Buckets fail to open +- **(Bug Fix)** Fix null exception when view documentation action executed for types with missing doc urls +- **(Bug Fix)** Fix uncaught exception when a resource does not support LIST in a certain region. + +# _1.33_ (2021-10-14) +- **(Feature)** Surface read-only support for hundreds of resources under the Resources node in the AWS Explorer +- **(Feature)** Amazon DynamoDB table viewer +- **(Bug Fix)** Changed error message 'Command did not exist successfully' to 'Command did not exit successfully' +- **(Bug Fix)** Fixed spelling and grammar in MessagesBundle.properties +- **(Bug Fix)** Fix not being able to start Rider debugger against a Lambda running on a host ARM machine +- **(Bug Fix)** Fix SSO login not being triggered when the auth code is invalid ([#2796](https://github.com/aws/aws-toolkit-jetbrains/issues/2796)) +- **(Removal)** Removed support for 2020.2.x IDEs +- **(Removal)** Dropped support for the no longer supported Lambda runtime Python 2.7 +- **(Removal)** Dropped support for the no longer supported Lambda runtime Node.js 10.x + +# _1.32_ (2021-09-07) +- **(Bug Fix)** Fix IDE error about context.module being null ([#2776](https://github.com/aws/aws-toolkit-jetbrains/issues/2776)) +- **(Bug Fix)** Fix NullPointerException calling isInTestSourceContent ([#2752](https://github.com/aws/aws-toolkit-jetbrains/issues/2752)) + +# _1.31_ (2021-08-17) +- **(Feature)** Add support for Python 3.9 Lambdas +- **(Bug Fix)** Fix regression in SAM run configurations using file-based input ([#2762](https://github.com/aws/aws-toolkit-jetbrains/issues/2762)) +- **(Bug Fix)** Fix CloudWatch sorting ([#2737](https://github.com/aws/aws-toolkit-jetbrains/issues/2737)) + +# _1.30_ (2021-08-05) +- **(Feature)** Add ability to view bucket by entering bucket name/URI +- **(Bug Fix)** Fix CWL last event sorting ([#2737](https://github.com/aws/aws-toolkit-jetbrains/issues/2737)) +- **(Bug Fix)** Fix Go Lambda handler resolving into Go standard library ([#2730](https://github.com/aws/aws-toolkit-jetbrains/issues/2730)) +- **(Bug Fix)** Fix `ActionPlaces.isPopupPlace` error after opening the AWS connection settings menu ([#2736](https://github.com/aws/aws-toolkit-jetbrains/issues/2736)) +- **(Bug Fix)** Fix some warnings due to slow operations on EDT ([#2735](https://github.com/aws/aws-toolkit-jetbrains/issues/2735)) +- **(Bug Fix)** Fix Java Lambda run marker issues and disable runmarker processing in tests and language-injected text fragments + +# _1.29_ (2021-07-20) +- **(Feature)** When uploading a file to S3, the content type is now set accoriding to the files extension +- **(Bug Fix)** Fix being unable to update Lambda configuration if the Image packaging type + +# _1.28_ (2021-07-12) +- **(Breaking Change)** Python 2.7 Lambda template removed from New Project Wizard +- **(Feature)** Adding the ability to inject credentials/region into existing IntelliJ IDEA and PyCharm Run Configurations (e.g Application, JUnit, Python, PyTest). This requires experiments `aws.feature.javaRunConfigurationExtension` / `aws.feature.pythonRunConfigurationExtension`, see [Enabling Experiments](https://github.com/aws/aws-toolkit-jetbrains/blob/master/README.md#experimental-features) +- **(Feature)** Add support for updating tags during SAM deployment +- **(Feature)** (Experimental) Adding ability to create a local terminal using the currently selected AWS connection (experiment ID `aws.feature.connectedLocalTerminal`, see [Enabling Experiments](https://github.com/aws/aws-toolkit-jetbrains/blob/master/README.md#experimental-features)) [#2151](https://github.com/aws/aws-toolkit-jetbrains/issues/2151) +- **(Feature)** Add support for pulling images from ECR +- **(Bug Fix)** Fix missing text in the View S3 bucket with prefix dialog +- **(Bug Fix)** Improved performance of listing S3 buckets in certain situations +- **(Bug Fix)** Fix copying action in CloudWatch Logs Stream and Event Time providing epoch time instead of displayed value +- **(Bug Fix)** Fix using message bus after project has been closed (Fixes [#2615](https://github.com/aws/aws-toolkit-jetbrains/issues/2615)) +- **(Bug Fix)** Fix S3 bucket viewer actions being triggered by short cuts even if it is not focused +- **(Bug Fix)** Don't show Lambda run configuration suggestions on Go test code +- **(Bug Fix)** Fix being unable to create Python 3.8 Image-based Lambdas in New Project wizard +- **(Bug Fix)** Fixed showing templates that were not for Image-based Lambdas when Image is selected in New Project wizard +- **(Deprecation)** An upcoming release will remove support for IDEs based on the 2020.2 platform + +# _1.27_ (2021-05-24) +- **(Feature)** Add support for AppRunner. Create/delete/pause/resume/deploy and view logs for your AppRunner services. +- **(Feature)** Add support for building and pushing local images to ECR +- **(Feature)** Add support for running/debugging Typescript Lambdas +- **(Bug Fix)** Fix Rider locking up when right clicking a Lambda in the AWS Explorer with a dotnet runtime in 2021.1 +- **(Bug Fix)** While debugging a Lambda function locally, make sure stopping the debugger will always stop the underlying SAM cli process ([#2564](https://github.com/aws/aws-toolkit-jetbrains/issues/2564)) + +# _1.26_ (2021-04-14) +- **(Feature)** Add support for creating/debugging Golang Lambdas ([#649](https://github.com/aws/aws-toolkit-jetbrains/issues/649)) +- **(Bug Fix)** Fix breaking run configuration gutter icons when the IDE has no languages installed that support Lambda local runtime ([#2504](https://github.com/aws/aws-toolkit-jetbrains/issues/2504)) +- **(Bug Fix)** Fix issue preventing deployment of CloudFormation templates with empty values ([#1498](https://github.com/aws/aws-toolkit-jetbrains/issues/1498)) +- **(Bug Fix)** Fix cloudformation stack events failing to update after reaching a final state ([#2519](https://github.com/aws/aws-toolkit-jetbrains/issues/2519)) +- **(Bug Fix)** Fix the Local Lambda run configuration always reseting the environemnt variables to defaults when using templates ([#2509](https://github.com/aws/aws-toolkit-jetbrains/issues/2509)) +- **(Bug Fix)** Fix being able to interact with objects from deleted buckets ([#1601](https://github.com/aws/aws-toolkit-jetbrains/issues/1601)) +- **(Removal)** Remove support for 2020.1 +- **(Removal)** Lambda gutter icons no longer take deployed Lambdas into account due to accuracy and performance issues + +# _1.25_ (2021-03-10) +- **(Breaking Change)** Minimum SAM CLI version is now 1.0.0 +- **(Feature)** Debugging Python based Lambdas locally now have the Python interactive console enabled (Fixes [#1165](https://github.com/aws/aws-toolkit-jetbrains/issues/1165)) +- **(Feature)** Add a setting for how the AWS profiles notification is shown ([#2408](https://github.com/aws/aws-toolkit-jetbrains/issues/2408)) +- **(Feature)** Deleting resources now requires typing "delete me" instead of the resource name +- **(Feature)** Add support for 2021.1 +- **(Feature)** Allow deploying SAM templates from the CloudFormaton node ([#2166](https://github.com/aws/aws-toolkit-jetbrains/issues/2166)) +- **(Bug Fix)** Improve error messages when properties are not found in templates ([#2449](https://github.com/aws/aws-toolkit-jetbrains/issues/2449)) +- **(Bug Fix)** Fix resource selectors assuming every region has every service ([#2435](https://github.com/aws/aws-toolkit-jetbrains/issues/2435)) +- **(Bug Fix)** Docker is now validated before building the Lambda when running and debugging locally (Fixes [#2418](https://github.com/aws/aws-toolkit-jetbrains/issues/2418)) +- **(Bug Fix)** Fixed several UI inconsistencies in the S3 bucket viewer actions +- **(Bug Fix)** Fix showing stack status notification on opening existing CloudFormation stack ([#2157](https://github.com/aws/aws-toolkit-jetbrains/issues/2157)) +- **(Bug Fix)** Processes using the Step system (e.g. SAM build) can now be stopped ([#2418](https://github.com/aws/aws-toolkit-jetbrains/issues/2418)) +- **(Bug Fix)** Fixed the Remote Lambda Run Configuration failing to load the list of functions if not in active region +- **(Deprecation)** 2020.1 support will be removed in the next release + +# _1.24_ (2021-02-17) +- **(Feature)** RDS serverless databases are now visible in the RDS node in the explorer +- **(Bug Fix)** Fix transient 'Aborted!' message on successful SAM CLI local Lambda execution +- **(Bug Fix)** Fix being unable to open the file browser in the Schemas download panel +- **(Bug Fix)** Fix being unable to type/copy paste into the SAM local run config's template path textbox +- **(Bug Fix)** Fix Secrets Manager-based databse auth throwing NullPointer when editing settings in 2020.3.2 (Fixes [#2403](https://github.com/aws/aws-toolkit-jetbrains/issues/2403)) +- **(Bug Fix)** Fix making an un-needed service call on IDE startup ([#2426](https://github.com/aws/aws-toolkit-jetbrains/issues/2426)) + +# _1.23_ (2021-02-04) +- **(Feature)** Add "Copy S3 URI" to S3 objects ([#2208](https://github.com/aws/aws-toolkit-jetbrains/issues/2208)) +- **(Feature)** Add Dotnet5 Lambda support (Image only) +- **(Feature)** Add option to view past object versions in S3 file editor +- **(Feature)** Nodejs14.x Lambda support +- **(Feature)** Update Lambda max memory to 10240 +- **(Bug Fix)** Re-add environment variable settings to SAM template based run configurations ([#2282](https://github.com/aws/aws-toolkit-jetbrains/issues/2282)) +- **(Bug Fix)** Fix error thrown on profile refresh if removing a profile that uses source_profile ([#2309](https://github.com/aws/aws-toolkit-jetbrains/issues/2309)) +- **(Bug Fix)** Fix NodeJS and Python breakpoints failing to hit sometimes +- **(Bug Fix)** Speed up loading CloudFormation resources +- **(Bug Fix)** Fix not invalidating credentials when a `source_profile` is updated +- **(Bug Fix)** Fix cell based copying in CloudWatch Logs ([#2333](https://github.com/aws/aws-toolkit-jetbrains/issues/2333)) +- **(Bug Fix)** Fix certain S3 buckets being unable to be shown in the explorer ([#2342](https://github.com/aws/aws-toolkit-jetbrains/issues/2342)) +- **(Bug Fix)** Fix exception thrown in the new project wizard when run immediately after the toolkit is installed +- **(Bug Fix)** Fixing issue with SSO refresh locking UI thread ([#2224](https://github.com/aws/aws-toolkit-jetbrains/issues/2224)) + +# _1.22_ (2020-12-01) +- **(Feature)** Container Image Support in Lambda +- **(Bug Fix)** Fix update Lambda code for compiled languages ([#2231](https://github.com/aws/aws-toolkit-jetbrains/issues/2231)) + +# _1.21_ (2020-11-24) +- **(Breaking Change)** Remove support for 2019.3, 2020.1 is the new minimum version +- **(Feature)** Add copy Logical/Physical ID actions to Stack View [#2165](https://github.com/aws/aws-toolkit-jetbrains/issues/2165) +- **(Feature)** Add SQS AWS Explorer node and the ability to send/poll for messages +- **(Feature)** Add the ability to search CloudWatch Logs using CloudWatch Logs Insights +- **(Feature)** Add copy actions to CloudFormation outputs ([#2179](https://github.com/aws/aws-toolkit-jetbrains/issues/2179)) +- **(Feature)** Support for the 2020.3 family of IDEs +- **(Feature)** Add an AWS Explorer ECR node +- **(Bug Fix)** Significantly speed up loading the list of S3 buckets ([#2174](https://github.com/aws/aws-toolkit-jetbrains/issues/2174)) + +# _1.20_ (2020-10-22) +- **(Feature)** Add support for `+` in AWS profile names +- **(Bug Fix)** Fix being unable to use a SSO profile in a credential chain +- **(Bug Fix)** Fix Aurora MySQL 5.7 not showing up in the AWS Explorer +- **(Bug Fix)** Improve IAM RDS connection: Fix Aurora MySQL, detect more error cases, fix database configuration validation throwing when there is no DB name +- **(Deprecation)** 2019.3 support will be removed in the next release + +# _1.19_ (2020-10-07) +- **(Feature)** Add the ability to copy the URL to an S3 object +- **(Feature)** Add support for debugging dotnet 3.1 local lambdas (requires minimum SAM CLI version of 1.4.0) + +# _1.18_ (2020-09-21) +- **(Feature)** Add support for AWS SSO based credential profiles +- **(Feature)** Support colons (`:`) in credential profile names +- **(Feature)** Add support for Lambda runtime java8.al2 +- **(Feature)** Allow connecting to RDS/Redshift databases with temporary IAM AWS credentials or a SecretsManager secret +- **(Feature)** Several enhancements to the UX around connecting to AWS including: + - Making connection settings more visible (now visible in the AWS Explorer) + - Automatically selecting 'default' profile if it exists + - Better visibility of connection validation workflow (more information when unable to connect) + - Handling of default regions on credential profile + - Better UX around partitions + - Adding ability to refresh connection from the UI +- **(Feature)** Save update Lambda code settings +- **(Bug Fix)** Fix several cases where features not supported by the host IDE are shown ([#1980](https://github.com/aws/aws-toolkit-jetbrains/issues/1980)) +- **(Bug Fix)** Start generating SAM project before the IDE is done indexing +- **(Bug Fix)** Fix several uncaught exceptions caused by plugins being installed but not enabled +- **(Bug Fix)** Fix removing a source_profile leading to an IDE error on profile file refresh +- **(Bug Fix)** Fix issue where templates > 51200 bytes would not deploy with "Deploy Serverless Application" ([#1973](https://github.com/aws/aws-toolkit-jetbrains/issues/1973)) +- **(Bug Fix)** Fix the function selection panel not reloading when changing SAM templates ([#955](https://github.com/aws/aws-toolkit-jetbrains/issues/955)) +- **(Bug Fix)** Fix remote terminal start issue on 2020.2 +- **(Bug Fix)** Fix Rider building Lambda into incorrect folders +- **(Bug Fix)** Improved rendering speed of wrapped text in CloudWatch logs and CloudFormation events tables +- **(Bug Fix)** Fix the CloudWatch Logs table breaking when the service returns an exception during loading more entries ([#1951](https://github.com/aws/aws-toolkit-jetbrains/issues/1951)) +- **(Bug Fix)** Improve watching of the AWS profile files to incorporate changes made to the files outisde of the IDE +- **(Bug Fix)** Fix SAM Gradle Hello World syncing twice ([#2003](https://github.com/aws/aws-toolkit-jetbrains/issues/2003)) +- **(Bug Fix)** Quote template parameters when deploying a cloudformation template + # _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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5d30349cb..2bd1c3fef3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,12 +27,12 @@ reported the issue. Please try to include as much information as you can. Detail * Dotnet Framework (Windows) * macOS steps: ``` - brew cask install dotnet-sdk + brew install --cask dotnet-sdk ``` - + * It is recommended dotnet version `5.0.403` and `below`. If your dotnet versions were higher, you should refer to this [link](https://github.com/isen-ng/homebrew-dotnet-sdk-versions). ### Instructions -1. Clone the github repository and run `./gradlew buildPlugin`
(This will produce a plugin zip under `build/distributions`) +1. Clone the github repository and run `./gradlew :intellij:buildPlugin`
(This will produce a plugin zip under `intellij/build/distributions`) 2. In your JetBrains IDE (e.g. IntelliJ) navigate to the `Plugins` preferences and select "Install Plugin from Disk...", navigate to the zip file produced in step 1. 4. You will be prompted to restart your IDE. @@ -40,7 +40,7 @@ reported the issue. Please try to include as much information as you can. Detail Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: -1. You are working against the latest source on the *master* branch. +1. You are working against the latest source on the *main* branch. 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. @@ -53,9 +53,9 @@ To send us a pull request, please: ./gradlew check ``` -4. Generate a change log entry for your change using +4. Generate a change log entry for your change if the change is visible to users of the toolkit in their IDE. ``` - ./gradlew newChange --console plain + ./gradlew :newChange --console plain ``` and following the prompts. Change log entries should describe the change @@ -70,51 +70,75 @@ GitHub provides additional documentation on [forking a repository](https://help. ## Debugging/Running Locally -To test your changes locally, you can run the project from IntelliJ or gradle. +To test your changes locally, you can run the project from IntelliJ or Gradle using the `runIde` tasks. Each build will download the required IDE version and +start it in a sandbox (isolated) configuration. + +### In IDE Approach (Recommended) + +Launch the IDE through your IntelliJ instance using the provided run configurations. +If ran using the Debug feature, a debugger will be auto-attached to the sandbox IDE. + +### Running manually -- **Simple approach:** from the top-level of the repository, run: - ``` - ./gradlew runIde --info - ``` - The `runIde` task automatically downloads the correct version of IntelliJ - Community Edition, builds and installs the plugin, and starts a _new_ - instance of IntelliJ with the built extension. -- To run **Rider or "Ultimate"**, specify the respective gradle target: ``` + ./gradlew jetbrains-core:runIde ./gradlew jetbrains-ultimate:runIde ./gradlew jetbrains-rider:runIde ``` - These targets download the required IDE for testing. - - Do not specify `ALTERNATIVE_IDE`. + +#### Alternative IDE + - To run the plugin in a **specific JetBrains IDE** (and you have it installed), specify the `ALTERNATIVE_IDE` environment variable: ``` - ALTERNATIVE_IDE=/path/to/ide ./gradlew :runIde + ALTERNATIVE_IDE=/path/to/ide ./gradlew :intellij:runIde ``` - This is needed to run PyCharm and WebStorm. - - Notice that the top-level `:runIde` target is always used with `ALTERNATIVE_IDE`. - - See also `alternativeIdePath` in the Gradle IntelliJ Plugin [documentation](https://github.com/JetBrains/gradle-intellij-plugin). -- To run **integration tests**: - ``` - ./gradlew integrationTest - ``` - - Requires valid AWS credentials (take care: it will respect any credentials currently defined in your environmental variables, and fallback to your default AWS profile otherwise). - - Requires [`sam`](https://github.com/awslabs/serverless-application-model) CLI to be on your `$PATH`. + - See also `alternativeIdePath` option in the `runIde` tasks provided by the Gradle IntelliJ Plugin [documentation](https://github.com/JetBrains/gradle-intellij-plugin). + +## Running Tests + +### Unit Tests / Checkstyle + +These tests make no network calls and are safe for anyone to run. + ``` + ./gradlew check + ``` + +### Integration Tests + +It is **NOT** recommended for third party contributors to run these due to they create and mutate AWS resources. + +- Requires valid AWS credentials (take care: it will respect any credentials currently defined in your environmental variables, and fallback to your default AWS profile otherwise). +- Requires [`sam`](https://github.com/awslabs/serverless-application-model) CLI to be on your `$PATH`. - Requires [`cfn-lint`](https://github.com/aws-cloudformation/cfn-python-lint/) CLI to be on your `$PATH`. -- To run **GUI tests**: - ``` - ./gradlew uiTestCore - ``` - - To debug GUI tests, - 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. + ``` + ./gradlew integrationTest + ``` + +### UI Tests + +It is **NOT** recommended for third party contributors to run these due to they create and mutate AWS resources. + +- Requires valid AWS credentials (take care: it will respect any credentials currently defined in your environmental variables, and fallback to your default AWS profile otherwise). +- Requires `sam` CLI to be on your `$PATH`. + ``` + ./gradlew :ui-tests:uiTestCore + ``` + +#### Debug GUI tests + +The sandbox IDE runs with a debug port open (`5005`). In your main IDE, create a Java Remote Debug run configuration and tell it to attach to that port. + +If the tests run too quickly, you can tell the UI tests to wait for the debugger to attach by editing the `suspend.set(false)` to `true` in the tasks +`RunIdeForUiTestTask` in [toolkit-intellij-subplugin Gradle plugin](buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts) ### Logging - Log messages (`LOG.info`, `LOG.error()`, …) by default are written to: ``` - jetbrains-core/build/idea-sandbox/system/log/idea.log - jetbrains-core/build/idea-sandbox/system-test/logs/idea.log # Tests + jetbrains-[subModule]/build/idea-sandbox/system/log/idea.log + jetbrains-[subModule]/build/idea-sandbox/system-test/logs/idea.log # Tests ``` - DEBUG-level log messages are skipped by default. To enable them, add the following line to the _Help_ \> _Debug Log Settings_ dialog in the IDE @@ -122,7 +146,7 @@ To test your changes locally, you can run the project from IntelliJ or gradle. ``` software.aws.toolkits ``` - + **Please be aware that debug level logs may contain more sensitive information. It is not advisable to keep it on nor share log files that contain debug logs** ## Guidelines @@ -133,7 +157,6 @@ To test your changes locally, you can run the project from IntelliJ or gradle. Looking at the existing issues is a great way to find something to contribute on. Any of the [help wanted](https://github.com/aws/aws-toolkit-jetbrains/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) issues is a great place to start. - ## Code of Conduct This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). diff --git a/README.md b/README.md index de7266e4e2..ec0c8f4240 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,29 @@ ![Build Status](https://codebuild.eu-west-1.amazonaws.com/badges?uuid=eyJlbmNyeXB0ZWREYXRhIjoiekhxeERIMmNLSkNYUktnUFJzUVJucmJqWnFLMGlpNXJiNE1LLzVWV3B1QUpSSkhCS04veHZmUGxZZ0ZmZlRzYjJ3T1VtVEs1b3JxbWNVOHFOeFJDOTAwPSIsIml2UGFyYW1ldGVyU3BlYyI6ImZXNW5KaytDRGNLdjZuZDgiLCJtYXRlcmlhbFNldFNlcmlhbCI6MX0%3D&branch=master) [![Coverage](https://img.shields.io/codecov/c/github/aws/aws-toolkit-jetbrains/master.svg)](https://codecov.io/gh/aws/aws-toolkit-jetbrains/branch/master) -[![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) [![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 -AWS Toolkit for JetBrains - a plugin for interacting with AWS from JetBrains IDEs. The plugin includes features that -make it easier to write applications on [Amazon Web Services](https://aws.amazon.com/) using a JetBrains IDE. +AWS Toolkit for JetBrains is a plugin for JetBrains IDEs that +make it easier to write applications built on [Amazon Web Services](https://aws.amazon.com/) -This is an open source project because we want you to be involved. We love issues, feature requests, code reviews, pull -requests or any positive contribution. See [CONTRIBUTING](CONTRIBUTING.md) for how to help. +The AWS Toolkit for JetBrains is open source because we want you to be involved. We appreciate issues, feature requests, pull +requests, code reviews or any other contributions. ## Feedback We want your feedback! -- Upvote 👍 [feature requests](https://github.com/aws/aws-toolkit-jetbrains/issues?q=is%3Aissue+is%3Aopen+label%3Afeature-request+sort%3Areactions-%2B1-desc) -- [Ask a question](https://github.com/aws/aws-toolkit-jetbrains/issues/new?labels=guidance&template=guidance_request.md) +- Vote on [feature requests](https://github.com/aws/aws-toolkit-jetbrains/issues?q=is%3Aissue+is%3Aopen+label%3Afeature-request+sort%3Areactions-%2B1-desc). Votes help us drive prioritization of features - [Request a new feature](https://github.com/aws/aws-toolkit-jetbrains/issues/new?labels=feature-request&template=feature_request.md) +- [Ask a question](https://github.com/aws/aws-toolkit-jetbrains/issues/new?labels=guidance&template=guidance_request.md) - [File an issue](https://github.com/aws/aws-toolkit-jetbrains/issues/new?labels=bug&template=bug_report.md) +- Code contributions. See [our contributing guide](CONTRIBUTING.md) for how to get started. -## Requirements -Supported IDEs: -* IntelliJ Community/Ultimate 2019.2+ -* PyCharm Community/Professional 2019.2+ -* Rider 2019.2+ -* WebStorm 2019.2+ +## Supported IDEs +All JetBrains IDEs 2023.1+ ## Installation @@ -35,7 +31,7 @@ See [Installing the AWS Toolkit for JetBrains](https://docs.aws.amazon.com/conso To use this AWS Toolkit, you will first need an AWS account, a user within that account, and an access key for that user. To use the AWS Toolkit to do AWS serverless application development and to run/debug AWS Lambda functions locally, -you will also need to install the AWS CLI, Docker, and the AWS SAM CLI. The preceding link covers setting up all of +you will also need to install the AWS CLI, Docker, and the AWS SAM CLI. The installation guide covers setting up all of these prerequisites. ### EAP Builds @@ -46,27 +42,26 @@ In order to opt-in: going to **Plugins->Gear Icon->Manage Plugin Repositories** and adding the URL to the list * Check for updates. -### From Source +### Installing From Source Please see [CONTRIBUTING](CONTRIBUTING.md#building-from-source) for instructions. ## Features ### General -Features that don't relate to a specific AWS service. - -* **Credential management** - the ability to select how you want to authenticate with AWS, management of several -credential types and the ability to easily switch between profiles. -[Learn More](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/credentials) -* **Region management** - the ability to switch between viewing resources in different AWS regions. -[Learn More](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/regions) * **AWS Resource Explorer** - tree-view of AWS resources available in your selected account/region. This does not represent all resources available in your account, only a sub-set of those resource types supported by the plugin. [Learn More](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/aws-explorer) +* **Authentication** - Connect to AWS using static credentials, credential process, AWS Builder ID or AWS SSO. [Learn more about +authentication options](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/credentials) ### Services +#### ![CloudFormation][cloudformation-icon] AWS CloudFormation +* View events, resources, and outputs for your CloudFormation stacks +#### ![CloudWatch Logs][cloudwatch-logs-icon] CloudWatch Logs +* View and search your CloudWatch log streams #### ![AWS Lambda][lambda-icon] AWS Lambda Many of these features require the [AWS SAM CLI](https://github.com/awslabs/aws-sam-cli) to be installed, see the @@ -75,8 +70,6 @@ installation of the SAM CLI. **SAM features support Java, Python, Node.js, and .NET Core** -* **New Project Wizard** - Get started quickly by using one of the quickstart serverless application templates. -[Learn More](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/new-project) * **Run/Debug Local Lambda Functions** - Locally test and step-through debug functions in a Lambda-like execution environment provided by the SAM CLI. [Learn More](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/lambda-local) @@ -84,30 +77,39 @@ environment provided by the SAM CLI. [Learn More](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/lambda-remote) * **Package & Deploy Lambda Functions** - Ability to package a Lambda function zip and create a remote lambda [Learn More](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/lambda-deploy) -* **Deploy SAM-based Applications** - Package, deploy & track SAM-based applications +* **Sync SAM-based Applications** - Sync & track SAM-based applications [Learn More](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/sam-deploy) -*NB: Python-only features are available in both PyCharm and IntelliJ with the +*Note: Python features are available in both PyCharm and IntelliJ with the [Python Plugin](https://www.jetbrains.com/help/idea/plugin-overview.html) installed.* -## Roadmap +#### ![Amazon Redshift][redshift-icon] Amazon RDS/Redshift +* Connect to RDS/Redshift databases using temporary credentials with IAM/SecretsManager, no copy paste required + +*Note: database features require using a paid JetBrains product* +#### ![Amazon S3][s3-icon] Amazon S3 +* View and manage your S3 buckets +* Upload/Download to from buckets +* [Learn more](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/s3-tasks) + +### Experimental Features -The best view of our long-term road-map is by looking the upcoming Release -[Milestones](https://github.com/aws/aws-toolkit-jetbrains/milestones). +Sometimes we'll introduce experimental features that we're trying out. These may have bugs, usability problems or may not be fully functional, and because these +aren't ready for prime-time we'll hide them behind an experimental feature flag. -In addition to GitHub's built-in [Projects](https://github.com/aws/aws-toolkit-jetbrains/projects) and -[Milestones](https://github.com/aws/aws-toolkit-jetbrains/milestones) we use [ZenHub](https://www.zenhub.com) to help: -* manage our back-log -* prioritize features -* estimate issues -* create sprint-boards +Experimental features can be enabled in the settings/preferences +(`Settings -> Tools -> AWS -> Experimental Features`) or via the Addtional Settings (![Gear Icon][gear-icon]) in the AWS Explorer Tool Window. -To enable these enhanced views can sign-up for ZenHub (using your GitHub account - it's free), install -the ZenHub [extension](https://www.zenhub.com/extension) for your browser and then navigate to the -[ZebHub](https://github.com/aws/aws-toolkit-jetbrains#zenhub) tab in the toolkit repository. +Please note that experimental features may be disabled / removed at any time. ## Licensing The plugin is distributed according to the terms outlined in our [LICENSE](LICENSE). [lambda-icon]: jetbrains-core/resources/icons/resources/LambdaFunction.svg +[s3-icon]: jetbrains-core/resources/icons/resources/S3Bucket.svg +[cloudwatch-logs-icon]: jetbrains-core/resources/icons/resources/cloudwatchlogs/CloudWatchLogs.svg +[cloudformation-icon]: jetbrains-core/resources/icons/resources/CloudFormationStack.svg +[redshift-icon]: jetbrains-core/resources/icons/resources/Redshift.svg +[find-action]: https://www.jetbrains.com/help/idea/searching-everywhere.html#search_actions +[gear-icon]: https://raw.githubusercontent.com/JetBrains/intellij-community/master/platform/icons/src/general/gear.svg diff --git a/build.gradle b/build.gradle deleted file mode 100644 index b54eebb066..0000000000 --- a/build.gradle +++ /dev/null @@ -1,355 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// 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 - -buildscript { - repositories { - maven { url "https://plugins.gradle.org/m2/" } - mavenCentral() - jcenter() - } - dependencies { - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" - classpath "gradle.plugin.org.jetbrains.intellij.plugins:gradle-intellij-plugin:$ideaPluginVersion" - classpath "com.adarshr:gradle-test-logger-plugin:1.7.0" - } -} - -plugins { - id "de.undercouch.download" version "4.1.1" apply false -} - -apply from: 'intellijJVersions.gradle' - -def ideVersion = shortenVersion(resolveIdeProfileName()) - -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/" } -} - -allprojects { - repositories { - mavenLocal() - mavenCentral() - jcenter() - } - - apply plugin: "com.adarshr.test-logger" - apply plugin: 'java' - apply plugin: 'jacoco' - - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - - tasks.withType(JavaExec) { - systemProperty("aws.toolkits.enableTelemetry", false) - } - - tasks.withType(org.jetbrains.intellij.tasks.RunIdeTask) { - intellij { - if (System.env.ALTERNATIVE_IDE) { - 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 ==~ /.*[\/\\] *$/ - ? " (HINT: remove trailing slash '/')" - : ": ${System.env.ALTERNATIVE_IDE}")) - } - } - } - } - - configurations { - 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" - } -} - -// Kotlin plugin seems to be bugging out when there are no kotlin sources -configure(subprojects - project(":telemetry-client")) { - apply plugin: 'kotlin' - - sourceSets { - integrationTest { - kotlin.srcDir 'it' - } - } -} - -subprojects { - group = parent.group - version = parent.version - - apply plugin: 'java' - apply plugin: 'idea' - - sourceSets { - 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 = SourceUtils.findFolders(project, "it", ideVersion) - resources.srcDirs = SourceUtils.findFolders(project, "it-resources", ideVersion) - } - } - - configurations { - testArtifacts - - integrationTestImplementation.extendsFrom testImplementation - integrationTestRuntimeOnly.extendsFrom testRuntimeOnly - } - - dependencies { - 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" - } - - testlogger { - showFullStackTraces true - showStandardStreams true - showPassedStandardStreams false - showSkippedStandardStreams true - showFailedStandardStreams true - } - - test { - jacoco { - // don't instrument sdk, icons, ktlint, etc. - includes = ["software.aws.toolkits.*"] - excludes = ["software.aws.toolkits.ktlint.*"] - } - - reports { - junitXml.enabled = true - html.enabled = true - } - } - - idea { - module { - sourceDirs += sourceSets.main.java.srcDirs - resourceDirs += sourceSets.main.resources.srcDirs - testSourceDirs += file("tst-$ideVersion") - testResourceDirs += file("tst-resources-$ideVersion") - - sourceDirs -= file("it") - testSourceDirs += file("it") - testSourceDirs += file("it-$ideVersion") - - resourceDirs -= file("it-resources") - testResourceDirs += file("it-resources") - testResourceDirs += file("it-resources-$ideVersion") - } - } - - task integrationTest(type: Test) { - group = LifecycleBasePlugin.VERIFICATION_GROUP - description = "Runs the integration tests." - testClassesDirs = sourceSets.integrationTest.output.classesDirs - classpath = sourceSets.integrationTest.runtimeClasspath - - jacoco { - // don't instrument sdk, icons, ktlint, etc. - includes = ["software.aws.toolkits.*"] - excludes = ["software.aws.toolkits.ktlint.*"] - } - - project.plugins.withId("org.jetbrains.intellij") { - systemProperty("log.dir", "${project.intellij.sandboxDirectory}-test/logs") - } - - 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" - } - - // Force us to compile the integration tests even during check even though we don't run them - check.dependsOn(integrationTestClasses) - - task testJar(type: Jar) { - baseName = "${project.name}-test" - from sourceSets.test.output - from sourceSets.integrationTest.output - } - - artifacts { - testArtifacts testJar - } - - // Remove the tasks added in by gradle-intellij-plugin so that we don't publish/verify multiple times - project.afterEvaluate { - removeTask(tasks, org.jetbrains.intellij.tasks.PublishTask) - removeTask(tasks, org.jetbrains.intellij.tasks.VerifyPluginTask) - removeTask(tasks, org.jetbrains.intellij.tasks.BuildSearchableOptionsTask) - } -} - -configurations { - ktlint -} - -def removeTask(TaskContainer tasks, Class takeType) { - tasks.withType(takeType).configureEach { - setEnabled(false) - } -} - -apply plugin: 'org.jetbrains.intellij' -apply plugin: 'toolkit-change-log' - -intellij { - version ideSdkVersion("IC") - pluginName 'aws-jetbrains-toolkit' - updateSinceUntilBuild false - downloadSources = System.getenv("CI") == null -} - -prepareSandbox { - tasks.findByPath(":jetbrains-rider:prepareSandbox")?.collect { - from(it) - } -} - -publishPlugin { - token publishToken - channels publishChannel ? publishChannel.split(',').collect { it.trim() } : [] -} - -tasks.register('generateChangeLog', GenerateGithubChangeLog) { - changeLogFile = project.file("CHANGELOG.md") -} - -task ktlint(type: JavaExec, group: "verification") { - description = "Check Kotlin code style." - classpath = configurations.ktlint - main = "com.pinterest.ktlint.Main" - - def isWindows = System.properties['os.name'].toLowerCase().contains('windows') - - def toInclude = project.rootDir.relativePath(project.projectDir) + "/**/*.kt" - def toExclude = project.rootDir.relativePath(new File(project.projectDir, "jetbrains-rider")) + "/**/*.Generated.kt" - - if (isWindows) { - toInclude = toInclude.replace("/", "\\") - toExclude = toExclude.replace("/", "\\") - } - - args "-v", toInclude, "!${toExclude}", "!/**/generated-src/**/*.kt" - - inputs.files(project.fileTree(dir: ".", include: "**/*.kt")) - outputs.dir("${project.buildDir}/reports/ktlint/") -} - -task validateLocalizedMessages(group: "verification") { - doLast { - BufferedReader files = Files.newBufferedReader(Paths.get("${project.rootDir}/resources/resources/software/aws/toolkits/resources/localized_messages.properties")) - files - .lines() - .map({ item -> - if (item == null || item.isEmpty()) { - return "" - } - String[] chunks = item.split("=") - if (chunks.length <= 1) { - return "" - } else { - return chunks[0] - } - }) - .filter({ item -> !item.isEmpty() }) - .reduce({ item1, item2 -> - if (item1 > item2) { - throw new GradleException("localization file is not sorted:" + item1 + " > " + item2) - } - - return item2 - }) - } -} - -check.dependsOn ktlint -check.dependsOn validateLocalizedMessages -check.dependsOn verifyPlugin - -task coverageReport(type: JacocoReport) { - executionData fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec") - - getAdditionalSourceDirs().from(subprojects.sourceSets.main.java.srcDirs) - getSourceDirectories().from(subprojects.sourceSets.main.java.srcDirs) - getClassDirectories().from(subprojects.sourceSets.main.output.classesDirs) - - reports { - html.enabled true - xml.enabled true - } -} -subprojects.forEach { - coverageReport.mustRunAfter(it.tasks.withType(Test)) -} -check.dependsOn coverageReport - -// Workaround for runIde being defined in multiple projects, if we request the root project runIde, "alias" it to -// community edition -if (gradle.startParameter.taskNames.contains("runIde")) { - // Only disable this if running from root project - if (gradle.startParameter.projectDir == project.rootProject.rootDir - || System.properties.containsKey("idea.gui.tests.gradle.runner")) { - println("Top level runIde selected, excluding sub-projects' runIde") - gradle.taskGraph.whenReady { graph -> - graph.allTasks.forEach { - if (it.name == "runIde" && - it.project != project(':jetbrains-core')) { - it.enabled = false - } - } - } - } -} - -dependencies { - implementation project(':jetbrains-ultimate') - project.findProject(':jetbrains-rider')?.collect { - implementation it - } - - ktlint "com.pinterest:ktlint:$ktlintVersion" - ktlint project(":ktlint-rules") -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000000..45b784988b --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,80 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import org.jetbrains.gradle.ext.ProjectSettings +import org.jetbrains.gradle.ext.TaskTriggersConfig +import software.aws.toolkits.gradle.changelog.tasks.GenerateGithubChangeLog + +plugins { + id("base") + id("toolkit-changelog") + id("toolkit-jacoco-report") + id("org.jetbrains.gradle.plugin.idea-ext") +} + +allprojects { + repositories { + val codeArtifactUrl: Provider = providers.environmentVariable("CODEARTIFACT_URL") + val codeArtifactToken: Provider = providers.environmentVariable("CODEARTIFACT_AUTH_TOKEN") + if (codeArtifactUrl.isPresent && codeArtifactToken.isPresent) { + maven { + url = uri(codeArtifactUrl.get()) + credentials { + username = "aws" + password = codeArtifactToken.get() + } + } + } + mavenCentral() + gradlePluginPortal() + } + + configurations.all { + resolutionStrategy { + failOnDynamicVersions() + failOnChangingVersions() + } + } +} + +val generateChangeLog = tasks.register("generateChangeLog") { + changeLogFile.set(project.file("CHANGELOG.md")) +} + +tasks.createRelease.configure { + mustRunAfter(generateChangeLog) + + releaseVersion.set(providers.gradleProperty("toolkitVersion")) +} + +dependencies { + aggregateCoverage(project(":intellij")) + aggregateCoverage(project(":ui-tests")) +} + +tasks.register("runIde") { + doFirst { + throw GradleException("Use project specific runIde command, i.e. :jetbrains-core:runIde, :intellij:runIde") + } +} + +if (idea.project != null) { // may be null during script compilation + idea { + project { + settings { + taskTriggers { + afterSync(":sdk-codegen:generateSdks") + afterSync(":jetbrains-core:generateTelemetry") + } + } + } + } +} + +fun org.gradle.plugins.ide.idea.model.IdeaProject.settings(configuration: ProjectSettings.() -> Unit) = (this as ExtensionAware).configure(configuration) +fun ProjectSettings.taskTriggers(action: TaskTriggersConfig.() -> Unit, ) = (this as ExtensionAware).extensions.configure("taskTriggers", action) + +// is there a better way to do this? +// coverageReport has implicit dependency on 'test' outputs since the task outputs the test.exec file +tasks.coverageReport { + mustRunAfter(rootProject.subprojects.map { it.tasks.withType() }) +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index f4d883d576..49cf19d524 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,66 +1,39 @@ -// 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 - -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() + 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() + props.entries.forEach { project.extensions.add(it.key.toString(), it.value) } } 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` + `kotlin-dsl` } -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") +// Note: We can't use our standard source layout due to https://github.com/gradle/gradle/issues/14310 - testImplementation("org.assertj:assertj-core:$assertjVersion") - testImplementation("junit:junit:$junitVersion") - testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion") - testImplementation("org.mockito:mockito-core:$mockitoVersion") +dependencies { + implementation(libs.jacoco) + implementation(libs.aws.codeGen) + implementation(libs.bundles.jackson) + implementation(libs.commonmark) + implementation(libs.gradlePlugin.detekt) + implementation(libs.gradlePlugin.intellij) + implementation(libs.gradlePlugin.kotlin) + implementation(libs.gradlePlugin.testLogger) + implementation(libs.gradlePlugin.testRetry) + implementation(libs.jgit) + + testImplementation(libs.assertj) + testImplementation(libs.junit4) + testImplementation(libs.bundles.mockito) + + testRuntimeOnly(libs.junit5.jupiterVintage) } -gradlePlugin { - plugins { - create("changeLog") { - id = "toolkit-change-log" - implementationClass = "toolkits.gradle.changelog.ChangeLogPlugin" - } - } +tasks.test { + useJUnitPlatform() } diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 0000000000..2517d7c108 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,48 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +val codeArtifactMavenRepo = fun RepositoryHandler.(): MavenArtifactRepository? { + val codeArtifactUrl: Provider = providers.environmentVariable("CODEARTIFACT_URL") + val codeArtifactToken: Provider = providers.environmentVariable("CODEARTIFACT_AUTH_TOKEN") + return if (codeArtifactUrl.isPresent && codeArtifactToken.isPresent) { + maven { + url = uri(codeArtifactUrl.get()) + credentials { + username = "aws" + password = codeArtifactToken.get() + } + } + } else { + null + } +}.also { + pluginManagement { + repositories { + it() + gradlePluginPortal() + } + } +} + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + + apply(from = "../kotlinResolution.settings.gradle.kts") + } + } + + repositories { + codeArtifactMavenRepo() + mavenCentral() + gradlePluginPortal() + maven { + url = uri("https://oss.sonatype.org/content/repositories/snapshots/") + content { + // only allowed to pull snapshots of gradle-intellij-plugin from here + includeModule("org.jetbrains.intellij", "org.jetbrains.intellij.gradle.plugin") + includeModule("org.jetbrains.intellij.plugins", "gradle-intellij-plugin") + } + } + } +} diff --git a/buildSrc/src/SourcesUtils.kt b/buildSrc/src/SourcesUtils.kt deleted file mode 100644 index d34e6e182a..0000000000 --- a/buildSrc/src/SourcesUtils.kt +++ /dev/null @@ -1,50 +0,0 @@ -// 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/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt new file mode 100644 index 0000000000..7f5e6099cf --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.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.gradle + +import org.eclipse.jgit.api.Git +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import software.aws.toolkits.gradle.intellij.IdeVersions +import java.io.IOException +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion as KotlinVersionEnum + +/** + * Only run the given block if this build is running within a CI system (e.g. GitHub actions, CodeBuild etc) + */ +fun Project.ciOnly(block: () -> Unit) { + if (isCi()) { + block() + } +} + +fun Project.isCi() : Boolean = providers.environmentVariable("CI").isPresent + +fun Project.jvmTarget(): Provider = withCurrentProfileName { + JavaVersion.VERSION_17 +} + +// https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#other-bundled-kotlin-libraries +fun Project.kotlinTarget(): Provider = withCurrentProfileName { + when (it) { + "2022.3" -> KotlinVersionEnum.KOTLIN_1_7 + "2023.1", "2023.2" -> KotlinVersionEnum.KOTLIN_1_8 + "2023.3" -> KotlinVersionEnum.KOTLIN_1_9 + else -> error("not set") + }.version +} + +fun Project.withCurrentProfileName(consumer: (String) -> T): Provider { + val name = IdeVersions.ideProfile(providers).map { it.name } + return name.map { + consumer(it) + } +} + +fun Project.buildMetadata() = + try { + val git = Git.open(rootDir) + val currentShortHash = git.repository.findRef("HEAD").objectId.abbreviate(7).name() + val isDirty = git.status().call().hasUncommittedChanges() + + buildString { + append(currentShortHash) + + if (isDirty) { + append(".modified") + } + } + } catch(e: IOException) { + logger.warn("Could not determine current commit", e) + + "unknownCommit" + } diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/SourcesUtils.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/SourcesUtils.kt new file mode 100644 index 0000000000..a398f24d13 --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/SourcesUtils.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.gradle + +import org.gradle.api.Project +import software.aws.toolkits.gradle.intellij.Profile +import java.io.File +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') + * [ideProfile] is the IDE [Profile] currently configured in the project + */ +fun findFolders(project: Project, type: String, ideProfile: Profile): Set = project.projectDir.listFiles( + FileFilter { + it.isDirectory && includeFolder(type, ideProfile.shortName, it.name) + } +)?.map { File(it.name) }?.toSet() ?: setOf() + +/** + * 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+ + * - tst-201-202 + * + * 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() + // Check version range first + "$type-(\\d{3})-(\\d{3})".toRegex().matchEntire(folderName)?.destructured?.let { (minVersion, maxVersion) -> + return ideVersionAsInt in minVersion.toInt()..maxVersion.toInt() + } + // Then check singular versions/min+ + val (_, version, plus) = "$type(-(\\d{3}))?(\\+)?".toRegex().matchEntire(folderName)?.destructured ?: return false + return when { + version.isBlank() -> true + plus.isBlank() -> version.toInt() == ideVersionAsInt + else -> version.toInt() <= ideVersionAsInt + } +} diff --git a/buildSrc/src/toolkits/gradle/changelog/ChangeLog.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ChangeLog.kt similarity index 93% rename from buildSrc/src/toolkits/gradle/changelog/ChangeLog.kt rename to buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ChangeLog.kt index 5d0e494181..1945a839fa 100644 --- a/buildSrc/src/toolkits/gradle/changelog/ChangeLog.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ChangeLog.kt @@ -1,7 +1,7 @@ -// 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 toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.MapperFeature diff --git a/buildSrc/src/toolkits/gradle/changelog/ChangeLogGenerator.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ChangeLogGenerator.kt similarity index 83% rename from buildSrc/src/toolkits/gradle/changelog/ChangeLogGenerator.kt rename to buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ChangeLogGenerator.kt index 591a05fa12..570cd6f1c1 100644 --- a/buildSrc/src/toolkits/gradle/changelog/ChangeLogGenerator.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ChangeLogGenerator.kt @@ -1,32 +1,31 @@ -// 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 toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog -import org.gradle.api.logging.Logging +import org.gradle.api.logging.Logger import java.nio.file.Path import java.time.LocalDate import java.time.format.DateTimeFormatter import kotlin.streams.toList -/* ktlint-disable custom-ktlint-rules:log-not-lazy */ /** * Generates a combined change log file based in Markdown syntax */ -class ChangeLogGenerator(private val writers: List) { +class ChangeLogGenerator(private val writers: List, private val logger: Logger) : AutoCloseable { fun addUnreleasedChanges(unreleasedFiles: List) { val entries = unreleasedFiles.parallelStream() .map { readFile(it.toFile()) } .toList().filterNotNull() val unreleasedEntry = ReleaseEntry(LocalDate.now(), "Pending Release", entries) - LOGGER.info("Adding unreleased entry: $unreleasedEntry") + logger.info("Adding unreleased entry: $unreleasedEntry") generateEntry(unreleasedEntry) } fun addReleasedChanges(changelogFiles: List) { val versions = mutableSetOf() - LOGGER.info("Including release change logs: $changelogFiles") + logger.info("Including release change logs: $changelogFiles") changelogFiles.parallelStream() .map { readFile(it.toFile()) } .toList() @@ -38,7 +37,7 @@ class ChangeLogGenerator(private val writers: List) { } .sortedByDescending { it.date } .forEach { - LOGGER.info("Adding release entry: $it") + logger.info("Adding release entry: $it") generateEntry(it) } } @@ -50,13 +49,11 @@ class ChangeLogGenerator(private val writers: List) { } } - fun close() { + override fun close() { writers.forEach { it.close() } } companion object { - private val LOGGER = Logging.getLogger(ChangeLogGenerator::class.java) - fun renderEntry(releaseEntry: ReleaseEntry): String { val renderedEntry = StringBuilder() diff --git a/buildSrc/src/toolkits/gradle/changelog/ChangeLogWriter.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ChangeLogWriter.kt similarity index 82% rename from buildSrc/src/toolkits/gradle/changelog/ChangeLogWriter.kt rename to buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ChangeLogWriter.kt index 7737b85e17..7ef459f3e4 100644 --- a/buildSrc/src/toolkits/gradle/changelog/ChangeLogWriter.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ChangeLogWriter.kt @@ -1,7 +1,7 @@ -// 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 toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog abstract class ChangeLogWriter(issueUrl: String? = null) { private val issueUrl = issueUrl?.trimEnd('/')?.plus("/") @@ -21,7 +21,7 @@ abstract class ChangeLogWriter(issueUrl: String? = null) { return entry } - val regex = """#(\d+)""".toRegex() + val regex = "#(\\d+)".toRegex() return regex.replace(entry) { val issue = it.groups[1]?.value ?: return@replace it.value "[#$issue]($issueUrl$issue)" diff --git a/buildSrc/src/toolkits/gradle/changelog/GitStager.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/GitStager.kt similarity index 88% rename from buildSrc/src/toolkits/gradle/changelog/GitStager.kt rename to buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/GitStager.kt index e9c87cb753..0a63f97f52 100644 --- a/buildSrc/src/toolkits/gradle/changelog/GitStager.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/GitStager.kt @@ -1,7 +1,7 @@ -// 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 toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog import org.eclipse.jgit.api.Git import java.io.File diff --git a/buildSrc/src/toolkits/gradle/changelog/GithubWriter.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/GithubWriter.kt similarity index 79% rename from buildSrc/src/toolkits/gradle/changelog/GithubWriter.kt rename to buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/GithubWriter.kt index ee4ca5d7d9..acbe0d8402 100644 --- a/buildSrc/src/toolkits/gradle/changelog/GithubWriter.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/GithubWriter.kt @@ -1,7 +1,7 @@ -// 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 toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog import java.nio.file.Path diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/JetBrainsWriter.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/JetBrainsWriter.kt new file mode 100644 index 0000000000..c47404b09e --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/JetBrainsWriter.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.gradle.changelog + +import org.commonmark.node.AbstractVisitor +import org.commonmark.node.Heading +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.io.File +import java.lang.Math.max +import java.lang.Math.min + +class JetBrainsWriter(private val changeNotesFile: File, issueUrl: String? = null) : ChangeLogWriter(issueUrl) { + private val sb = StringBuilder() + + override fun append(line: String) { + sb.append(line) + } + + override fun close() { + val renderer = HtmlRenderer.builder() + .softbreak("
") + .build() + val parser = Parser.builder() + .postProcessor { + it.accept( + object : AbstractVisitor() { + override fun visit(heading: Heading) { + heading.level = max(1, min(heading.level + 2, 6)) + } + } + ) + + it + } + .build() + val htmlVersionError = renderer.render(parser.parse(sb.toString())) + + changeNotesFile.writeText( + """ + + + + + + """.trimIndent() + ) + } + + override fun toString(): String = "JetBrainsWriter(file=$changeNotesFile)" +} diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ReleaseCreator.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ReleaseCreator.kt new file mode 100644 index 0000000000..eedf5122a2 --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/ReleaseCreator.kt @@ -0,0 +1,28 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.changelog + +import org.gradle.api.logging.Logger +import java.io.File +import java.time.LocalDate + +class ReleaseCreator(private val unreleasedFiles: Collection, private val nextReleaseFile: File, logger: Logger) { + init { + if (nextReleaseFile.exists()) { + throw RuntimeException("Release file $nextReleaseFile already exists!") + } + if (unreleasedFiles.isEmpty()) { + logger.warn("Release created without any unreleased change files, this will yield an empty changelog") + } + } + + fun create(version: String, date: LocalDate = LocalDate.now()) { + val entriesByType = unreleasedFiles.map { readFile(it) }.groupBy { it.type } + val entries = ChangeType.values().flatMap { entriesByType.getOrDefault(it, emptyList()) } + val release = ReleaseEntry(date, version, entries) + + MAPPER.writerWithDefaultPrettyPrinter().writeValue(nextReleaseFile, release) + unreleasedFiles.forEach { it.delete() } + } +} diff --git a/buildSrc/src/toolkits/gradle/changelog/tasks/ChangeLogTask.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/ChangeLogTask.kt similarity index 83% rename from buildSrc/src/toolkits/gradle/changelog/tasks/ChangeLogTask.kt rename to buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/ChangeLogTask.kt index 7a6f0baeee..bd336edc0f 100644 --- a/buildSrc/src/toolkits/gradle/changelog/tasks/ChangeLogTask.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/ChangeLogTask.kt @@ -1,7 +1,7 @@ // Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package toolkits.gradle.changelog.tasks +package software.aws.toolkits.gradle.changelog.tasks import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty @@ -9,7 +9,7 @@ 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 +import software.aws.toolkits.gradle.changelog.GitStager abstract class ChangeLogTask : DefaultTask() { @Internal @@ -21,7 +21,11 @@ abstract class ChangeLogTask : DefaultTask() { @InputFiles val nextReleaseDirectory: DirectoryProperty = project.objects.directoryProperty().convention(changesDirectory.dir("next-release")) + init { + group = "changelog" + } + protected fun DirectoryProperty.jsonFiles(): FileTree = this.asFileTree.matching { - it.include("*.json") + include("*.json") } } diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/CreateRelease.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/CreateRelease.kt new file mode 100644 index 0000000000..4935e50761 --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/CreateRelease.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.gradle.changelog.tasks + +import org.gradle.api.file.ProjectLayout +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 software.aws.toolkits.gradle.changelog.ChangeLogGenerator +import software.aws.toolkits.gradle.changelog.GithubWriter +import software.aws.toolkits.gradle.changelog.ReleaseCreator +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +open class CreateRelease @Inject constructor(projectLayout: ProjectLayout) : 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) + + @Input + @Optional + val issuesUrl: Provider = project.objects.property(String::class.java).convention("https://github.com/aws/aws-toolkit-jetbrains/issues") + + @OutputFile + val releaseFile: RegularFileProperty = project.objects.fileProperty().convention(changesDirectory.file(releaseVersion.map { "$it.json" })) + + @OutputFile + val changeLogFile: RegularFileProperty = project.objects.fileProperty().convention(projectLayout.buildDirectory.file("releaseChangeLog.md")) + + @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, logger) + creator.create(releaseVersion.get(), releaseDate) + if (git != null) { + git.stage(releaseFile.get().asFile.absoluteFile) + git.stage(nextReleaseDirectory.get().asFile.absoluteFile) + } + + val generator = ChangeLogGenerator(listOf(GithubWriter(changeLogFile.get().asFile.toPath(), issuesUrl.get())), logger) + generator.use { + generator.addReleasedChanges(listOf(releaseFile.get().asFile.toPath())) + } + } +} diff --git a/buildSrc/src/toolkits/gradle/changelog/tasks/GenerateChangeLog.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/GenerateChangeLog.kt similarity index 84% rename from buildSrc/src/toolkits/gradle/changelog/tasks/GenerateChangeLog.kt rename to buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/GenerateChangeLog.kt index 363658b3f8..47e24670a4 100644 --- a/buildSrc/src/toolkits/gradle/changelog/tasks/GenerateChangeLog.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/GenerateChangeLog.kt @@ -1,7 +1,7 @@ // Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package toolkits.gradle.changelog.tasks +package software.aws.toolkits.gradle.changelog.tasks import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property @@ -10,12 +10,11 @@ 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 +import software.aws.toolkits.gradle.changelog.ChangeLogGenerator +import software.aws.toolkits.gradle.changelog.ChangeLogWriter +import software.aws.toolkits.gradle.changelog.GithubWriter +import software.aws.toolkits.gradle.changelog.JetBrainsWriter -/* ktlint-disable custom-ktlint-rules:log-not-lazy */ abstract class GenerateChangeLog(private val shouldStage: Boolean) : ChangeLogTask() { @Input @Optional @@ -31,7 +30,7 @@ abstract class GenerateChangeLog(private val shouldStage: Boolean) : ChangeLogTa fun generate() { val writer = createWriter() logger.info("Generating Changelog with $writer") - val generator = ChangeLogGenerator(listOf(writer)) + val generator = ChangeLogGenerator(listOf(writer), logger) if (includeUnreleased.get()) { val unreleasedEntries = nextReleaseDirectory.jsonFiles().files diff --git a/buildSrc/src/toolkits/gradle/changelog/tasks/NewChange.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/NewChange.kt similarity index 86% rename from buildSrc/src/toolkits/gradle/changelog/tasks/NewChange.kt rename to buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/NewChange.kt index 1457bd3971..c52dcc8cab 100644 --- a/buildSrc/src/toolkits/gradle/changelog/tasks/NewChange.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/changelog/tasks/NewChange.kt @@ -1,13 +1,13 @@ // Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package toolkits.gradle.changelog.tasks +package software.aws.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 software.aws.toolkits.gradle.changelog.ChangeType +import software.aws.toolkits.gradle.changelog.Entry +import software.aws.toolkits.gradle.changelog.MAPPER import java.io.File import java.util.Scanner import java.util.UUID @@ -59,13 +59,14 @@ open class NewChange : ChangeLogTask() { } private fun createChange(changeType: ChangeType, description: String) = newFile(changeType).apply { - MAPPER.writerWithDefaultPrettyPrinter().writeValue(this, + 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() - } + parentFile?.mkdirs() + createNewFile() + } } diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt new file mode 100644 index 0000000000..86898ae34a --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt @@ -0,0 +1,198 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.intellij + +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory + + +enum class IdeFlavor { GW, IC, IU, RD } + +object IdeVersions { + private val commonPlugins = arrayOf( + "vcs-git", + "org.jetbrains.plugins.terminal", + "org.jetbrains.plugins.yaml" + ) + + private val ideProfiles = listOf( + Profile( + name = "2023.1", + community = ProductProfile( + sdkFlavor = IdeFlavor.IC, + sdkVersion = "2023.1", + plugins = commonPlugins + listOf( + "java", + "com.intellij.gradle", + "org.jetbrains.idea.maven", + "PythonCore:231.8109.144", + "Docker:231.8109.217" + ) + ), + ultimate = ProductProfile( + sdkFlavor = IdeFlavor.IU, + sdkVersion = "2023.1", + plugins = commonPlugins + listOf( + "JavaScript", + // Transitive dependency needed for javascript + // Can remove when https://github.com/JetBrains/gradle-intellij-plugin/issues/608 is fixed + "com.intellij.css", + "JavaScriptDebugger", + "com.intellij.database", + "com.jetbrains.codeWithMe", + "Pythonid:231.8109.175", + "org.jetbrains.plugins.go:231.8109.175", + // https://github.com/JetBrains/gradle-intellij-plugin/issues/1056 + "org.intellij.intelliLang" + ) + ), + rider = RiderProfile( + sdkVersion = "2023.1", + plugins = commonPlugins + listOf( + "rider-plugins-appender" // Workaround for https://youtrack.jetbrains.com/issue/IDEA-179607 + ), + netFrameworkTarget = "net472", + rdGenVersion = "2023.1.2", + nugetVersion = "2023.1.0" + ) + ), + Profile( + name = "2023.2", + community = ProductProfile( + sdkFlavor = IdeFlavor.IC, + sdkVersion = "2023.2.2", + plugins = commonPlugins + listOf( + "java", + "com.intellij.gradle", + "org.jetbrains.idea.maven", + "PythonCore:232.8660.185", + "Docker:232.8660.185" + ) + ), + ultimate = ProductProfile( + sdkFlavor = IdeFlavor.IU, + sdkVersion = "2023.2.2", + plugins = commonPlugins + listOf( + "JavaScript", + // Transitive dependency needed for javascript + // Can remove when https://github.com/JetBrains/gradle-intellij-plugin/issues/608 is fixed + "com.intellij.css", + "JavaScriptDebugger", + "com.intellij.database", + "com.jetbrains.codeWithMe", + "Pythonid:232.8660.185", + "org.jetbrains.plugins.go:232.8660.142", + // https://github.com/JetBrains/gradle-intellij-plugin/issues/1056 + "org.intellij.intelliLang" + ) + ), + rider = RiderProfile( + sdkVersion = "2023.2", + plugins = commonPlugins + listOf( + "rider-plugins-appender" // Workaround for https://youtrack.jetbrains.com/issue/IDEA-179607 + ), + netFrameworkTarget = "net472", + rdGenVersion = "2023.2.3", + nugetVersion = "2023.2.0" + ) + ), + Profile( + name = "2023.3", + gateway = ProductProfile( + sdkFlavor = IdeFlavor.GW, + sdkVersion = "233.11799-EAP-CANDIDATE-SNAPSHOT", + plugins = arrayOf("org.jetbrains.plugins.terminal") + ), + community = ProductProfile( + sdkFlavor = IdeFlavor.IC, + sdkVersion = "2023.3", + plugins = commonPlugins + listOf( + "java", + "com.intellij.gradle", + "org.jetbrains.idea.maven", + "PythonCore:233.11799.241", + "Docker:233.11799.244" + ) + ), + ultimate = ProductProfile( + sdkFlavor = IdeFlavor.IU, + sdkVersion = "2023.3", + plugins = commonPlugins + listOf( + "JavaScript", + // Transitive dependency needed for javascript + // Can remove when https://github.com/JetBrains/gradle-intellij-plugin/issues/608 is fixed + "com.intellij.css", + "JavaScriptDebugger", + "com.intellij.database", + "com.jetbrains.codeWithMe", + "Pythonid:233.11799.241", + "org.jetbrains.plugins.go:233.11799.196", + // https://github.com/JetBrains/gradle-intellij-plugin/issues/1056 + "org.intellij.intelliLang" + ) + ), + rider = RiderProfile( + sdkVersion = "2023.3", + plugins = commonPlugins + listOf( + "rider-plugins-appender" // Workaround for https://youtrack.jetbrains.com/issue/IDEA-179607 + ), + netFrameworkTarget = "net472", + rdGenVersion = "2023.3.2", + nugetVersion = "2023.3.0" + ) + ), + + ).associateBy { it.name } + + fun ideProfile(project: Project): Profile = ideProfile(project.providers).get() + + fun ideProfile(providers: ProviderFactory): Provider = resolveIdeProfileName(providers).map { + ideProfiles[it] ?: throw IllegalStateException("Can't find profile for $it") + } + + private fun resolveIdeProfileName(providers: ProviderFactory): Provider = providers.gradleProperty("ideProfileName") +} + +open class ProductProfile( + val sdkFlavor: IdeFlavor, + val sdkVersion: String, + val plugins: Array = emptyArray() +) { + fun version(): String? = if (!isLocalPath(sdkVersion)) { + sdkFlavor.name + "-" + sdkVersion + } else { + null + } + + fun localPath(): String? = sdkVersion.takeIf { + isLocalPath(it) + } + + private fun isLocalPath(str: String) = str.startsWith("/") || str.getOrNull(1) == ':' +} + +class RiderProfile( + sdkVersion: String, + plugins: Array, + val netFrameworkTarget: String, + val rdGenVersion: String, // https://central.sonatype.com/artifact/com.jetbrains.rd/rd-gen/2023.2.3/versions + val nugetVersion: String // https://www.nuget.org/packages/JetBrains.Rider.SDK/ +) : ProductProfile(IdeFlavor.RD, sdkVersion, plugins) + +class Profile( + val name: String, + val shortName: String = shortenedIdeProfileName(name), + val sinceVersion: String = shortName, + val untilVersion: String = "$sinceVersion.*", + val gateway: ProductProfile? = null, + val community: ProductProfile, + val ultimate: ProductProfile, + val rider: RiderProfile, +) + +private fun shortenedIdeProfileName(sdkName: String): String { + val parts = sdkName.trim().split(".") + return parts[0].substring(2) + parts[1] +} diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/ToolkitIntelliJExtension.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/ToolkitIntelliJExtension.kt new file mode 100644 index 0000000000..14db898ef3 --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/ToolkitIntelliJExtension.kt @@ -0,0 +1,31 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.intellij + +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory + +abstract class ToolkitIntelliJExtension(private val providers: ProviderFactory) { + abstract val ideFlavor: Property + + fun ideProfile() = IdeVersions.ideProfile(providers) + + fun version(): Provider = productProfile().flatMap { profile -> + providers.provider { profile.version() } + } + + fun localPath(): Provider = productProfile().flatMap { profile -> + providers.provider { profile.localPath() } + } + + fun productProfile(): Provider = ideFlavor.flatMap { flavor -> + when (flavor) { + IdeFlavor.IC -> ideProfile().map { it.community } + IdeFlavor.IU -> ideProfile().map { it.ultimate } + IdeFlavor.RD -> ideProfile().map { it.rider } + IdeFlavor.GW -> ideProfile().map { it.gateway!! } + } + } +} diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/jacoco/RemoteCoverage.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/jacoco/RemoteCoverage.kt new file mode 100644 index 0000000000..4fd4beaeac --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/jacoco/RemoteCoverage.kt @@ -0,0 +1,125 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.jacoco + +import org.gradle.BuildAdapter +import org.gradle.BuildResult +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters +import org.gradle.api.tasks.testing.Test +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.jacoco.core.data.ExecutionData +import org.jacoco.core.data.ExecutionDataWriter +import org.jacoco.core.data.IExecutionDataVisitor +import org.jacoco.core.data.ISessionInfoVisitor +import org.jacoco.core.data.SessionInfo +import org.jacoco.core.runtime.RemoteControlReader +import org.jacoco.core.runtime.RemoteControlWriter +import java.io.FileOutputStream +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.atomic.AtomicBoolean + +class RemoteCoverage private constructor(task: Test) { + companion object { + fun enableRemoteCoverage(task: Test) = RemoteCoverage(task) + + private const val DEFAULT_JACOCO_PORT = 6300 + } + + init { + task.extensions.findByType(JacocoTaskExtension::class.java)?.let { + val execFile = it.destinationFile ?: return@let + + // Use a shared service since it does not block task execution + val jacocoServer = task.project.gradle.sharedServices.registerIfAbsent("jacocoServer", JacocoServer::class.java) { + if (!execFile.exists()) { + task.project.mkdir(execFile.parentFile) + execFile.createNewFile() + } + + parameters.execFile.set(execFile) + } + + task.doFirst { + jacocoServer.get().start() + task.project.gradle.addBuildListener(object : BuildAdapter() { + override fun buildFinished(result: BuildResult) { + task.project.gradle.removeListener(this) + runCatching { + jacocoServer.get().close() + } + } + }) + } + } ?: task.logger.warn("$task does not have Jacoco enabled on it") + } + + abstract class JacocoServer : BuildService, AutoCloseable { + interface Params : BuildServiceParameters { + val execFile: RegularFileProperty + } + + private val serverSocket = ServerSocket(DEFAULT_JACOCO_PORT) + private val isRunning = AtomicBoolean(false) + + private val serverRunnable = Runnable { + parameters.execFile.asFile.get().outputStream().use { + while (isRunning.get()) { + val clientSocket = serverSocket.accept() + JacocoHandler(clientSocket, it).run() + } + } + } + private lateinit var serverThread: Thread + + fun start() { + if (!isRunning.getAndSet(true)) { + serverThread = Thread(serverRunnable) + serverThread.start() + } else { + throw IllegalStateException("Jacoco server is already running!") + } + } + + override fun close() { + if (isRunning.getAndSet(false)) { + serverThread.interrupt() + } + } + } + + private class JacocoHandler(private val socket: Socket, private val outputFile: FileOutputStream) : ISessionInfoVisitor, IExecutionDataVisitor { + private val fileWriter = ExecutionDataWriter(outputFile) + + fun run() { + socket.use { + socket.getInputStream().use { input -> + socket.getOutputStream().use { output -> + val reader = RemoteControlReader(input) + reader.setSessionInfoVisitor(this) + reader.setExecutionDataVisitor(this) + + RemoteControlWriter(output) + + @Suppress("ControlFlowWithEmptyBody") + // Read all the data from jacoco + while (reader.read()) { + } + synchronized(fileWriter) { fileWriter.flush() } + } + } + } + } + + override fun visitSessionInfo(info: SessionInfo) { + synchronized(fileWriter) { fileWriter.visitSessionInfo(info) } + } + + override fun visitClassExecution(data: ExecutionData) { + synchronized(fileWriter) { fileWriter.visitClassExecution(data) } + } + } +} diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/resources/ValidateMessages.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/resources/ValidateMessages.kt new file mode 100644 index 0000000000..a0dc28e875 --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/resources/ValidateMessages.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.gradle.resources + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.language.base.plugins.LifecycleBasePlugin.VERIFICATION_GROUP +import java.time.Instant + +open class ValidateMessages : DefaultTask() { + private companion object { + const val COPYRIGHT_HEADER_LINES = 2 + } + @InputFiles + val paths: ConfigurableFileCollection = project.objects.fileCollection() + + @OutputFile + val output: RegularFileProperty = project.objects.fileProperty().convention { + project.buildDir.resolve("validateMessages") + } + + init { + group = VERIFICATION_GROUP + } + + @TaskAction + fun validateMessage() { + var hasError = false + paths + .map { it.absolutePath to it.readLines() } + .forEach { (filePath, fileLines) -> + fileLines + // filter out blank lines and comments + .filter { it.isNotBlank() && it.trim().firstOrNull() != '#' } + .mapIndexed { lineNumber, it -> + if (it.contains("=")) { + it + } else { + logger.error(""""$filePath:${lineNumber + COPYRIGHT_HEADER_LINES} contains invalid message missing a '=': "$it"""") + hasError = true + null + } + } + .filterNotNull() + .map { it.split("=").first() } + .reduceIndexed { lineNumber, item1, item2 -> + if (item1 > item2) { + logger.error("""$filePath:${lineNumber + COPYRIGHT_HEADER_LINES} is not sorted:"$item1" > "$item2"""") + hasError = true + } + + item2 + } + if (hasError) { + throw GradleException("$filePath has one or more out of order items!") + } + } + + // Write the current time to the file so it will be cacheable (gradle can only use files to determine up to date checks) + output.asFile.get().writeText(Instant.now().toString()) + } +} diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/sdk/GenerateSdk.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/sdk/GenerateSdk.kt new file mode 100644 index 0000000000..03275d5b52 --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/sdk/GenerateSdk.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.gradle.sdk + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import software.amazon.awssdk.codegen.C2jModels +import software.amazon.awssdk.codegen.CodeGenerator +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig +import software.amazon.awssdk.codegen.model.service.ServiceModel +import software.amazon.awssdk.codegen.utils.ModelLoaderUtils +import java.io.File + +open class GenerateSdk : DefaultTask() { + @InputDirectory + val c2jFolder: DirectoryProperty = project.objects.directoryProperty().convention(project.extensions.getByType(GenerateSdkExtension::class.java).c2jFolder) + + @OutputDirectory + val srcDir: DirectoryProperty = project.objects.directoryProperty().convention(project.extensions.getByType(GenerateSdkExtension::class.java).srcDir()) + + @Internal + val testDir: DirectoryProperty = project.objects.directoryProperty().convention(project.extensions.getByType(GenerateSdkExtension::class.java).testDir()) + + @TaskAction + fun generate() { + val srcDir = srcDir.asFile.get() + val testDir = testDir.asFile.get() + srcDir.deleteRecursively() + testDir.deleteRecursively() + + c2jFolder.get().asFileTree.visit { + if (isDirectory) { + with(file) { + logger.info("Generating SDK from $this") + val models = C2jModels.builder() + .serviceModel(loadServiceModel()) + .paginatorsModel(loadOptionalModel("paginators-1.json")) + .customizationConfig(loadOptionalModel("customization.config") ?: CustomizationConfig.create()) + .waitersModel(loadOptionalModel("waiters-2.json")) + .build() + + CodeGenerator.builder() + .models(models) + .sourcesDirectory(srcDir.absolutePath) + .testsDirectory(testDir.absolutePath) + .build() + .execute() + } + } + } + } + + private fun File.loadServiceModel(): ServiceModel? = ModelLoaderUtils.loadModel(ServiceModel::class.java, resolve("service-2.json")) + + private inline fun File.loadOptionalModel(fileName: String): T? = resolve(fileName).takeIf { it.exists() }?.let { + ModelLoaderUtils.loadModel(T::class.java, it) + } +} diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/sdk/GenerateSdkExtension.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/sdk/GenerateSdkExtension.kt new file mode 100644 index 0000000000..7f5a394835 --- /dev/null +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/sdk/GenerateSdkExtension.kt @@ -0,0 +1,18 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.sdk + +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Provider + +open class GenerateSdkExtension(objects: ObjectFactory) { + val c2jFolder: DirectoryProperty = objects.directoryProperty() + + val outputDir: DirectoryProperty = objects.directoryProperty() + + fun srcDir(): Provider = outputDir.dir("src") + fun testDir(): Provider = outputDir.dir("tst") +} diff --git a/buildSrc/src/main/kotlin/toolkit-changelog.gradle.kts b/buildSrc/src/main/kotlin/toolkit-changelog.gradle.kts new file mode 100644 index 0000000000..141a76549e --- /dev/null +++ b/buildSrc/src/main/kotlin/toolkit-changelog.gradle.kts @@ -0,0 +1,24 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import software.aws.toolkits.gradle.changelog.ChangeType +import software.aws.toolkits.gradle.changelog.tasks.CreateRelease +import software.aws.toolkits.gradle.changelog.tasks.NewChange + +tasks.register("createRelease") + +tasks.register("newChange") { + description = "Creates a new change entry for inclusion in the Change Log" +} + +tasks.register("newFeature") { + description = "Creates a new feature change entry for inclusion in the Change Log" + + defaultChangeType = ChangeType.FEATURE +} + +tasks.register("newBugFix") { + description = "Creates a new bug-fix change entry for inclusion in the Change Log" + + defaultChangeType = ChangeType.BUGFIX +} diff --git a/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts b/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts new file mode 100644 index 0000000000..fa268707f3 --- /dev/null +++ b/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts @@ -0,0 +1,62 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask +import kotlin.reflect.KVisibility +import kotlin.reflect.full.companionObject +import kotlin.reflect.full.companionObjectInstance +import kotlin.reflect.full.functions +import kotlin.reflect.full.memberFunctions +import kotlin.reflect.full.memberProperties + +plugins { + id("io.gitlab.arturbosch.detekt") + id("toolkit-testing") +} + +// TODO: https://github.com/gradle/gradle/issues/15383 +val versionCatalog = extensions.getByType().named("libs") +dependencies { + detektPlugins(versionCatalog.findLibrary("detekt-formattingRules").get()) + detektPlugins(project(":detekt-rules")) +} + +detekt { + val rulesProject = project(":detekt-rules").projectDir + source.setFrom(projectDir) + buildUponDefaultConfig = true + parallel = true + allRules = false + config.setFrom("$rulesProject/detekt.yml") + autoCorrect = true +} + +tasks.withType { + reports { + html.required.set(true) // Human readable report + xml.required.set(true) // Checkstyle like format for CI tool integrations + } +} + +tasks.withType { + // weird issue where the baseline tasks can't find the source code + source.plus(projectDir) + + // hack around https://github.com/detekt/detekt/issues/6167 + doLast { + Class.forName("io.gitlab.arturbosch.detekt.invoke.DetektInvoker").kotlin.let { detektInvoker -> + val invokerInstance = detektInvoker.companionObject!!.memberFunctions.find { it.name == "create" }!!.call(detektInvoker.companionObjectInstance, false) + val invokeCliMethod = detektInvoker.memberFunctions.find { it.name == "invokeCli" } + val jdkHomeArgumentClass = Class.forName("io.gitlab.arturbosch.detekt.invoke.JdkHomeArgument").kotlin + val jdkHomeArgument = jdkHomeArgumentClass.constructors.first().call(jdkHome) + val jdkHomeArgs = jdkHomeArgumentClass.memberFunctions.find { it.name == "toArgument" }!!.call(jdkHomeArgument) as List + val taskArgs = this::class.memberProperties.find { it.name == "arguments" }!!.call(this) as List + + val cliArgs = taskArgs + jdkHomeArgs + val ignoreFailures = ignoreFailures.getOrElse(false) + val classpath = detektClasspath.plus(pluginClasspath) + val taskName = name + invokeCliMethod!!.call(invokerInstance, cliArgs, classpath, taskName, ignoreFailures) + } + } +} diff --git a/buildSrc/src/main/kotlin/toolkit-generate-sdks.gradle.kts b/buildSrc/src/main/kotlin/toolkit-generate-sdks.gradle.kts new file mode 100644 index 0000000000..d4e14b5774 --- /dev/null +++ b/buildSrc/src/main/kotlin/toolkit-generate-sdks.gradle.kts @@ -0,0 +1,41 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import software.aws.toolkits.gradle.sdk.GenerateSdk +import software.aws.toolkits.gradle.sdk.GenerateSdkExtension +import software.aws.toolkits.gradle.jvmTarget + +val sdkGenerator = project.extensions.create("sdkGenerator") + +plugins { + java +} + +sourceSets { + main { + java { + setSrcDirs(listOf(sdkGenerator.srcDir())) + } + } + + test { + java { + setSrcDirs(emptyList()) + } + } +} + +java { + val target = project.jvmTarget().get() + sourceCompatibility = target + targetCompatibility = target +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" +} + +val generateTask = tasks.register("generateSdks") +tasks.named("compileJava") { + dependsOn(generateTask) +} diff --git a/buildSrc/src/main/kotlin/toolkit-integration-testing.gradle.kts b/buildSrc/src/main/kotlin/toolkit-integration-testing.gradle.kts new file mode 100644 index 0000000000..84c393c481 --- /dev/null +++ b/buildSrc/src/main/kotlin/toolkit-integration-testing.gradle.kts @@ -0,0 +1,53 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +plugins { + id("java") + id("idea") + id("toolkit-testing") +} + +val integrationTests: SourceSet = sourceSets.maybeCreate("integrationTest") +sourceSets { + integrationTests.apply { + java.setSrcDirs(listOf("it")) + resources.srcDirs(listOf("it-resources")) + + compileClasspath += main.get().output + test.get().output + runtimeClasspath += main.get().output + test.get().output + } +} + +configurations.getByName("integrationTestCompileClasspath") { + extendsFrom(configurations.getByName(JavaPlugin.TEST_COMPILE_CLASSPATH_CONFIGURATION_NAME)) + isCanBeResolved = true +} +configurations.getByName("integrationTestRuntimeClasspath") { + extendsFrom(configurations.getByName(JavaPlugin.TEST_RUNTIME_CLASSPATH_CONFIGURATION_NAME)) + isCanBeResolved = true +} + +// Add the integration test source set to test jar +val testJar = tasks.named("testJar") { + from(integrationTests.output) +} + +idea { + module { + testSourceDirs = testSourceDirs + integrationTests.java.srcDirs + testResourceDirs = testResourceDirs + integrationTests.resources.srcDirs + } +} + +tasks.register("integrationTest") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Runs the integration tests." + testClassesDirs = integrationTests.output.classesDirs + classpath = integrationTests.runtimeClasspath + + mustRunAfter(tasks.test) +} + +tasks.check { + dependsOn(integrationTests.compileJavaTaskName, integrationTests.getCompileTaskName("kotlin")) +} diff --git a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts new file mode 100644 index 0000000000..fb30156810 --- /dev/null +++ b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts @@ -0,0 +1,282 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import org.gradle.internal.os.OperatingSystem +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension.Output +import org.jetbrains.intellij.tasks.DownloadRobotServerPluginTask +import org.jetbrains.intellij.tasks.PatchPluginXmlTask +import org.jetbrains.intellij.tasks.RunIdeForUiTestTask +import org.jetbrains.intellij.utils.OpenedPackages +import software.aws.toolkits.gradle.buildMetadata +import software.aws.toolkits.gradle.ciOnly +import software.aws.toolkits.gradle.findFolders +import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions +import software.aws.toolkits.gradle.intellij.ToolkitIntelliJExtension +import software.aws.toolkits.gradle.isCi + +val toolkitIntelliJ = project.extensions.create("intellijToolkit") + +val ideProfile = IdeVersions.ideProfile(project) +val toolkitVersion: String by project +val remoteRobotPort: String by project + +// please check changelog generation logic if this format is changed +version = "$toolkitVersion-${ideProfile.shortName}" + +plugins { + id("toolkit-kotlin-conventions") + id("toolkit-testing") + id("org.jetbrains.intellij") +} + +// Add our source sets per IDE profile version (i.e. src-211) +sourceSets { + main { + java.srcDirs(findFolders(project, "src", ideProfile)) + resources.srcDirs(findFolders(project, "resources", ideProfile)) + } + test { + java.srcDirs(findFolders(project, "tst", ideProfile)) + resources.srcDirs(findFolders(project, "tst-resources", ideProfile)) + } + + plugins.withType { + maybeCreate("integrationTest").apply { + java.srcDirs(findFolders(project, "it", ideProfile)) + resources.srcDirs(findFolders(project, "it-resources", ideProfile)) + } + } +} + +configurations { + runtimeClasspath { + // Exclude dependencies that ship with iDE + exclude(group = "org.slf4j") + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + + // Exclude dependencies we don't use to make plugin smaller + exclude(group = "software.amazon.awssdk", module = "netty-nio-client") + } + + testRuntimeClasspath { + // Conflicts with CRT in test classpath + exclude(group = "software.amazon.awssdk", module = "netty-nio-client") + } + + // TODO: https://github.com/gradle/gradle/issues/15383 + val versionCatalog = extensions.getByType().named("libs") + dependencies { + testImplementation(platform(versionCatalog.findLibrary("junit5-bom").get())) + testImplementation(versionCatalog.findLibrary("junit5-jupiterApi").get()) + + testRuntimeOnly(versionCatalog.findLibrary("junit5-jupiterEngine").get()) + testRuntimeOnly(versionCatalog.findLibrary("junit5-jupiterVintage").get()) + } + + all { + if (name.startsWith("detekt")) { + return@all + } + + resolutionStrategy.eachDependency { + if (requested.group == "org.jetbrains.kotlinx" && requested.name.startsWith("kotlinx-coroutines")) { + useVersion(versionCatalog.findVersion("kotlinCoroutines").get().toString()) + because("resolve kotlinx-coroutines version conflicts in favor of local version catalog") + } + + if (requested.group == "org.jetbrains.kotlin" && requested.name.startsWith("kotlin")) { + useVersion(versionCatalog.findVersion("kotlin").get().toString()) + because("resolve kotlin version conflicts in favor of local version catalog") + } + } + } +} + +tasks.processResources { + // needed because both rider and ultimate include plugin-datagrip.xml which we are fine with + duplicatesStrategy = DuplicatesStrategy.WARN +} + +// Run after the project has been evaluated so that the extension (intellijToolkit) has been configured +intellij { + pluginName.set("aws-toolkit-jetbrains") + + localPath.set(toolkitIntelliJ.localPath()) + version.set(toolkitIntelliJ.version()) + + plugins.set(toolkitIntelliJ.productProfile().map { it.plugins.toMutableList() }) + + downloadSources.set(toolkitIntelliJ.ideFlavor.map { it == IdeFlavor.IC && !project.isCi() }) + instrumentCode.set(toolkitIntelliJ.ideFlavor.map { it == IdeFlavor.IC || it == IdeFlavor.IU }) +} + +tasks.jar { + archiveBaseName.set(toolkitIntelliJ.ideFlavor.map { "aws-toolkit-jetbrains-$it" }) +} + +tasks.withType().all { + sinceBuild.set(toolkitIntelliJ.ideProfile().map { it.sinceVersion }) + untilBuild.set(toolkitIntelliJ.ideProfile().map { it.untilVersion }) +} + +// attach the current commit hash on local builds +if (!project.isCi()){ + val buildMetadata = buildMetadata() + tasks.withType().all { + version.set("${project.version}+$buildMetadata") + } + + tasks.buildPlugin { + archiveClassifier.set(buildMetadata) + } +} + +// Disable building the settings search cache since it 1. fails the build, 2. gets run on the final packaged plugin +tasks.buildSearchableOptions { + enabled = false +} + +// https://github.com/JetBrains/gradle-intellij-plugin/blob/829786d5d196ab942d7e6eb3e472ac0af776d3fa/src/main/kotlin/org/jetbrains/intellij/tasks/RunIdeBase.kt#L315 +val openedPackages = OpenedPackages + listOf( + // very noisy in UI tests + "--add-opens=java.desktop/javax.swing.text=ALL-UNNAMED", +) + with(OperatingSystem.current()) { + when { + isWindows -> listOf( + "--add-opens=java.base/sun.nio.fs=ALL-UNNAMED", + ) + else -> emptyList() + } +} + +tasks.withType().all { + systemProperty("log.dir", intellij.sandboxDir.map { "$it-test/logs" }.get()) + systemProperty("testDataPath", project.rootDir.resolve("testdata").absolutePath) + val jetbrainsCoreTestResources = project(":jetbrains-core").projectDir.resolve("tst-resources") + // FIX_WHEN_MIN_IS_221: log4j 1.2 removed in 221 + systemProperty("log4j.configuration", jetbrainsCoreTestResources.resolve("log4j.xml")) + systemProperty("idea.log.config.properties.file", jetbrainsCoreTestResources.resolve("toolkit-test-log.properties")) + systemProperty("org.gradle.project.ideProfileName", ideProfile.name) + + jvmArgs(openedPackages) + + useJUnitPlatform() +} + +tasks.withType { + systemProperty("aws.toolkits.enableTelemetry", false) +} + +tasks.runIde { + systemProperty("aws.toolkit.developerMode", true) + systemProperty("ide.plugins.snapshot.on.unload.fail", true) + systemProperty("memory.snapshots.path", project.rootDir) + systemProperty("idea.auto.reload.plugins", false) + + val alternativeIde = providers.environmentVariable("ALTERNATIVE_IDE") + if (alternativeIde.isPresent) { + // remove the trailing slash if there is one or else it will not work + val value = alternativeIde.get() + val path = File(value.trimEnd('/')) + if (path.exists()) { + ideDir.set(path) + } else { + throw GradleException("ALTERNATIVE_IDE path not found $value") + } + } +} + +// TODO: https://github.com/gradle/gradle/issues/15383 +val versionCatalog = extensions.getByType().named("libs") +tasks.withType { + version.set(versionCatalog.findVersion("intellijRemoteRobot").get().requiredVersion) +} + +// Enable coverage for the UI test target IDE +ciOnly { + extensions.getByType().applyTo(tasks.withType()) +} +tasks.withType().all { + systemProperty("robot-server.port", remoteRobotPort) + // mac magic + systemProperty("ide.mac.message.dialogs.as.sheets", "false") + systemProperty("jbScreenMenuBar.enabled", "false") + systemProperty("apple.laf.useScreenMenuBar", "false") + 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", "") + systemProperty("ide.show.tips.on.startup.default.value", false) + + systemProperty("aws.telemetry.skip_prompt", "true") + systemProperty("aws.suppress_deprecation_prompt", true) + systemProperty("idea.trust.all.projects", "true") + + // These are experiments to enable for UI tests + systemProperty("aws.experiment.connectedLocalTerminal", true) + systemProperty("aws.experiment.dynamoDb", true) + + debugOptions { + enabled.set(true) + suspend.set(false) + } + + jvmArgs(openedPackages) + + ciOnly { + configure { + // sync with testing-subplugin + // don't instrument sdk, icons, etc. + includes = listOf("software.aws.toolkits.*") + excludes = listOf("software.aws.toolkits.telemetry.*") + + // 221+ uses a custom classloader and jacoco fails to find classes + isIncludeNoLocationClasses = true + + output = Output.TCP_CLIENT // Dump to our jacoco server instead of to a file + } + } +} + +// weird implicit dependency issue, maybe with how the task graph works? +// or because tests are on the ide classpath for some reason? +tasks.named("classpathIndexCleanup") { + dependsOn(tasks.named("compileIntegrationTestKotlin")) +} + +configurations.instrumentedJar.configure { + // when the "instrumentedJar" configuration is selected, gradle is unable to resolve configurations needed by jacoco + // to calculate coverage, so we declare these as seconary artifacts on the primary "instrumentedJar" implicit variant + outgoing.variants { + create("instrumentedClasses") { + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY)) + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL)) + attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.CLASSES)) + } + + artifact(tasks.instrumentCode) { + type = ArtifactTypeDefinition.JVM_CLASS_DIRECTORY + } + } + + listOf("coverageDataElements", "mainSourceElements").forEach { implicitVariant -> + val configuration = configurations.getByName(implicitVariant) + create(implicitVariant) { + attributes { + configuration.attributes.keySet().forEach { + attribute(it as Attribute, configuration.attributes.getAttribute(it)!!) + } + } + + configuration.artifacts.forEach { + artifact(it) + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/toolkit-jacoco-report.gradle.kts b/buildSrc/src/main/kotlin/toolkit-jacoco-report.gradle.kts new file mode 100644 index 0000000000..271dd95261 --- /dev/null +++ b/buildSrc/src/main/kotlin/toolkit-jacoco-report.gradle.kts @@ -0,0 +1,80 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Taken from https://docs.gradle.org/current/userguide/structuring_software_products.html + +plugins { + id("java-base") + id("jacoco") +} +// TODO: https://github.com/gradle/gradle/issues/15383 +val versionCatalog = extensions.getByType().named("libs") +jacoco { + // need to probe resolved dependencies directly if moved to rich version declaration + toolVersion = versionCatalog.findVersion("jacoco").get().toString() +} + +// Configurations to declare dependencies +val aggregateCoverage by configurations.creating { + isVisible = false + isCanBeResolved = false + isCanBeConsumed = false +} + +// Resolvable configuration to resolve the classes of all dependencies +val classPath by configurations.creating { + isVisible = false + isCanBeResolved = true + isCanBeConsumed = false + extendsFrom(aggregateCoverage) + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY)) + attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.CLASSES)) + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL)) + } +} + +// A resolvable configuration to collect source code +val sourcesPath by configurations.creating { + isVisible = false + isCanBeResolved = true + isCanBeConsumed = false + extendsFrom(aggregateCoverage) + attributes { + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.VERIFICATION)) + attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL)) + attribute(VerificationType.VERIFICATION_TYPE_ATTRIBUTE, objects.named(VerificationType.MAIN_SOURCES)) + } +} + +// A resolvable configuration to collect JaCoCo coverage data +val coverageDataPath by configurations.creating { + isVisible = false + isCanBeResolved = true + isCanBeConsumed = false + extendsFrom(aggregateCoverage) + attributes { + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.DOCUMENTATION)) + attribute(DocsType.DOCS_TYPE_ATTRIBUTE, objects.named("jacoco-coverage-data")) + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + } +} + +// Register a code coverage report task to generate the aggregated report +tasks.register("coverageReport") { + additionalClassDirs( + classPath.filter { it.isDirectory }.asFileTree.matching { + include("**/software/aws/toolkits/**") + exclude("**/software/aws/toolkits/telemetry/**") + } + ) + + additionalSourceDirs(sourcesPath.incoming.artifactView { lenient(true) }.files) + executionData(coverageDataPath.incoming.artifactView { lenient(true) }.files.filter { it.exists() && it.extension == "exec" }) + + reports { + html.required.set(true) + xml.required.set(true) + } +} diff --git a/buildSrc/src/main/kotlin/toolkit-kotlin-conventions.gradle.kts b/buildSrc/src/main/kotlin/toolkit-kotlin-conventions.gradle.kts new file mode 100644 index 0000000000..a73fec92a8 --- /dev/null +++ b/buildSrc/src/main/kotlin/toolkit-kotlin-conventions.gradle.kts @@ -0,0 +1,86 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import software.aws.toolkits.gradle.jvmTarget +import software.aws.toolkits.gradle.kotlinTarget + +plugins { + id("java") + kotlin("jvm") + id("toolkit-detekt") +} + +// TODO: https://github.com/gradle/gradle/issues/15383 +val versionCatalog = extensions.getByType().named("libs") +dependencies { + implementation(versionCatalog.findBundle("kotlin").get()) + implementation(versionCatalog.findLibrary("kotlin-coroutines").get()) + + testImplementation(versionCatalog.findLibrary("kotlin-test").get()) +} + +sourceSets { + main { + java { + setSrcDirs(listOf("src")) + } + resources { + setSrcDirs(listOf("resources")) + } + } + + test { + java { + setSrcDirs(listOf("tst")) + } + resources { + setSrcDirs(listOf("tst-resources")) + } + } +} + +val javaVersion = project.jvmTarget().get() +java { + sourceCompatibility = javaVersion + targetCompatibility = javaVersion +} + +tasks.withType().all { + kotlinOptions { + jvmTarget = javaVersion.majorVersion + apiVersion = project.kotlinTarget().get() + languageVersion = project.kotlinTarget().get() + freeCompilerArgs = listOf("-Xjvm-default=all") + } +} + +tasks.withType().configureEach { + jvmTarget = javaVersion.majorVersion + dependsOn(":detekt-rules:assemble") + include("**/*.kt") + exclude("build/**") + exclude("**/*.Generated.kt") + exclude("**/TelemetryDefinitions.kt") +} + +tasks.withType().configureEach { + jvmTarget = javaVersion.majorVersion + dependsOn(":detekt-rules:assemble") + include("**/*.kt") + exclude("build/**") + exclude("**/*.Generated.kt") + exclude("**/TelemetryDefinitions.kt") +} + +project.afterEvaluate { + tasks.check { + dependsOn(tasks.detekt, tasks.detektMain, tasks.detektTest) + + tasks.findByName("detektIntegrationTest")?.let { + dependsOn(it) + } + } +} diff --git a/buildSrc/src/main/kotlin/toolkit-testing.gradle.kts b/buildSrc/src/main/kotlin/toolkit-testing.gradle.kts new file mode 100644 index 0000000000..1d02d707d7 --- /dev/null +++ b/buildSrc/src/main/kotlin/toolkit-testing.gradle.kts @@ -0,0 +1,102 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import software.aws.toolkits.gradle.ciOnly + +plugins { + id("java") // Needed for referencing "implementation" configuration + id("jacoco") + id("org.gradle.test-retry") + id("com.adarshr.test-logger") +} + +// TODO: https://github.com/gradle/gradle/issues/15383 +val versionCatalog = extensions.getByType().named("libs") +dependencies { + testImplementation(versionCatalog.findBundle("mockito").get()) + testImplementation(versionCatalog.findLibrary("assertj").get()) + + // Don't add a test framework by default since we use junit4, junit5, and testng depending on project +} + +jacoco { + // need to probe resolved dependencies directly if moved to rich version declaration + toolVersion = versionCatalog.findVersion("jacoco").get().toString() +} + +// TODO: Can we model this using https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures +val testArtifacts by configurations.creating +val testJar = tasks.register("testJar") { + archiveBaseName.set("${project.name}-test") + from(sourceSets.test.get().output) +} + +// Silly but allows higher throughput of the build because we can start compiling / testing other modules while the tests run +// This works because the sourceSet 'integrationTest' extends 'test', so it won't compile until after 'test' is compiled, but the +// task graph goes 'compileTest*' -> 'test' -> 'compileIntegrationTest*' -> 'testJar'. +// By flipping the order of the graph slightly, we can unblock downstream consumers of the testJar to start running tasks while this project +// can be executing the 'test' task. +tasks.test { + mustRunAfter(testJar) +} + +artifacts { + add("testArtifacts", testJar) +} + +tasks.withType().all { + ciOnly { + retry { + failOnPassedAfterRetry.set(false) + maxFailures.set(5) + maxRetries.set(2) + } + } + + reports { + junitXml.required.set(true) + html.required.set(true) + } + + testlogger { + showFullStackTraces = true + showStandardStreams = true + showPassedStandardStreams = false + showSkippedStandardStreams = true + showFailedStandardStreams = true + } + + configure { + // sync with intellij-subplugin + // don't instrument sdk, icons, etc. + includes = listOf("software.aws.toolkits.*") + excludes = listOf("software.aws.toolkits.telemetry.*") + + // 221+ uses a custom classloader and jacoco fails to find classes + isIncludeNoLocationClasses = true + } +} + +// Jacoco configs taken from official Gradle docs: https://docs.gradle.org/current/userguide/structuring_software_products.html + +// Do not generate reports for individual projects, see toolkit-jacoco-report plugin +tasks.jacocoTestReport.configure { + enabled = false +} + +// Share the coverage data to be aggregated for the whole product +// this can be removed once we're using jvm-test-suites properly +configurations.create("coverageDataElements") { + isVisible = false + isCanBeResolved = false + isCanBeConsumed = true + extendsFrom(configurations.implementation.get()) + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.DOCUMENTATION)) + attribute(DocsType.DOCS_TYPE_ATTRIBUTE, objects.named("jacoco-coverage-data")) + } + tasks.withType { + outgoing.artifact(extensions.getByType().destinationFile!!) + } +} diff --git a/buildSrc/tst/SourceUtilsTest.kt b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/SourceUtilsTest.kt similarity index 79% rename from buildSrc/tst/SourceUtilsTest.kt rename to buildSrc/src/test/kotlin/software/aws/toolkits/gradle/SourceUtilsTest.kt index ffff559f32..12ba1e2f96 100644 --- a/buildSrc/tst/SourceUtilsTest.kt +++ b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/SourceUtilsTest.kt @@ -1,6 +1,8 @@ // Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.gradle + import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith @@ -17,14 +19,19 @@ class SourceUtilsTest(private val folderName: String, private val expected: Bool arrayOf("tst-201", true), arrayOf("tst-190+", true), arrayOf("tst-201+", true), + arrayOf("tst-201-202", true), + arrayOf("tst-193-201", true), + arrayOf("tst-193-202", true), arrayOf("tst-resources", false), arrayOf("tst-resources-201", false), arrayOf("tst-192", false), arrayOf("tst-202", false), arrayOf("tst-202+", false), + arrayOf("src-201", false), arrayOf("random", false), - arrayOf("src-tst", false) + arrayOf("src-tst", false), + arrayOf("tst-192-193", false) ) } diff --git a/buildSrc/tst/toolkits/gradle/changelog/ChangeLogGeneratorTest.kt b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/ChangeLogGeneratorTest.kt similarity index 92% rename from buildSrc/tst/toolkits/gradle/changelog/ChangeLogGeneratorTest.kt rename to buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/ChangeLogGeneratorTest.kt index 4cfab03746..163ad6ac72 100644 --- a/buildSrc/tst/toolkits/gradle/changelog/ChangeLogGeneratorTest.kt +++ b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/ChangeLogGeneratorTest.kt @@ -1,22 +1,22 @@ // Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.inOrder -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify import org.assertj.core.api.Assertions.assertThat import org.intellij.lang.annotations.Language import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.mockito.ArgumentMatchers.anyString -import toolkits.gradle.changelog.ChangeLogGenerator.Companion.renderEntry -import toolkits.gradle.changelog.ChangeType.BUGFIX -import toolkits.gradle.changelog.ChangeType.FEATURE +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import software.aws.toolkits.gradle.changelog.ChangeLogGenerator.Companion.renderEntry +import software.aws.toolkits.gradle.changelog.ChangeType.BUGFIX +import software.aws.toolkits.gradle.changelog.ChangeType.FEATURE import java.nio.file.Path import java.time.LocalDate @@ -77,7 +77,7 @@ class ChangeLogGeneratorTest { ) val writer = mock() - val sut = ChangeLogGenerator(listOf(writer)) + val sut = ChangeLogGenerator(listOf(writer), mock()) sut.addReleasedChanges(listOf(first, third, second)) sut.close() @@ -148,7 +148,7 @@ class ChangeLogGeneratorTest { """ ) - val sut = ChangeLogGenerator(mock()) + val sut = ChangeLogGenerator(mock(), mock()) sut.addReleasedChanges(listOf(first, second)) } @@ -171,7 +171,7 @@ class ChangeLogGeneratorTest { val firstWriter = mock() val secondWriter = mock() - val sut = ChangeLogGenerator(listOf(firstWriter, secondWriter)) + val sut = ChangeLogGenerator(listOf(firstWriter, secondWriter), mock()) sut.addReleasedChanges(listOf(entry)) sut.close() @@ -184,7 +184,7 @@ class ChangeLogGeneratorTest { @Test fun basicWrite() { val writer = mock() - val sut = ChangeLogGenerator(listOf(writer)) + val sut = ChangeLogGenerator(listOf(writer), mock()) val first = createFile( """ @@ -246,7 +246,7 @@ class ChangeLogGeneratorTest { @Test fun canHandleMarkdown() { val writer = mock() - val sut = ChangeLogGenerator(listOf(writer)) + val sut = ChangeLogGenerator(listOf(writer), mock()) val first = createFile( """ @@ -289,7 +289,7 @@ class ChangeLogGeneratorTest { @Test fun canHandleMultiLine() { val writer = mock() - val sut = ChangeLogGenerator(listOf(writer)) + val sut = ChangeLogGenerator(listOf(writer), mock()) val first = createFile( """ diff --git a/buildSrc/tst/toolkits/gradle/changelog/GitStagerTest.kt b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/GitStagerTest.kt similarity index 98% rename from buildSrc/tst/toolkits/gradle/changelog/GitStagerTest.kt rename to buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/GitStagerTest.kt index e273dd747c..a398707533 100644 --- a/buildSrc/tst/toolkits/gradle/changelog/GitStagerTest.kt +++ b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/GitStagerTest.kt @@ -1,7 +1,7 @@ // Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog import org.assertj.core.api.Assertions.assertThat import org.eclipse.jgit.api.Git diff --git a/buildSrc/tst/toolkits/gradle/changelog/GithubWriterTest.kt b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/GithubWriterTest.kt similarity index 90% rename from buildSrc/tst/toolkits/gradle/changelog/GithubWriterTest.kt rename to buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/GithubWriterTest.kt index 22b51e417f..79d566c8b2 100644 --- a/buildSrc/tst/toolkits/gradle/changelog/GithubWriterTest.kt +++ b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/GithubWriterTest.kt @@ -1,13 +1,13 @@ // Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog import org.assertj.core.api.Assertions.assertThat import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder -import toolkits.gradle.changelog.ChangeLogGenerator.Companion.renderEntry +import software.aws.toolkits.gradle.changelog.ChangeLogGenerator.Companion.renderEntry import java.time.LocalDate class GithubWriterTest { @@ -64,7 +64,9 @@ class GithubWriterTest { sut.writeLine( renderEntry( ReleaseEntry( - LocalDate.of(2017, 2, 1), "2.0.0-preview-3", listOf( + LocalDate.of(2017, 2, 1), + "2.0.0-preview-3", + listOf( Entry( ChangeType.FEATURE, "A feature with some an issue link #45 or (#12) but not regular #hash" diff --git a/buildSrc/tst/toolkits/gradle/changelog/JetBrainsWriterTest.kt b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/JetBrainsWriterTest.kt similarity index 93% rename from buildSrc/tst/toolkits/gradle/changelog/JetBrainsWriterTest.kt rename to buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/JetBrainsWriterTest.kt index e147c8dbc3..47d2751712 100644 --- a/buildSrc/tst/toolkits/gradle/changelog/JetBrainsWriterTest.kt +++ b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/JetBrainsWriterTest.kt @@ -1,13 +1,13 @@ // Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog import org.assertj.core.api.Assertions.assertThat import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder -import toolkits.gradle.changelog.ChangeLogGenerator.Companion.renderEntry +import software.aws.toolkits.gradle.changelog.ChangeLogGenerator.Companion.renderEntry import java.time.LocalDate class JetBrainsWriterTest { @@ -103,7 +103,9 @@ class JetBrainsWriterTest { sut.writeLine( renderEntry( ReleaseEntry( - LocalDate.of(2017, 2, 1), "2.0.0-preview-3", listOf( + LocalDate.of(2017, 2, 1), + "2.0.0-preview-3", + listOf( Entry( ChangeType.FEATURE, "A feature with some *code* sample\n```java\nhello();\n```" @@ -136,7 +138,9 @@ class JetBrainsWriterTest { sut.writeLine( renderEntry( ReleaseEntry( - LocalDate.of(2017, 2, 1), "2.0.0-preview-3", listOf( + LocalDate.of(2017, 2, 1), + "2.0.0-preview-3", + listOf( Entry( ChangeType.FEATURE, "A feature with some an issue link #45 or (#12) but not regular #hash" diff --git a/buildSrc/tst/toolkits/gradle/changelog/ReleaseCreatorTest.kt b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/ReleaseCreatorTest.kt similarity index 85% rename from buildSrc/tst/toolkits/gradle/changelog/ReleaseCreatorTest.kt rename to buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/ReleaseCreatorTest.kt index a51dfc04c8..a96e42dd20 100644 --- a/buildSrc/tst/toolkits/gradle/changelog/ReleaseCreatorTest.kt +++ b/buildSrc/src/test/kotlin/software/aws/toolkits/gradle/changelog/ReleaseCreatorTest.kt @@ -1,8 +1,9 @@ // Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package toolkits.gradle.changelog +package software.aws.toolkits.gradle.changelog +import org.mockito.kotlin.mock import org.assertj.core.api.Assertions.assertThat import org.junit.Rule import org.junit.Test @@ -30,7 +31,7 @@ class ReleaseCreatorTest { "type": "bugfix", "description": "Some bugfix" } - """.trimIndent() + """.trimIndent() ) } @@ -41,11 +42,11 @@ class ReleaseCreatorTest { "type": "feature", "description": "Some feature" } - """.trimIndent() + """.trimIndent() ) } - val sut = ReleaseCreator(listOf(firstFile, secondFile), nextReleaseFile) + val sut = ReleaseCreator(listOf(firstFile, secondFile), nextReleaseFile, mock()) sut.create("2.0.0", date) @@ -62,7 +63,7 @@ class ReleaseCreatorTest { "description" : "Some bugfix" } ] } - """.trimIndent() + """.trimIndent() ) assertThat(firstFile).doesNotExist() @@ -73,12 +74,6 @@ class ReleaseCreatorTest { fun exitingReleaseVersionThrows() { val nextReleaseFile = folder.newFolder().resolve("2.0.0.json") nextReleaseFile.createNewFile() - ReleaseCreator(listOf(folder.newFile()), nextReleaseFile) - } - - @Test(expected = RuntimeException::class) - fun noChangesThrows() { - val nextReleaseFile = folder.newFolder().resolve("2.0.0.json") - ReleaseCreator(listOf(), nextReleaseFile) + ReleaseCreator(listOf(folder.newFile()), nextReleaseFile, mock()) } } diff --git a/buildSrc/src/toolkits/gradle/changelog/ChangeLogPlugin.kt b/buildSrc/src/toolkits/gradle/changelog/ChangeLogPlugin.kt deleted file mode 100644 index 4e50a47816..0000000000 --- a/buildSrc/src/toolkits/gradle/changelog/ChangeLogPlugin.kt +++ /dev/null @@ -1,32 +0,0 @@ -// 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/JetBrainsWriter.kt b/buildSrc/src/toolkits/gradle/changelog/JetBrainsWriter.kt deleted file mode 100644 index 986ca9d226..0000000000 --- a/buildSrc/src/toolkits/gradle/changelog/JetBrainsWriter.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 toolkits.gradle.changelog - -import org.commonmark.node.AbstractVisitor -import org.commonmark.node.Heading -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer -import java.io.File -import java.lang.Math.max -import java.lang.Math.min - -class JetBrainsWriter(private val changeNotesFile: File, issueUrl: String? = null) : ChangeLogWriter(issueUrl) { - private val sb = StringBuilder() - - override fun append(line: String) { - sb.append(line) - } - - override fun close() { - val renderer = HtmlRenderer.builder() - .softbreak("
") - .build() - val parser = Parser.builder() - .postProcessor { - it.accept(object : AbstractVisitor() { - override fun visit(heading: Heading) { - heading.level = max(1, min(heading.level + 2, 6)) - } - }) - - it - } - .build() - val htmlVersionError = renderer.render(parser.parse(sb.toString())) - - changeNotesFile.writeText(""" - - - - - - """.trimIndent()) - } - - override fun toString(): String = "JetBrainsWriter(file=$changeNotesFile)" -} diff --git a/buildSrc/src/toolkits/gradle/changelog/ReleaseCreator.kt b/buildSrc/src/toolkits/gradle/changelog/ReleaseCreator.kt deleted file mode 100644 index 3fee200808..0000000000 --- a/buildSrc/src/toolkits/gradle/changelog/ReleaseCreator.kt +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package toolkits.gradle.changelog - -import java.io.File -import java.time.LocalDate - -class ReleaseCreator(private val unreleasedFiles: Collection, private val nextReleaseFile: File) { - init { - if (nextReleaseFile.exists()) { - throw RuntimeException("Release file $nextReleaseFile already exists!") - } - if (unreleasedFiles.isEmpty()) { - throw RuntimeException("No unreleased changes!") - } - } - - fun create(version: String, date: LocalDate = LocalDate.now()) { - val entriesByType = unreleasedFiles.map { readFile(it) }.groupBy { it.type } - val entries = ChangeType.values().flatMap { entriesByType.getOrDefault(it, emptyList()) } - val release = ReleaseEntry(date, version, entries) - - MAPPER.writerWithDefaultPrettyPrinter().writeValue(nextReleaseFile, release) - unreleasedFiles.forEach { it.delete() } - } -} diff --git a/buildSrc/src/toolkits/gradle/changelog/tasks/CreateRelease.kt b/buildSrc/src/toolkits/gradle/changelog/tasks/CreateRelease.kt deleted file mode 100644 index 8ca71867f1..0000000000 --- a/buildSrc/src/toolkits/gradle/changelog/tasks/CreateRelease.kt +++ /dev/null @@ -1,42 +0,0 @@ -// 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/sdk/GenerateSdk.kt b/buildSrc/src/toolkits/gradle/sdk/GenerateSdk.kt deleted file mode 100644 index d94f280fe9..0000000000 --- a/buildSrc/src/toolkits/gradle/sdk/GenerateSdk.kt +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package toolkits.gradle.sdk - -import org.gradle.api.DefaultTask -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.TaskAction -import software.amazon.awssdk.codegen.C2jModels -import software.amazon.awssdk.codegen.CodeGenerator -import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig -import software.amazon.awssdk.codegen.model.service.Paginators -import software.amazon.awssdk.codegen.model.service.ServiceModel -import software.amazon.awssdk.codegen.utils.ModelLoaderUtils -import java.io.File - -/* ktlint-disable custom-ktlint-rules:log-not-lazy */ -open class GenerateSdk : DefaultTask() { - @InputDirectory - lateinit var c2jFolder: File - - @OutputDirectory - lateinit var outputDir: File - - @TaskAction - fun generate() { - outputDir.deleteRecursively() - - logger.info("Generating SDK from $c2jFolder") - val models = C2jModels.builder() - .serviceModel(loadServiceModel()) - .paginatorsModel(loadPaginatorsModel()) - .customizationConfig(loadCustomizationConfig()) - .build() - - CodeGenerator.builder() - .models(models) - .sourcesDirectory(outputDir.absolutePath) - .fileNamePrefix(models.serviceModel().metadata.serviceId) - .build() - .execute() - } - - private fun loadServiceModel(): ServiceModel? = - ModelLoaderUtils.loadModel(ServiceModel::class.java, File(c2jFolder, "service-2.json")) - - private fun loadPaginatorsModel(): Paginators? { - val paginatorsFile = File(c2jFolder, "paginators-1.json") - if (paginatorsFile.exists()) - return ModelLoaderUtils.loadModel(Paginators::class.java, paginatorsFile) - return null - } - - private fun loadCustomizationConfig(): CustomizationConfig = ModelLoaderUtils.loadOptionalModel( - CustomizationConfig::class.java, - File(c2jFolder, "customization.config") - ).orElse(CustomizationConfig.create()) -} diff --git a/buildspec/linuxIntegrationTests.yml b/buildspec/linuxIntegrationTests.yml index 79c627fa6d..a77886db9f 100644 --- a/buildspec/linuxIntegrationTests.yml +++ b/buildspec/linuxIntegrationTests.yml @@ -2,7 +2,7 @@ version: 0.2 cache: paths: - - '/root/.gradle/caches/**/*' +# - '/root/.gradle/caches/**/*' - '/root/.gradle/wrapper/**/*' env: @@ -13,34 +13,58 @@ env: phases: install: - runtime-versions: - java: corretto11 - docker: 19 - dotnet: 3.1 - commands: - - apt-get update - - 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` - - export TOKEN=`jq -r '.Credentials.SessionToken' creds.json` - - pip3 install --user --upgrade aws-sam-cli - - pip3 install --upgrade awscli - pip3 install cfn-lint + - sed -i 's/latest\/download/download\/v1.98.0/g' /usr/local/bin/installSam.sh + - installSam.sh --update + - goenv versions --bare | xargs -I'{}' goenv uninstall -f '{}' + - goenv install 1.21.3 + - goenv global 1.21.3 + - startDocker.sh + # login to DockerHub so we don't get throttled + - export DOCKER_USERNAME=`echo $DOCKER_HUB_TOKEN | jq -r '.username'` + - export DOCKER_PASSWORD=`echo $DOCKER_HUB_TOKEN | jq -r '.password'` + - docker login --username $DOCKER_USERNAME --password $DOCKER_PASSWORD || true + - DOTNET_ROOT="/root/.dotnet" /usr/local/bin/dotnet-install.sh --channel 6.0 + - export PATH="${PATH}:${DOTNET_ROOT}" + - export PATH="$PATH:$HOME/.dotnet/tools" + - dotnet codeartifact-creds install + # should probably be managed as an extension/rule in any tests that need a screen available + - /usr/bin/Xvfb :22 -screen 0 1920x1080x24 & build: commands: - - export SAM_CLI_EXEC=`which sam` - - echo "SAM CLI location $SAM_CLI_EXEC" - - $SAM_CLI_EXEC --version + - | + if [ "$CODEARTIFACT_DOMAIN_NAME" ] && [ "$CODEARTIFACT_REPO_NAME" ]; then + CODEARTIFACT_URL=$(aws codeartifact get-repository-endpoint --domain $CODEARTIFACT_DOMAIN_NAME --repository $CODEARTIFACT_REPO_NAME --format maven --query repositoryEndpoint --output text) + CODEARTIFACT_NUGET_URL=$(aws codeartifact get-repository-endpoint --domain $CODEARTIFACT_DOMAIN_NAME --repository $CODEARTIFACT_REPO_NAME --format nuget --query repositoryEndpoint --output text) + CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token --domain $CODEARTIFACT_DOMAIN_NAME --query authorizationToken --output text --duration-seconds 3600) + fi + + - AWS_CONFIG_FILE=`mktemp` + - | + >$AWS_CONFIG_FILE echo "[default] + role_arn=$ASSUME_ROLE_ARN + credential_source=EcsContainer" + - chmod +x gradlew - - env AWS_ACCESS_KEY_ID=$KEY_ID AWS_SECRET_ACCESS_KEY=$SECRET AWS_SESSION_TOKEN=$TOKEN ./gradlew integrationTest coverageReport --info --full-stacktrace --console plain + - DISPLAY=:22 ./gradlew -PideProfileName=$ALTERNATIVE_IDE_PROFILE_NAME integrationTest coverageReport -x :jetbrains-rider:integrationTest --info --console plain + - | + if [ $(docker ps -q | wc -l) -gt 0 ]; then + echo 'Docker containers were not completely cleaned up!'; + docker ps; + for container in $(docker ps -q); do + echo $container; + docker exec -i $container sh -c 'tail -n +1 /tmp/logs/*'; + done; + + exit 1; + 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 - CI_BUILD_ID="${CODEBUILD_BUILD_ID}" - - test -n "$CODE_COV_TOKEN" && curl -s https://codecov.io/bash > codecov.sh || true # this sometimes times out but we don't want to fail the build - - test -n "$CODE_COV_TOKEN" && bash ./codecov.sh -t $CODE_COV_TOKEN -F integtest || true + - test -n "$CODE_COV_TOKEN" && curl -Os https://uploader.codecov.io/latest/linux/codecov && chmod +x codecov || true # this sometimes times out but we don't want to fail the build + - test -n "$CODE_COV_TOKEN" && test -n "$CODEBUILD_BUILD_SUCCEEDING" && ./codecov -t $CODE_COV_TOKEN -F integtest || true post_build: commands: diff --git a/buildspec/linuxTests.yml b/buildspec/linuxTests.yml index 8672637db9..5d1b1e8d25 100644 --- a/buildspec/linuxTests.yml +++ b/buildspec/linuxTests.yml @@ -2,7 +2,7 @@ version: 0.2 cache: paths: - - '/root/.gradle/caches/**/*' +# - '/root/.gradle/caches/**/*' - '/root/.gradle/wrapper/**/*' env: @@ -12,20 +12,31 @@ env: phases: install: - runtime-versions: - java: corretto11 - dotnet: 3.1 + commands: + - useradd codebuild-user + - dnf install -y acl + - chown -R codebuild-user:codebuild-user /codebuild/output + - setfacl -m d:o::rwx,o::rwx /root build: commands: + - | + if [ "$CODEARTIFACT_DOMAIN_NAME" ] && [ "$CODEARTIFACT_REPO_NAME" ]; then + CODEARTIFACT_URL=$(aws codeartifact get-repository-endpoint --domain $CODEARTIFACT_DOMAIN_NAME --repository $CODEARTIFACT_REPO_NAME --format maven --query repositoryEndpoint --output text) + CODEARTIFACT_NUGET_URL=$(aws codeartifact get-repository-endpoint --domain $CODEARTIFACT_DOMAIN_NAME --repository $CODEARTIFACT_REPO_NAME --format nuget --query repositoryEndpoint --output text) + CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token --domain $CODEARTIFACT_DOMAIN_NAME --query authorizationToken --output text --duration-seconds 3600) + su codebuild-user -c "dotnet codeartifact-creds install" + fi + - chmod +x gradlew - - ./gradlew check coverageReport --info --full-stacktrace --console plain - - ./gradlew buildPlugin + - su codebuild-user -c "./gradlew -PideProfileName=$ALTERNATIVE_IDE_PROFILE_NAME check coverageReport --info --console plain --continue" + - ./gradlew -PideProfileName=$ALTERNATIVE_IDE_PROFILE_NAME 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}" - - test -n "$CODE_COV_TOKEN" && curl -s https://codecov.io/bash > codecov.sh || true # this sometimes times out but we don't want to fail the build - - test -n "$CODE_COV_TOKEN" && bash ./codecov.sh -t $CODE_COV_TOKEN -F unittest || true + - test -n "$CODE_COV_TOKEN" && curl -Os https://uploader.codecov.io/latest/linux/codecov && chmod +x codecov || true # this sometimes times out but we don't want to fail the build + - test -n "$CODE_COV_TOKEN" && test -n "$CODEBUILD_BUILD_SUCCEEDING" && ./codecov -t $CODE_COV_TOKEN -F unittest || true + - test -n "$CODE_COV_TOKEN" && test -n "$CODEBUILD_BUILD_SUCCEEDING" && ./codecov -t $CODE_COV_TOKEN -F codewhisperer || true post_build: commands: @@ -36,7 +47,7 @@ phases: - 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 + - cp -r ./intellij/build/distributions/*.zip $BUILD_ARTIFACTS/ || touch $BUILD_ARTIFACTS/build_failed reports: unit-test: diff --git a/buildspec/linuxUiTests.yml b/buildspec/linuxUiTests.yml index ed4d130f87..98e99c1d60 100644 --- a/buildspec/linuxUiTests.yml +++ b/buildspec/linuxUiTests.yml @@ -1,72 +1,73 @@ version: 0.2 -#cache: -# paths: -# - 'gradle-home/caches/**/*' -# - 'gradle-home/wrapper/**/*' +cache: + paths: +# - '/root/.gradle/caches/**/*' +# - '/root/.gradle/wrapper/**/*' env: variables: CI: true - RECORD_UI: true LOCAL_ENV_RUN: true - GRADLE_USER_HOME: gradle-home AWS_STS_REGIONAL_ENDPOINTS: regional + DISPLAY: :99 + SCREEN_WIDTH: 1920 + SCREEN_HEIGHT: 1080 + SCREEN_DEPTH: 24 + phases: install: - runtime-versions: - java: corretto11 - dotnet: 3.1 - commands: - - apt-get update - - 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=$KEY_ID - aws_secret_access_key=$SECRET - aws_session_token=$TOKEN" - - pip3 install --user --upgrade aws-sam-cli + - dnf install -y marco mate-media + - startDesktop.sh + + # login to DockerHub so we don't get throttled + - export DOCKER_USERNAME=`echo $DOCKER_HUB_TOKEN | jq -r '.username'` + - export DOCKER_PASSWORD=`echo $DOCKER_HUB_TOKEN | jq -r '.password'` + - docker login --username $DOCKER_USERNAME --password $DOCKER_PASSWORD || true + - export PATH="$PATH:$HOME/.dotnet/tools" + - dotnet codeartifact-creds install build: commands: - - export SAM_CLI_EXEC=`which sam` - - echo "SAM CLI location $SAM_CLI_EXEC" - - $SAM_CLI_EXEC --version - - Xvfb :99 -screen 0 1920x1080x24 & - - export DISPLAY=:99 - - while [ ! -e /tmp/.X11-unix/X99 ]; do sleep 0.1; done - - icewm & - - chmod +x gradlew - - ./gradlew buildPlugin --console plain --info - - > - if [ "$RECORD_UI" ]; then - ffmpeg -loglevel warning -f x11grab -video_size 1920x1080 -i :99 -codec:v libx264 -r 12 /tmp/screen_recording.mp4 & + - | + if [ "$CODEARTIFACT_DOMAIN_NAME" ] && [ "$CODEARTIFACT_REPO_NAME" ]; then + CODEARTIFACT_URL=$(aws codeartifact get-repository-endpoint --domain $CODEARTIFACT_DOMAIN_NAME --repository $CODEARTIFACT_REPO_NAME --format maven --query repositoryEndpoint --output text) + CODEARTIFACT_NUGET_URL=$(aws codeartifact get-repository-endpoint --domain $CODEARTIFACT_DOMAIN_NAME --repository $CODEARTIFACT_REPO_NAME --format nuget --query repositoryEndpoint --output text) + CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token --domain $CODEARTIFACT_DOMAIN_NAME --query authorizationToken --output text --duration-seconds 3600) fi - - env AWS_ACCESS_KEY_ID=$KEY_ID AWS_SECRET_ACCESS_KEY=$SECRET AWS_SESSION_TOKEN=$TOKEN ./gradlew uiTestCore coverageReport --console plain --info + + - AWS_CONFIG_FILE=`mktemp` + - | + >$AWS_CONFIG_FILE echo "[default] + role_arn=$ASSUME_ROLE_ARN + credential_source=EcsContainer" + + - chmod +x gradlew + - ./gradlew -PideProfileName=$ALTERNATIVE_IDE_PROFILE_NAME buildPlugin --console plain --info + + - ffmpeg -loglevel quiet -nostdin -f x11grab -video_size ${SCREEN_WIDTH}x${SCREEN_HEIGHT} -i ${DISPLAY} -codec:v libx264 -pix_fmt yuv420p -vf drawtext="fontsize=48:box=1:boxcolor=black@0.75:boxborderw=5:fontcolor=white:x=0:y=h-text_h:text='%{gmtime\:%H\\\\\:%M\\\\\:%S}'" -framerate 12 -g 12 /tmp/screen_recording.mp4 & + - ./gradlew -PideProfileName=$ALTERNATIVE_IDE_PROFILE_NAME uiTestCore coverageReport --console plain --info post_build: commands: - TEST_ARTIFACTS="/tmp/testArtifacts" - mkdir -p $TEST_ARTIFACTS/test-reports + + - pkill -SIGINT ffmpeg && sleep 5 + - 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/; fi + - mv /tmp/screen_recording.mp4 $TEST_ARTIFACTS/ - 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}" - - test -n "$CODE_COV_TOKEN" && curl -s https://codecov.io/bash > codecov.sh || true # this sometimes times out but we don't want to fail the build - - test -n "$CODE_COV_TOKEN" && test -n "$CODEBUILD_BUILD_SUCCEEDING" && bash ./codecov.sh -t $CODE_COV_TOKEN -F uitest || true + - test -n "$CODE_COV_TOKEN" && curl -Os https://uploader.codecov.io/latest/linux/codecov && chmod +x codecov || true # this sometimes times out but we don't want to fail the build + - test -n "$CODE_COV_TOKEN" && test -n "$CODEBUILD_BUILD_SUCCEEDING" && ./codecov -t $CODE_COV_TOKEN -F uitest || true reports: ui-test: diff --git a/buildspec/windowsTests.yml b/buildspec/windowsTests.yml index 1ecd03bae3..24d0346c1d 100644 --- a/buildspec/windowsTests.yml +++ b/buildspec/windowsTests.yml @@ -7,19 +7,54 @@ env: phases: install: - runtime-versions: - java: openjdk11 - dotnet: 2.2 - commands: + - | + $url = 'https://corretto.aws/downloads/latest/amazon-corretto-17-x64-windows-jdk.msi'; + Write-Host ('Downloading from {0}' -f $url); + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; + Invoke-WebRequest -Uri $url -OutFile 'corretto_jdk_build.msi'; + Start-Process 'corretto_jdk_build.msi' -PassThru | Wait-Process + - | + $javaName = "C:\Program Files\Amazon Corretto" | ForEach-Object { + ls $_ | Sort-Object -Descending -Property Name | Select-Object -first 1 -expandproperty Name + } + $JAVA_HOME = "C:\Program Files\Amazon Corretto\$javaName" - | if(-Not($Env:CODE_COV_TOKEN -eq $null)) { - choco install -y --no-progress codecov + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; + Invoke-WebRequest -Uri https://uploader.codecov.io/latest/windows/codecov.exe -Outfile codecov.exe } + - dotnet --list-sdks + - | + Invoke-WebRequest 'https://dot.net/v1/dotnet-install.ps1' -OutFile 'dotnet-install.ps1'; + ./dotnet-install.ps1 -Verbose -InstallDir 'C:\Program Files\dotnet' -Channel '5.0' + - | + $DOTNET_ROOT = "$Env:USERPROFILE\.dotnet" + $Env:PATH = "$Env:PATH;$DOTNET_ROOT;$DOTNET_ROOT\tools" + dotnet tool install -g AWS.CodeArtifact.NuGet.CredentialProvider + dotnet codeartifact-creds install + - dotnet --list-sdks build: commands: - - ./gradlew check coverageReport --info --full-stacktrace --console plain + - | + # See https://github.com/NuGet/NuGet.Client/pull/4259 + $Env:NUGET_EXPERIMENTAL_CHAIN_BUILD_RETRY_POLICY = "3,1000" + + $Env:JAVA_HOME = $JAVA_HOME + if ($Env:CODEARTIFACT_DOMAIN_NAME -and $Env:CODEARTIFACT_REPO_NAME) { + $Env:CODEARTIFACT_URL=aws codeartifact get-repository-endpoint --domain $Env:CODEARTIFACT_DOMAIN_NAME --repository $Env:CODEARTIFACT_REPO_NAME --format maven --query repositoryEndpoint --output text + $Env:CODEARTIFACT_NUGET_URL=aws codeartifact get-repository-endpoint --domain $Env:CODEARTIFACT_DOMAIN_NAME --repository $Env:CODEARTIFACT_REPO_NAME --format nuget --query repositoryEndpoint --output text + $Env:CODEARTIFACT_AUTH_TOKEN=aws codeartifact get-authorization-token --domain $Env:CODEARTIFACT_DOMAIN_NAME --query authorizationToken --output text --duration-seconds 3600 + } + + # Rider is very expensive (spikes our CI jobs to 50% CPU, so let it do the prep work in parallel, but run tests later + ./gradlew -PideProfileName="$Env:ALTERNATIVE_IDE_PROFILE_NAME" check :jetbrains-rider:compileTestKotlin -x :jetbrains-rider:test --info --console plain --continue + if ($LastExitCode -ne 0) { + Write-Host "Command failed with exit code $LastExitCode" + exit -1 + } + # ./gradlew -PideProfileName="$Env:ALTERNATIVE_IDE_PROFILE_NAME" :jetbrains-rider:check coverageReport --info --console plain post_build: commands: @@ -48,8 +83,8 @@ phases: $env:VCS_COMMIT_ID=$Env:CODEBUILD_RESOLVED_SOURCE_VERSION; $env:CI_BUILD_URL=[uri]::EscapeUriString($Env:CODEBUILD_BUILD_URL); $env:CI_BUILD_ID=$Env:CODEBUILD_BUILD_ID; - codecov -t $Env:CODE_COV_TOKEN ` - --flag unittest ` + .\codecov.exe -t $Env:CODE_COV_TOKEN ` + --flags unittest ` -f "build/reports/jacoco/coverageReport/coverageReport.xml" ` -c $Env:CODEBUILD_RESOLVED_SOURCE_VERSION } diff --git a/codecov.yml b/codecov.yml index fafed1135e..cd234eeca1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -4,6 +4,7 @@ codecov: notify: require_ci_to_pass: no + max_report_age: off coverage: precision: 2 @@ -20,6 +21,12 @@ coverage: only_pulls: true flags: - "unittest" + codewhisperer: + target: 75% + paths: + - "**/src/software/aws/toolkits/jetbrains/services/codewhisperer/*" + flags: + - "codewhisperer" patch: default: threshold: 1 @@ -28,6 +35,7 @@ coverage: unittest: threshold: 1 only_pulls: true + informational: true flags: - "unittest" changes: no @@ -35,8 +43,13 @@ coverage: comment: false ignore: - - "ktlint-rules/**/*" + - "detekt-rules/**/*" - "resources/**/*" - - "telemetry-client/**/*" + - "sdk-codegen/**/*" - "jetbrains-rider/**/*.Generated.kt" - "**/TelemetryDefinitions.kt" + +flags: + codewhisperer: + paths: + - "**/src/software/aws/toolkits/jetbrains/services/codewhisperer/" diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 50778eb78f..af988342d7 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,24 +1,31 @@ // 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 +plugins { + id("toolkit-kotlin-conventions") + id("toolkit-testing") + id("toolkit-integration-testing") +} 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") + api(project(":sdk-codegen")) + + api(libs.aws.cognitoidentity) + api(libs.aws.ecr) + api(libs.aws.ecs) + api(libs.aws.lambda) + api(libs.aws.s3) + api(libs.aws.sso) + api(libs.aws.ssooidc) + api(libs.aws.sts) + api(libs.bundles.jackson) - compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") + testImplementation(libs.junit4) + + testRuntimeOnly(libs.junit5.jupiterVintage) +} - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") +tasks.test { + useJUnitPlatform() } diff --git a/core/detekt-baseline-integrationTest.xml b/core/detekt-baseline-integrationTest.xml new file mode 100644 index 0000000000..b9d892d26e --- /dev/null +++ b/core/detekt-baseline-integrationTest.xml @@ -0,0 +1,7 @@ + + + + + NoNameShadowing:BucketUtilsTest.kt$BucketUtilsTest${ it.status(BucketVersioningStatus.ENABLED) } + + diff --git a/core/detekt-baseline-main.xml b/core/detekt-baseline-main.xml new file mode 100644 index 0000000000..2674934bb2 --- /dev/null +++ b/core/detekt-baseline-main.xml @@ -0,0 +1,12 @@ + + + + + SleepInsteadOfDelay:Waiter.kt$sleep(delay.toMillis()) + UseCheckOrError:LambdaRuntime.kt$LambdaRuntime$throw IllegalStateException("LambdaRuntime has no runtime or override string") + UseCheckOrError:ToolkitRegionProvider.kt$ToolkitRegionProvider$throw IllegalStateException("$serviceId in ${region.partitionId} lacks a partitionEndpoint") + UseCheckOrError:ToolkitRegionProvider.kt$ToolkitRegionProvider$throw IllegalStateException("$serviceId is not global in ${region.partitionId}") + UseCheckOrError:ToolkitRegionProvider.kt$ToolkitRegionProvider$throw IllegalStateException("Partition data is missing for ${region.partitionId}") + UseCheckOrError:ToolkitRegionProvider.kt$ToolkitRegionProvider$throw IllegalStateException("Unknown service $serviceId in ${region.partitionId}") + + diff --git a/core/detekt-baseline-test.xml b/core/detekt-baseline-test.xml new file mode 100644 index 0000000000..538e8f4c56 --- /dev/null +++ b/core/detekt-baseline-test.xml @@ -0,0 +1,9 @@ + + + + + UnsafeCallOnNullableType:EnvironmentVariableHelper.kt$EnvironmentVariableHelper$getField(System.getenv().javaClass, System.getenv(), "m")!! + UnsafeCallOnNullableType:PartitionParserTest.kt$PartitionParserTest$PartitionParser.parse(BundledResources.ENDPOINTS_FILE)!! + UnsafeCallOnNullableType:ZipUtilsTest.kt$ZipUtilsTest$zipFile!! + + diff --git a/core/detekt-baseline.xml b/core/detekt-baseline.xml new file mode 100644 index 0000000000..8e5f932438 --- /dev/null +++ b/core/detekt-baseline.xml @@ -0,0 +1,11 @@ + + + + + UseCheckOrError:LambdaRuntime.kt$LambdaRuntime$throw IllegalStateException("LambdaRuntime has no runtime or override string") + UseCheckOrError:ToolkitRegionProvider.kt$ToolkitRegionProvider$throw IllegalStateException("$serviceId in ${region.partitionId} lacks a partitionEndpoint") + UseCheckOrError:ToolkitRegionProvider.kt$ToolkitRegionProvider$throw IllegalStateException("$serviceId is not global in ${region.partitionId}") + UseCheckOrError:ToolkitRegionProvider.kt$ToolkitRegionProvider$throw IllegalStateException("Partition data is missing for ${region.partitionId}") + UseCheckOrError:ToolkitRegionProvider.kt$ToolkitRegionProvider$throw IllegalStateException("Unknown service $serviceId in ${region.partitionId}") + + diff --git a/core/it/software/aws/toolkits/core/s3/BucketUtilsTest.kt b/core/it/software/aws/toolkits/core/s3/BucketUtilsTest.kt index b9bf4b4fe3..50ca0c2340 100644 --- a/core/it/software/aws/toolkits/core/s3/BucketUtilsTest.kt +++ b/core/it/software/aws/toolkits/core/s3/BucketUtilsTest.kt @@ -14,12 +14,12 @@ import software.amazon.awssdk.services.s3.model.PutObjectRequest import software.aws.toolkits.core.rules.S3TemporaryBucketRule class BucketUtilsTest { - private val usEast2Client = S3Client.builder().region(Region.US_EAST_2).build() + private val usEast1Client = S3Client.builder().region(Region.US_EAST_1).build() private val euWest2Client = S3Client.builder().region(Region.EU_WEST_2).build() @Rule @JvmField - val usEast2TempBucket = S3TemporaryBucketRule(usEast2Client) + val usEast1TempBucket = S3TemporaryBucketRule(usEast1Client) @Rule @JvmField @@ -33,16 +33,16 @@ class BucketUtilsTest { @Test fun deleteABucketWithObjects() { createAndDeleteBucket { bucket -> - usEast2Client.putObject(PutObjectRequest.builder().bucket(bucket).key("hello").build(), RequestBody.fromString("")) + usEast1Client.putObject(PutObjectRequest.builder().bucket(bucket).key("hello").build(), RequestBody.fromString("")) } } @Test fun deleteABucketWithVersionedObjects() { createAndDeleteBucket { bucket -> - usEast2Client.putBucketVersioning { it.bucket(bucket).versioningConfiguration { it.status(BucketVersioningStatus.ENABLED) } } - usEast2Client.putObject(PutObjectRequest.builder().bucket(bucket).key("hello").build(), RequestBody.fromString("")) - usEast2Client.putObject(PutObjectRequest.builder().bucket(bucket).key("hello").build(), RequestBody.fromString("")) + usEast1Client.putBucketVersioning { it.bucket(bucket).versioningConfiguration { it.status(BucketVersioningStatus.ENABLED) } } + usEast1Client.putObject(PutObjectRequest.builder().bucket(bucket).key("hello").build(), RequestBody.fromString("")) + usEast1Client.putObject(PutObjectRequest.builder().bucket(bucket).key("hello").build(), RequestBody.fromString("")) } } @@ -50,20 +50,20 @@ class BucketUtilsTest { fun canGetRegionBucketWithRegionNotSameAsClient() { val bucket = euWest2TempBucket.createBucket() - assertThat(usEast2Client.regionForBucket(bucket)).isEqualTo("eu-west-2") + assertThat(usEast1Client.regionForBucket(bucket)).isEqualTo("eu-west-2") } @Test fun canGetRegionInSameRegionAsClient() { - val bucket = usEast2TempBucket.createBucket() + val bucket = usEast1TempBucket.createBucket() - assertThat(usEast2Client.regionForBucket(bucket)).isEqualTo("us-east-2") + assertThat(usEast1Client.regionForBucket(bucket)).isEqualTo("us-east-1") } private fun createAndDeleteBucket(populateBucket: (String) -> Unit) { - val bucket = usEast2TempBucket.createBucket() + val bucket = usEast1TempBucket.createBucket() populateBucket(bucket) - usEast2Client.deleteBucketAndContents(bucket) - assertThat(usEast2Client.listBuckets().buckets().map { it.name() }).doesNotContain(bucket) + usEast1Client.deleteBucketAndContents(bucket) + assertThat(usEast1Client.listBuckets().buckets().map { it.name() }).doesNotContain(bucket) } } diff --git a/core/src/software/aws/toolkits/core/ConnectionSettings.kt b/core/src/software/aws/toolkits/core/ConnectionSettings.kt new file mode 100644 index 0000000000..9ae2272ff0 --- /dev/null +++ b/core/src/software/aws/toolkits/core/ConnectionSettings.kt @@ -0,0 +1,41 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core + +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider +import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider +import software.aws.toolkits.core.credentials.toEnvironmentVariables +import software.aws.toolkits.core.region.AwsRegion + +sealed interface ClientConnectionSettings { + val region: AwsRegion + val providerId: String + + /** + * Copies bean with the region replaced + */ + fun withRegion(region: AwsRegion): ClientConnectionSettings +} + +data class ConnectionSettings(val credentials: ToolkitCredentialsProvider, override val region: AwsRegion) : ClientConnectionSettings { + override val providerId: String + get() = credentials.id + + override fun withRegion(region: AwsRegion) = copy(region = region) +} + +data class TokenConnectionSettings( + val tokenProvider: ToolkitBearerTokenProvider, + override val region: AwsRegion +) : ClientConnectionSettings { + override val providerId: String + get() = tokenProvider.id + + override fun withRegion(region: AwsRegion) = copy(region = region) +} + +val ConnectionSettings.shortName get() = "${credentials.shortName}@${region.id}" + +fun ConnectionSettings.toEnvironmentVariables(): Map = region.toEnvironmentVariables() + + credentials.resolveCredentials().toEnvironmentVariables() diff --git a/core/src/software/aws/toolkits/core/ToolkitClientCustomizer.kt b/core/src/software/aws/toolkits/core/ToolkitClientCustomizer.kt new file mode 100644 index 0000000000..cb2f2cffc0 --- /dev/null +++ b/core/src/software/aws/toolkits/core/ToolkitClientCustomizer.kt @@ -0,0 +1,34 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core + +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider +import software.amazon.awssdk.awscore.client.builder.AwsClientBuilder +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration + +/** + * Used to override/add behavior during AWS SDK Client creation. + * + * Example usage to add a local development endpoint for a particular service: + * + * ``` + * class MyDevEndpointCustomizer : AwsClientCustomizer { + * override fun customize(credentialProvider: AwsCredentialsProvider, regionId: String, builder: AwsClientBuilder<*, *>) { + * if (builder is LambdaClientBuilder && connection.region.id == "us-west-2") { + * builder.endpointOverride(URI.create("http://localhost:8888")) + * } + * } + * } + * ``` + */ +fun interface ToolkitClientCustomizer { + fun customize( + credentialProvider: AwsCredentialsProvider?, + tokenProvider: SdkTokenProvider?, + regionId: String, + builder: AwsClientBuilder<*, *>, + clientOverrideConfiguration: ClientOverrideConfiguration.Builder + ) +} diff --git a/core/src/software/aws/toolkits/core/ToolkitClientManager.kt b/core/src/software/aws/toolkits/core/ToolkitClientManager.kt index d6e49661e2..4253d4a940 100644 --- a/core/src/software/aws/toolkits/core/ToolkitClientManager.kt +++ b/core/src/software/aws/toolkits/core/ToolkitClientManager.kt @@ -5,21 +5,24 @@ package software.aws.toolkits.core import org.jetbrains.annotations.TestOnly import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider +import software.amazon.awssdk.auth.token.signer.aws.BearerTokenSigner +import software.amazon.awssdk.awscore.client.builder.AwsClientBuilder import software.amazon.awssdk.awscore.client.builder.AwsDefaultClientBuilder import software.amazon.awssdk.core.SdkClient +import software.amazon.awssdk.core.client.builder.SdkSyncClientBuilder +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration import software.amazon.awssdk.core.client.config.SdkAdvancedClientOption +import software.amazon.awssdk.core.retry.RetryMode import software.amazon.awssdk.http.SdkHttpClient import software.amazon.awssdk.regions.Region -import software.amazon.awssdk.services.s3.S3ClientBuilder -import software.amazon.awssdk.services.s3.internal.handlers.CreateBucketInterceptor -import software.amazon.awssdk.services.s3.internal.handlers.DecodeUrlEncodedResponseInterceptor -import software.amazon.awssdk.services.s3.internal.handlers.DisableDoubleUrlEncodingInterceptor -import software.amazon.awssdk.services.s3.internal.handlers.EnableChunkedEncodingInterceptor -import software.amazon.awssdk.services.s3.internal.handlers.EndpointAddressInterceptor -import software.amazon.awssdk.services.s3.internal.handlers.PutObjectInterceptor +import software.amazon.awssdk.utils.SdkAutoCloseable +import software.aws.toolkits.core.clients.nullDefaultProfileFile 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.getLogger +import software.aws.toolkits.core.utils.warn import java.lang.reflect.Modifier import java.net.URI import java.util.concurrent.ConcurrentHashMap @@ -30,136 +33,182 @@ import kotlin.reflect.KClass */ abstract class ToolkitClientManager { data class AwsClientKey( - val credentialProviderId: String, + val providerId: String, val region: AwsRegion, val serviceClass: KClass ) private val cachedClients = ConcurrentHashMap() - protected abstract val sdkHttpClient: SdkHttpClient protected abstract val userAgent: String - inline fun getClient( - credentialsProviderOverride: ToolkitCredentialsProvider? = null, - regionOverride: AwsRegion? = null - ): T = this.getClient(T::class, credentialsProviderOverride, regionOverride) + protected abstract fun sdkHttpClient(): SdkHttpClient - @Suppress("UNCHECKED_CAST") - open fun getClient( - clz: KClass, - credentialsProviderOverride: ToolkitCredentialsProvider? = null, - regionOverride: AwsRegion? = null - ): T { - val credProvider = credentialsProviderOverride ?: getCredentialsProvider() - val region = regionOverride ?: getRegion() + inline fun getClient(credProvider: ToolkitCredentialsProvider, region: AwsRegion): T = + this.getClient(T::class, ConnectionSettings(credProvider, region)) + + inline fun getClient(connection: ClientConnectionSettings<*>): T = this.getClient(T::class, connection) + fun getClient(sdkClass: KClass, connection: ClientConnectionSettings<*>): T { val key = AwsClientKey( - credentialProviderId = credProvider.id, - region = region, - serviceClass = clz + providerId = connection.providerId, + region = connection.region, + serviceClass = sdkClass ) - val serviceId = key.serviceClass.java.getField("SERVICE_NAME").get(null) as String - if (serviceId !in GLOBAL_SERVICE_BLACKLIST && getRegionProvider().isServiceGlobal(region, serviceId)) { - val globalRegion = getRegionProvider().getGlobalRegionForService(region, serviceId) - return cachedClients.computeIfAbsent(key.copy(region = globalRegion)) { createNewClient(it, globalRegion, credProvider) } as T + val serviceId = key.serviceClass.java.getField("SERVICE_METADATA_ID").get(null) as String + if (serviceId !in GLOBAL_SERVICE_DENY_LIST && getRegionProvider().isServiceGlobal(connection.region, serviceId)) { + val globalRegion = getRegionProvider().getGlobalRegionForService(connection.region, serviceId) + @Suppress("UNCHECKED_CAST") + return cachedClients.computeIfAbsent(key.copy(region = globalRegion)) { + createNewClient(sdkClass, connection.withRegion(region = globalRegion)) + } as T } - return cachedClients.computeIfAbsent(key) { createNewClient(it, region, credProvider) } as T + @Suppress("UNCHECKED_CAST") + return cachedClients.computeIfAbsent(key) { createNewClient(sdkClass, connection) } as T + } + + private fun createNewClient(sdkClass: KClass, connection: ClientConnectionSettings<*>): T = when (connection) { + is ConnectionSettings -> constructAwsClient( + sdkClass = sdkClass, + credProvider = connection.credentials, + region = Region.of(connection.region.id), + ) + + is TokenConnectionSettings -> constructAwsClient( + sdkClass = sdkClass, + tokenProvider = connection.tokenProvider, + region = Region.of(connection.region.id), + ) } /** - * Get the current active credential provider for the toolkit + * Constructs a new low-level AWS client whose lifecycle is **NOT** managed centrally. Caller is responsible for shutting down the client + */ + inline fun createUnmanagedClient( + credProvider: AwsCredentialsProvider, + region: Region, + endpointOverride: String? = null, + clientCustomizer: ToolkitClientCustomizer? = null + ): T = createUnmanagedClient(T::class, credProvider, region, endpointOverride, clientCustomizer) + + /** + * Constructs a new low-level AWS client whose lifecycle is **NOT** managed centrally. Caller is responsible for shutting down the client */ - protected abstract fun getCredentialsProvider(): ToolkitCredentialsProvider + fun createUnmanagedClient( + sdkClass: KClass, + credProvider: AwsCredentialsProvider, + region: Region, + endpointOverride: String?, + clientCustomizer: ToolkitClientCustomizer? = null + ): T = constructAwsClient(sdkClass, credProvider = credProvider, region = region, endpointOverride = endpointOverride, clientCustomizer = clientCustomizer) protected abstract fun getRegionProvider(): ToolkitRegionProvider /** - * Get the current active region for the toolkit + * Allow implementations to apply customizations to clients before they are built */ - protected abstract fun getRegion(): AwsRegion + protected open fun globalClientCustomizer( + credentialProvider: AwsCredentialsProvider?, + tokenProvider: SdkTokenProvider?, + regionId: String, + builder: AwsClientBuilder<*, *>, + clientOverrideConfiguration: ClientOverrideConfiguration.Builder + ) {} /** - * Calls [AutoCloseable.close] if client implements [AutoCloseable] and clears the cache + * Calls [SdkAutoCloseable.close] on all managed clients and clears the cache */ protected fun shutdown() { - cachedClients.values.mapNotNull { it as? AutoCloseable }.forEach { it.close() } + cachedClients.values.forEach { it.close() } + cachedClients.clear() } protected fun invalidateSdks(providerId: String) { - cachedClients.keys.removeIf { it.credentialProviderId == providerId } + val invalidClients = cachedClients.entries.filter { it.key.providerId == providerId }.toSet() + cachedClients.entries.removeAll(invalidClients) + invalidClients.forEach { it.value.close() } } - /** - * Used by [software.aws.toolkits.jetbrains.core.MockClientManager] - */ - @TestOnly - protected fun clear() = cachedClients.clear() - - @TestOnly - fun cachedClients() = cachedClients - - /** - * Creates a new client for the requested [AwsClientKey] - */ - @Suppress("UNCHECKED_CAST") - protected open fun createNewClient( - key: AwsClientKey, - region: AwsRegion = key.region, - credProvider: ToolkitCredentialsProvider = getCredentialsProvider() + protected open fun constructAwsClient( + sdkClass: KClass, + credProvider: AwsCredentialsProvider? = null, + tokenProvider: SdkTokenProvider? = null, + region: Region, + endpointOverride: String? = null, + clientCustomizer: ToolkitClientCustomizer? = null ): T { - val sdkClass = key.serviceClass as KClass - return createNewClient(sdkClass, sdkHttpClient, Region.of(region.id), credProvider, userAgent) - } + checkNotNull(credProvider ?: tokenProvider) { "Either a credential provider or a bearer token provider must be provided" } + + val builderMethod = sdkClass.java.methods.find { + it.name == "builder" && Modifier.isStatic(it.modifiers) && Modifier.isPublic(it.modifiers) + } ?: throw IllegalArgumentException("Expected service interface to have a public static `builder()` method.") + + val builder = builderMethod.invoke(null) as AwsDefaultClientBuilder<*, *> + + @Suppress("UNCHECKED_CAST") + return builder + .region(region) + .apply { + if (this is SdkSyncClientBuilder<*, *>) { + // async clients use CRT, and keeps trying to shut down our apache client even though it doesn't respect our client settings + // so only set this for sync clients + httpClient(sdkHttpClient()) + } - companion object { - private val GLOBAL_SERVICE_BLACKLIST = setOf( - // sts is regionalized but does not identify as such in metadata - "sts" - ) + val clientOverrideConfig = ClientOverrideConfiguration.builder() - fun createNewClient( - sdkClass: KClass, - httpClient: SdkHttpClient, - region: Region, - credProvider: AwsCredentialsProvider, - userAgent: String, - endpointOverride: String? = null - ): T { - val builderMethod = sdkClass.java.methods.find { - it.name == "builder" && Modifier.isStatic(it.modifiers) && Modifier.isPublic(it.modifiers) - } ?: throw IllegalArgumentException("Expected service interface to have a public static `builder()` method.") + if (credProvider != null) { + credentialsProvider(credProvider) + } - val builder = builderMethod.invoke(null) as AwsDefaultClientBuilder<*, *> + if (tokenProvider != null) { + val tokenMethod = builderMethod.returnType.methods.find { + it.name == "tokenProvider" && + it.parameterCount == 1 && + it.parameters[0].type.name == "software.amazon.awssdk.auth.token.credentials.SdkTokenProvider" + } - @Suppress("UNCHECKED_CAST") - return builder - .httpClient(httpClient) - .credentialsProvider(credProvider) - .region(region) - .overrideConfiguration { - it.putAdvancedOption(SdkAdvancedClientOption.USER_AGENT_PREFIX, userAgent) - if (builder is S3ClientBuilder) { - // TODO: Remove after SDK code-gens these instead of uses class loader - it.addExecutionInterceptor(EndpointAddressInterceptor()) - it.addExecutionInterceptor(CreateBucketInterceptor()) - it.addExecutionInterceptor(PutObjectInterceptor()) - it.addExecutionInterceptor(EnableChunkedEncodingInterceptor()) - it.addExecutionInterceptor(DisableDoubleUrlEncodingInterceptor()) - it.addExecutionInterceptor(DecodeUrlEncodedResponseInterceptor()) + if (tokenMethod == null) { + LOG.warn { "Ignoring bearer provider parameter for ${sdkClass.qualifiedName} since it's not a supported client attribute" } + } else { + tokenMethod.invoke(this, tokenProvider) + clientOverrideConfig.nullDefaultProfileFile() + // TODO: why do we need this? + clientOverrideConfig.putAdvancedOption(SdkAdvancedClientOption.SIGNER, BearerTokenSigner()) } } - .also { _ -> - endpointOverride?.let { - builder.endpointOverride(URI.create(it)) - } - if (builder is S3ClientBuilder) { - builder.serviceConfiguration { it.pathStyleAccessEnabled(true) } - } + + clientOverrideConfig.let { configuration -> + configuration.putAdvancedOption(SdkAdvancedClientOption.USER_AGENT_PREFIX, userAgent) + configuration.retryPolicy(RetryMode.STANDARD) } - .build() as T - } + + endpointOverride?.let { + endpointOverride(URI.create(it)) + } + + globalClientCustomizer(credProvider, tokenProvider, region.id(), this, clientOverrideConfig) + + clientCustomizer?.let { + it.customize(credProvider, tokenProvider, region.id(), this, clientOverrideConfig) + } + + // TODO: ban overrideConfiguration outside of here + overrideConfiguration(clientOverrideConfig.build()) + } + .build() as T + } + + @TestOnly + fun cachedClients() = cachedClients + + companion object { + private val LOG = getLogger() + private val GLOBAL_SERVICE_DENY_LIST = setOf( + // sts is regionalized but does not identify as such in metadata + "sts" + ) } } diff --git a/core/src/software/aws/toolkits/core/clients/ClientBuilderUtils.kt b/core/src/software/aws/toolkits/core/clients/ClientBuilderUtils.kt new file mode 100644 index 0000000000..c6b533b6a5 --- /dev/null +++ b/core/src/software/aws/toolkits/core/clients/ClientBuilderUtils.kt @@ -0,0 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.clients + +import software.amazon.awssdk.core.client.builder.SdkClientBuilder +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.profiles.ProfileFile +import java.io.InputStream + +fun ClientOverrideConfiguration.Builder.nullDefaultProfileFile() = defaultProfileFile( + ProfileFile.builder() + .content(InputStream.nullInputStream()) + .type(ProfileFile.Type.CONFIGURATION) + .build() +) + +/** + * Only use if this is the only [overrideConfiguration] block used by the [SdkClientBuilder] + */ +fun SdkClientBuilder<*, C>.nullDefaultProfileFile() = apply { + overrideConfiguration { + it.nullDefaultProfileFile() + } +} diff --git a/core/src/software/aws/toolkits/core/clients/SdkClientProvider.kt b/core/src/software/aws/toolkits/core/clients/SdkClientProvider.kt new file mode 100644 index 0000000000..9159a844e3 --- /dev/null +++ b/core/src/software/aws/toolkits/core/clients/SdkClientProvider.kt @@ -0,0 +1,10 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.clients + +import software.amazon.awssdk.http.SdkHttpClient + +interface SdkClientProvider { + fun sharedSdkClient(): SdkHttpClient +} diff --git a/core/src/software/aws/toolkits/core/credentials/AwsCredentials.kt b/core/src/software/aws/toolkits/core/credentials/AwsCredentials.kt new file mode 100644 index 0000000000..0bf0f45ff9 --- /dev/null +++ b/core/src/software/aws/toolkits/core/credentials/AwsCredentials.kt @@ -0,0 +1,43 @@ +// Copyright 2021 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.AwsCredentials +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.amazon.awssdk.core.SdkSystemSetting + +private val CREDENTIAL_ENVIRONMENT_VARIABLES = setOf( + SdkSystemSetting.AWS_ACCESS_KEY_ID.environmentVariable(), + SdkSystemSetting.AWS_SECRET_ACCESS_KEY.environmentVariable(), + SdkSystemSetting.AWS_SESSION_TOKEN.environmentVariable() +) + +fun AwsCredentials.toEnvironmentVariables(): Map { + val map = mutableMapOf() + map[SdkSystemSetting.AWS_ACCESS_KEY_ID.environmentVariable()] = this.accessKeyId() + map[SdkSystemSetting.AWS_SECRET_ACCESS_KEY.environmentVariable()] = this.secretAccessKey() + + if (this is AwsSessionCredentials) { + map[SdkSystemSetting.AWS_SESSION_TOKEN.environmentVariable()] = this.sessionToken() + } + + return map +} + +fun AwsCredentials.mergeWithExistingEnvironmentVariables(existing: MutableMap, replace: Boolean = false) { + mergeWithExistingEnvironmentVariables(existing.keys, existing::remove, existing::putAll, replace) +} + +fun AwsCredentials.mergeWithExistingEnvironmentVariables( + existingKeys: Collection, + removeKey: (String) -> Unit, + putValues: (Map) -> Unit, + replace: Boolean = false +) { + val envVars = toEnvironmentVariables() + if (replace || existingKeys.none { it in CREDENTIAL_ENVIRONMENT_VARIABLES }) { + CREDENTIAL_ENVIRONMENT_VARIABLES.forEach { removeKey(it) } + putValues(envVars) + } +} diff --git a/core/src/software/aws/toolkits/core/credentials/CredentialProviderFactory.kt b/core/src/software/aws/toolkits/core/credentials/CredentialProviderFactory.kt index c8a3aad744..6d5ebe605b 100644 --- a/core/src/software/aws/toolkits/core/credentials/CredentialProviderFactory.kt +++ b/core/src/software/aws/toolkits/core/credentials/CredentialProviderFactory.kt @@ -4,7 +4,6 @@ package software.aws.toolkits.core.credentials import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider -import software.amazon.awssdk.http.SdkHttpClient import software.aws.toolkits.core.region.AwsRegion /** @@ -16,6 +15,11 @@ interface CredentialProviderFactory { */ val id: String + /** + * ID used to indicate where credentials are stored or retrieved from + */ + val credentialSourceId: CredentialSourceId + /** * 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 @@ -28,7 +32,6 @@ interface CredentialProviderFactory { */ fun createAwsCredentialProvider( providerId: CredentialIdentifier, - region: AwsRegion, - sdkHttpClientSupplier: () -> SdkHttpClient + region: AwsRegion ): AwsCredentialsProvider } diff --git a/core/src/software/aws/toolkits/core/credentials/CredentialsChangeEvent.kt b/core/src/software/aws/toolkits/core/credentials/CredentialsChangeEvent.kt index 0d7eadaf56..f2a2899a2e 100644 --- a/core/src/software/aws/toolkits/core/credentials/CredentialsChangeEvent.kt +++ b/core/src/software/aws/toolkits/core/credentials/CredentialsChangeEvent.kt @@ -8,9 +8,13 @@ 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 = emptyList(), + val modified: List = emptyList(), + val removed: List = emptyList(), + + val ssoAdded: List = emptyList(), + val ssoModified: List = emptyList(), + val ssoRemoved: List = emptyList() ) 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 bacd0519cb..747330d9b1 100644 --- a/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProvider.kt +++ b/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProvider.kt @@ -4,6 +4,27 @@ package software.aws.toolkits.core.credentials import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider +import software.aws.toolkits.resources.message + +enum class CredentialType { + StaticProfile, + StaticSessionProfile, + CredentialProcessProfile, + AssumeRoleProfile, + AssumeMfaRoleProfile, + SsoProfile, + Ec2Metadata, + EcsMetadata +} + +enum class CredentialSourceId { + SharedCredentials, + SdkStore, + Ecs, + Ec2, + EnvVars +} /** * Represents a possible credential provider that can be used within the toolkit. @@ -33,13 +54,29 @@ interface CredentialIdentifier { */ val factoryId: String + /** + * The type of credential + */ + val credentialType: CredentialType? + /** * 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 { +interface SsoSessionBackedCredentialIdentifier { + val sessionIdentifier: String +} + +interface SsoSessionIdentifier { + val id: String + val startUrl: String + val ssoRegion: String + val scopes: Set +} + +abstract class CredentialIdentifierBase(override val credentialType: CredentialType?) : CredentialIdentifier { final override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -56,9 +93,17 @@ abstract class CredentialIdentifierBase : CredentialIdentifier { final override fun toString(): String = "${this::class.simpleName}(id='$id')" } -class ToolkitCredentialsProvider(private val identifier: CredentialIdentifier, delegate: AwsCredentialsProvider) : AwsCredentialsProvider by delegate { - val id: String = identifier.id - val displayName = identifier.displayName +interface ToolkitAuthenticationProvider { + val id: String + val displayName: String +} + +class ToolkitCredentialsProvider( + val identifier: CredentialIdentifier, + val delegate: AwsCredentialsProvider +) : ToolkitAuthenticationProvider, AwsCredentialsProvider by delegate { + override val id: String = identifier.id + override val displayName = identifier.displayName val shortName = identifier.shortName override fun equals(other: Any?): Boolean { @@ -76,3 +121,29 @@ class ToolkitCredentialsProvider(private val identifier: CredentialIdentifier, d override fun toString(): String = "${this::class.simpleName}(identifier='$identifier')" } + +// TODO: try to get rid of this because it's really annoying casting the delegate everywhere +interface ToolkitBearerTokenProviderDelegate : SdkTokenProvider, ToolkitAuthenticationProvider + +class ToolkitBearerTokenProvider(val delegate: ToolkitBearerTokenProviderDelegate) : SdkTokenProvider by delegate, ToolkitAuthenticationProvider by delegate { + companion object { + // TODO: is there a better place for this + fun ssoIdentifier(startUrl: String, region: String = DEFAULT_SSO_REGION) = "sso;$region;$startUrl" + + // TODO: For AWS Builder ID, we only have startUrl for now instead of each users' metadata data i.e. Email address + fun ssoDisplayName(startUrl: String) = if (startUrl == SONO_URL) { + message("aws_builder_id.service_name") + } else { + message("iam_identity_center.service_name", extractOrgID(startUrl)) + } + + fun diskSessionIdentifier(profileName: String) = "diskSessionProfile;$profileName" + fun diskSessionDisplayName(profileName: String) = "IAM Identity Center Session ($profileName)" + } +} + +private const val SONO_URL = "https://view.awsapps.com/start" + +const val DEFAULT_SSO_REGION = "us-east-1" + +private fun extractOrgID(startUrl: String) = startUrl.removePrefix("https://").removeSuffix(".awsapps.com/start") diff --git a/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProviderManager.kt b/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProviderManager.kt index ea649b49c3..e7adcbac58 100644 --- a/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProviderManager.kt +++ b/core/src/software/aws/toolkits/core/credentials/ToolkitCredentialsProviderManager.kt @@ -10,6 +10,11 @@ interface ToolkitCredentialsChangeListener { fun providerAdded(identifier: CredentialIdentifier) {} fun providerModified(identifier: CredentialIdentifier) {} fun providerRemoved(identifier: CredentialIdentifier) {} + fun providerRemoved(providerId: String) {} + + fun ssoSessionAdded(identifier: SsoSessionIdentifier) {} + fun ssoSessionModified(identifier: SsoSessionIdentifier) {} + fun ssoSessionRemoved(identifier: SsoSessionIdentifier) {} } 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 deleted file mode 100644 index 58ed8b8774..0000000000 --- a/core/src/software/aws/toolkits/core/credentials/sso/AccessToken.kt +++ /dev/null @@ -1,18 +0,0 @@ -// 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 deleted file mode 100644 index d40b3a9b3e..0000000000 --- a/core/src/software/aws/toolkits/core/credentials/sso/Authorization.kt +++ /dev/null @@ -1,19 +0,0 @@ -// 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 deleted file mode 100644 index 5b59372ad1..0000000000 --- a/core/src/software/aws/toolkits/core/credentials/sso/ClientRegistration.kt +++ /dev/null @@ -1,18 +0,0 @@ -// 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 deleted file mode 100644 index 19af16e629..0000000000 --- a/core/src/software/aws/toolkits/core/credentials/sso/DiskCache.kt +++ /dev/null @@ -1,132 +0,0 @@ -// 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 deleted file mode 100644 index b14f90f576..0000000000 --- a/core/src/software/aws/toolkits/core/credentials/sso/SsoAccessTokenProvider.kt +++ /dev/null @@ -1,135 +0,0 @@ -// 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 deleted file mode 100644 index 4650e3d35b..0000000000 --- a/core/src/software/aws/toolkits/core/credentials/sso/SsoCache.kt +++ /dev/null @@ -1,14 +0,0 @@ -// 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 deleted file mode 100644 index ddbf393891..0000000000 --- a/core/src/software/aws/toolkits/core/credentials/sso/SsoCredentialProvider.kt +++ /dev/null @@ -1,68 +0,0 @@ -// 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/SsoProfileProperty.kt b/core/src/software/aws/toolkits/core/credentials/sso/SsoProfileProperty.kt deleted file mode 100644 index 655b8307e2..0000000000 --- a/core/src/software/aws/toolkits/core/credentials/sso/SsoProfileProperty.kt +++ /dev/null @@ -1,11 +0,0 @@ -// 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/lambda/LambdaArchitecture.kt b/core/src/software/aws/toolkits/core/lambda/LambdaArchitecture.kt new file mode 100644 index 0000000000..fb897aaa36 --- /dev/null +++ b/core/src/software/aws/toolkits/core/lambda/LambdaArchitecture.kt @@ -0,0 +1,31 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.lambda + +import software.amazon.awssdk.services.lambda.model.Architecture + +enum class LambdaArchitecture( + private val architecture: Architecture, + val minSam: String? = null, +) { + X86_64(Architecture.X86_64), + ARM64(Architecture.ARM64, minSam = "1.33.0"); + + override fun toString() = architecture.toString() + + fun toSdkArchitecture() = architecture.validOrNull + + companion object { + fun fromValue(value: String?): LambdaArchitecture? = if (value == null) { + null + } else { + values().find { it.toString() == value } + } + + fun fromValue(value: Architecture): LambdaArchitecture? = values().find { it.architecture == value } + + val DEFAULT = X86_64 + val ARM_COMPATIBLE = listOf(X86_64, ARM64) + } +} diff --git a/core/src/software/aws/toolkits/core/lambda/LambdaRuntime.kt b/core/src/software/aws/toolkits/core/lambda/LambdaRuntime.kt new file mode 100644 index 0000000000..3be8757e3b --- /dev/null +++ b/core/src/software/aws/toolkits/core/lambda/LambdaRuntime.kt @@ -0,0 +1,50 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.lambda + +import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.core.lambda.LambdaArchitecture.Companion.ARM_COMPATIBLE + +enum class LambdaRuntime( + private val runtime: Runtime?, + val minSamInit: String? = null, + val minSamDebugging: String? = null, + val architectures: List? = listOf(LambdaArchitecture.DEFAULT), + private val runtimeOverride: String? = null +) { + GO1_X( + Runtime.GO1_X, + // Although go sam debugging was supported before 1.18.1, it does not work on 1.13.0-1.16.0 + // and 1.17.0 broke the arguments + minSamDebugging = "1.18.1" + ), + NODEJS14_X(Runtime.NODEJS14_X, minSamDebugging = "1.17.0", minSamInit = "1.17.0", architectures = ARM_COMPATIBLE), + NODEJS16_X(Runtime.NODEJS16_X, minSamDebugging = "1.49.0", minSamInit = "1.49.0", architectures = ARM_COMPATIBLE), + NODEJS18_X(Runtime.NODEJS18_X, minSamDebugging = "1.65.0", minSamInit = "1.65.0", architectures = ARM_COMPATIBLE), + JAVA8(Runtime.JAVA8), + JAVA8_AL2(Runtime.JAVA8_AL2, minSamDebugging = "1.2.0", architectures = ARM_COMPATIBLE), + JAVA11(Runtime.JAVA11, architectures = ARM_COMPATIBLE), + JAVA17(Runtime.JAVA17, minSamDebugging = "1.81.0", minSamInit = "1.81.0", architectures = ARM_COMPATIBLE), + PYTHON3_7(Runtime.PYTHON3_7), + PYTHON3_8(Runtime.PYTHON3_8, architectures = ARM_COMPATIBLE), + PYTHON3_9(Runtime.PYTHON3_9, minSamDebugging = "1.28.0", minSamInit = "1.28.0", architectures = ARM_COMPATIBLE), + PYTHON3_10(Runtime.PYTHON3_10, minSamDebugging = "1.78.0", minSamInit = "1.78.0", architectures = ARM_COMPATIBLE), + PYTHON3_11(Runtime.PYTHON3_11, minSamDebugging = "1.87.0", minSamInit = "1.87.0", architectures = ARM_COMPATIBLE), + DOTNET5_0(null, minSamInit = "1.16.0", runtimeOverride = "dotnet5.0"), + DOTNET6_0(Runtime.DOTNET6, minSamDebugging = "1.40.1", minSamInit = "1.40.1", architectures = ARM_COMPATIBLE); + + override fun toString() = runtime?.toString() ?: runtimeOverride ?: throw IllegalStateException("LambdaRuntime has no runtime or override string") + + fun toSdkRuntime() = runtime.validOrNull + + companion object { + fun fromValue(value: String?): LambdaRuntime? = if (value == null) { + null + } else { + values().find { it.toString() == value } + } + + fun fromValue(value: Runtime): LambdaRuntime? = values().find { it.runtime == value } + } +} diff --git a/core/src/software/aws/toolkits/core/lambda/LambdaSampleEventProvider.kt b/core/src/software/aws/toolkits/core/lambda/LambdaSampleEventProvider.kt index 11824bfa04..a12e9eeefc 100644 --- a/core/src/software/aws/toolkits/core/lambda/LambdaSampleEventProvider.kt +++ b/core/src/software/aws/toolkits/core/lambda/LambdaSampleEventProvider.kt @@ -4,15 +4,22 @@ package software.aws.toolkits.core.lambda import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.MapperFeature import com.fasterxml.jackson.dataformat.xml.XmlMapper import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.slf4j.LoggerFactory +import software.aws.toolkits.core.utils.RemoteResolveParser import software.aws.toolkits.core.utils.RemoteResource import software.aws.toolkits.core.utils.RemoteResourceResolver import software.aws.toolkits.core.utils.inputStream import software.aws.toolkits.core.utils.readText +import software.aws.toolkits.core.utils.tryOrNull +import java.io.InputStream import java.time.Duration import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage @@ -28,9 +35,12 @@ class LambdaSampleEventProvider(private val resourceResolver: RemoteResourceReso if (value != null) { return CompletableFuture.completedFuture(value) } else { - return resourceResolver.resolve(LambdaSampleEventManifestResource).thenApply { - val resolved = mapper.readValue(it.inputStream()).requests.map { - LambdaSampleEvent(it.name) { resourceResolver.resolve(LambdaSampleEventResource(it.filename)).thenApply { it?.readText() } } + return resourceResolver.resolve(LambdaSampleEventManifestResource).thenApply { resource -> + val resolved = mapper.readValue(resource.inputStream()).requests.map { request -> + LambdaSampleEvent(request.name) { + resourceResolver.resolve(LambdaSampleEventResource(request.filename)) + .thenApply { it?.readText() } + } } manifest.set(resolved) resolved @@ -44,25 +54,59 @@ open class LambdaSampleEvent(val name: String, private val contentProvider: () - override fun toString() = name } -private data class LambdaSampleEventManifest( +data class LambdaSampleEventManifest( @JsonProperty(value = "request") @JacksonXmlElementWrapper(useWrapping = false) val requests: List ) -private data class LambdaSampleEventRequest( +data class LambdaSampleEventRequest( val filename: String, val name: String ) internal val LambdaSampleEventManifestResource = LambdaSampleEventResource("manifest.xml") +object LambdaManifestValidator : RemoteResolveParser { + private val LOG = LoggerFactory.getLogger(LambdaManifestValidator::class.java) + override fun canBeParsed(data: InputStream): Boolean { + val mapper = XmlMapper().registerKotlinModule().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + val result = LOG.tryOrNull("Failed to parse Requests") { + mapper.readValue(data) + } + + return result?.requests?.isNotEmpty() ?: false + } +} internal data class LambdaSampleEventResource(val filename: String) : RemoteResource { override val urls: List = listOf( - "http://vstoolkit.amazonwebservices.com/LambdaSampleFunctions/SampleRequests/$filename", - "https://s3.amazonaws.com/aws-vs-toolkit/LambdaSampleFunctions/SampleRequests/$filename" + "https://aws-vs-toolkit.s3.amazonaws.com/LambdaSampleFunctions/SampleRequests/$filename" ) override val name: String = "lambda-sample-event-$filename" override val ttl: Duration = Duration.ofDays(7) + override val remoteResolveParser: RemoteResolveParser? = resolveParserForGivenFile(filename.substringAfterLast('.', "")) +} +object LambdaSampleEventJsonValidator : RemoteResolveParser { + private val LOG = LoggerFactory.getLogger(LambdaSampleEventJsonValidator::class.java) + + private val mapper = jacksonObjectMapper() + .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) + .disable(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(JsonParser.Feature.ALLOW_COMMENTS) + + override fun canBeParsed(data: InputStream): Boolean { + val jsonMapper: Map = HashMap() + val result = LOG.tryOrNull("Failed to parse Lambda Sample Event request") { + this.mapper.readValue(data, jsonMapper.javaClass) + } + return result?.isNotEmpty() ?: false + } } +fun resolveParserForGivenFile(extension: String): RemoteResolveParser? = + when (extension) { + "xml" -> LambdaManifestValidator + "json" -> LambdaSampleEventJsonValidator + else -> null + } diff --git a/core/src/software/aws/toolkits/core/lambda/LambdaUtils.kt b/core/src/software/aws/toolkits/core/lambda/LambdaUtils.kt new file mode 100644 index 0000000000..4c7a0755bd --- /dev/null +++ b/core/src/software/aws/toolkits/core/lambda/LambdaUtils.kt @@ -0,0 +1,10 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.lambda + +import software.amazon.awssdk.services.lambda.model.Architecture +import software.amazon.awssdk.services.lambda.model.Runtime + +val Runtime?.validOrNull: Runtime? get() = this?.takeUnless { it == Runtime.UNKNOWN_TO_SDK_VERSION } +val Architecture?.validOrNull: Architecture? get() = this?.takeUnless { it == Architecture.UNKNOWN_TO_SDK_VERSION } diff --git a/core/src/software/aws/toolkits/core/region/AwsRegion.kt b/core/src/software/aws/toolkits/core/region/AwsRegion.kt index 1d51ab4ced..61df7aac45 100644 --- a/core/src/software/aws/toolkits/core/region/AwsRegion.kt +++ b/core/src/software/aws/toolkits/core/region/AwsRegion.kt @@ -35,3 +35,18 @@ data class AwsRegion(val id: String, val name: String, val partitionId: String) private fun String.trimPrefixAndRemoveBrackets(prefix: String) = this.removePrefix(prefix).replace("(", "").replace(")", "").trim() } } + +fun AwsRegion.mergeWithExistingEnvironmentVariables(existing: MutableMap, replace: Boolean = false) { + mergeWithExistingEnvironmentVariables(existing.keys, existing::putAll, replace) +} + +fun AwsRegion.mergeWithExistingEnvironmentVariables( + existingKeys: Collection, + putValues: (Map) -> Unit, + replace: Boolean = false +) { + val regionEnvs = this.toEnvironmentVariables() + if (replace || regionEnvs.keys.none { it in existingKeys }) { + putValues(regionEnvs) + } +} diff --git a/core/src/software/aws/toolkits/core/region/Partitions.kt b/core/src/software/aws/toolkits/core/region/Partitions.kt index bbb2b0e88d..3dc885dc10 100644 --- a/core/src/software/aws/toolkits/core/region/Partitions.kt +++ b/core/src/software/aws/toolkits/core/region/Partitions.kt @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.MapperFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.slf4j.LoggerFactory +import software.aws.toolkits.core.utils.RemoteResolveParser import software.aws.toolkits.core.utils.RemoteResource import software.aws.toolkits.core.utils.tryOrNull import software.aws.toolkits.resources.BundledResources @@ -44,13 +45,18 @@ object PartitionParser { .enable(JsonParser.Feature.ALLOW_COMMENTS) fun parse(inputStream: InputStream): Partitions? = LOG.tryOrNull("Failed to parse Partitions") { - mapper.readValue(inputStream, Partitions::class.java) + mapper.readValue(inputStream, Partitions::class.java) + } +} +object EndpointsJsonValidator : RemoteResolveParser { + override fun canBeParsed(data: InputStream): Boolean { + return PartitionParser.parse(data)?.partitions?.isNotEmpty() ?: return false } } - object ServiceEndpointResource : RemoteResource { 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 } + override val remoteResolveParser: EndpointsJsonValidator = EndpointsJsonValidator } diff --git a/core/src/software/aws/toolkits/core/region/ToolkitRegionProvider.kt b/core/src/software/aws/toolkits/core/region/ToolkitRegionProvider.kt index 2c9ad4c126..4b1bebf72e 100644 --- a/core/src/software/aws/toolkits/core/region/ToolkitRegionProvider.kt +++ b/core/src/software/aws/toolkits/core/region/ToolkitRegionProvider.kt @@ -9,7 +9,11 @@ import java.util.concurrent.ConcurrentHashMap * An SPI to provide regions supported by this toolkit */ abstract class ToolkitRegionProvider { - protected data class PartitionData(val description: String, val services: Map, val regions: Map) + protected data class PartitionData( + val description: String, + val services: Map, + val regions: Map + ) protected abstract fun partitionData(): Map @@ -20,6 +24,11 @@ abstract class ToolkitRegionProvider { */ fun allRegions(): Map = partitionData().flatMap { it.value.regions.asIterable() }.associate { it.key to it.value } + /** + * Returns a map of region ID([AwsRegion.id]) to [AwsRegion], filtering by if the service is supported + */ + fun allRegionsForService(serviceId: String): Map = allRegions().filter { isServiceSupported(it.value, serviceId) } + /** * Returns a map of region ID([AwsRegion.id]) to [AwsRegion] for the specified partition */ @@ -46,8 +55,7 @@ abstract class ToolkitRegionProvider { open fun isServiceGlobal(region: AwsRegion, serviceId: String): Boolean { val partition = partitionData()[region.partitionId] ?: throw IllegalStateException("Partition data is missing for ${region.partitionId}") - val service = partition.services[serviceId] ?: throw IllegalStateException("Unknown service $serviceId in ${region.partitionId}") - return service.isGlobal + return partition.services[serviceId]?.isGlobal == true } fun getGlobalRegionForService(region: AwsRegion, serviceId: String): AwsRegion { diff --git a/core/src/software/aws/toolkits/core/s3/BucketUtils.kt b/core/src/software/aws/toolkits/core/s3/BucketUtils.kt index f2ffedf1e0..ce4c27a03a 100644 --- a/core/src/software/aws/toolkits/core/s3/BucketUtils.kt +++ b/core/src/software/aws/toolkits/core/s3/BucketUtils.kt @@ -3,10 +3,10 @@ package software.aws.toolkits.core.s3 +import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.ListObjectVersionsRequest import software.amazon.awssdk.services.s3.model.ObjectIdentifier -import software.amazon.awssdk.services.s3.model.S3Exception fun S3Client.deleteBucketAndContents(bucket: String) { this.listObjectVersionsPaginator(ListObjectVersionsRequest.builder().bucket(bucket).build()).forEach { resp -> @@ -18,18 +18,14 @@ fun S3Client.deleteBucketAndContents(bucket: String) { if (versions.isEmpty()) { return@forEach } - this.deleteObjects { it.bucket(bucket).delete { it.objects(versions) } } + this.deleteObjects { it.bucket(bucket).delete { obj -> obj.objects(versions) } } } this.deleteBucket { it.bucket(bucket) } } -fun S3Client.regionForBucket(bucketName: String): String? = try { - this.headBucket { it.bucket(bucketName) } - .sdkHttpResponse() - .headers()[BUCKET_REGION_HEADER]?.first() ?: throw IllegalStateException("Failed to get bucket header") -} catch (e: S3Exception) { - e.awsErrorDetails().sdkHttpResponse().firstMatchingHeader(BUCKET_REGION_HEADER).orElseGet { null } ?: throw e -} - -private const val BUCKET_REGION_HEADER = "x-amz-bucket-region" +fun S3Client.regionForBucket(bucketName: String): String = this.getBucketLocation { + it.bucket(bucketName) +}.locationConstraintAsString() + .takeIf { it.isNotEmpty() } // getBucketLocation returns an explicit empty string location contraint for us-east-1 + ?: Region.US_EAST_1.id() diff --git a/core/src/software/aws/toolkits/core/telemetry/MetricEvent.kt b/core/src/software/aws/toolkits/core/telemetry/MetricEvent.kt index 2f85fa51f9..d92ce37601 100644 --- a/core/src/software/aws/toolkits/core/telemetry/MetricEvent.kt +++ b/core/src/software/aws/toolkits/core/telemetry/MetricEvent.kt @@ -31,6 +31,7 @@ interface MetricEvent { val name: String val value: Double val unit: MetricUnit + val passive: Boolean val metadata: Map interface Builder { @@ -40,6 +41,8 @@ interface MetricEvent { fun unit(unit: MetricUnit): Builder + fun passive(value: Boolean): Builder + fun metadata(key: String, value: String): Builder fun metadata(key: String, value: Boolean): Builder = metadata(key, value.toString()) @@ -61,7 +64,7 @@ interface MetricEvent { fun String.replaceIllegal(replacement: String = "") = this.replace(illegalCharsRegex, replacement) -class DefaultMetricEvent internal constructor( +data class DefaultMetricEvent internal constructor( override val createTime: Instant, override val awsAccount: String, override val awsRegion: String, @@ -72,7 +75,7 @@ class DefaultMetricEvent internal constructor( private var createTime: Instant = Instant.now() private var awsAccount: String = METADATA_NA private var awsRegion: String = METADATA_NA - private var data: MutableCollection = mutableListOf() + private val data: MutableCollection = mutableListOf() override fun createTime(createTime: Instant): MetricEvent.Builder { this.createTime = createTime @@ -105,19 +108,19 @@ class DefaultMetricEvent internal constructor( const val METADATA_NA = "n/a" const val METADATA_NOT_SET = "not-set" const val METADATA_INVALID = "invalid" - - private val LOG = getLogger() } - class DefaultDatum( + data class DefaultDatum( override val name: String, override val value: Double, override val unit: MetricUnit, + override val passive: Boolean, override val metadata: Map ) : MetricEvent.Datum { class BuilderImpl(private var name: String) : MetricEvent.Datum.Builder { private var value: Double = 0.0 private var unit: MetricUnit = MetricUnit.NONE + private var passive: Boolean = false private val metadata: MutableMap = HashMap() override fun name(name: String): MetricEvent.Datum.Builder { @@ -135,17 +138,17 @@ class DefaultMetricEvent internal constructor( return this } + override fun passive(value: Boolean): MetricEvent.Datum.Builder { + this.passive = value + return this + } + override fun metadata(key: String, value: String): MetricEvent.Datum.Builder { if (metadata.containsKey(key)) { LOG.warn { "Attempted to add multiple pieces of metadata with the same key" } return this } - if (metadata.size > MAX_METADATA_ENTRIES) { - LOG.warn { "Each metric datum may contain a maximum of $MAX_METADATA_ENTRIES metadata entries" } - return this - } - metadata[key] = value return this } @@ -154,6 +157,7 @@ class DefaultMetricEvent internal constructor( name.replaceIllegal(), this.value, this.unit, + this.passive, this.metadata ) } @@ -162,8 +166,6 @@ class DefaultMetricEvent internal constructor( private val LOG = getLogger() fun builder(name: String): MetricEvent.Datum.Builder = BuilderImpl(name) - - const val MAX_METADATA_ENTRIES: Int = 10 } } } diff --git a/core/src/software/aws/toolkits/core/telemetry/TelemetryPublisher.kt b/core/src/software/aws/toolkits/core/telemetry/TelemetryPublisher.kt index 5c2004a6ad..eafc77bb28 100644 --- a/core/src/software/aws/toolkits/core/telemetry/TelemetryPublisher.kt +++ b/core/src/software/aws/toolkits/core/telemetry/TelemetryPublisher.kt @@ -5,8 +5,8 @@ package software.aws.toolkits.core.telemetry import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment -interface TelemetryPublisher { +interface TelemetryPublisher : AutoCloseable { suspend fun publish(metricEvents: Collection) - suspend fun sendFeedback(sentiment: Sentiment, comment: String) + suspend fun sendFeedback(sentiment: Sentiment, comment: String, metadata: Map) } diff --git a/core/src/software/aws/toolkits/core/utils/CollectionUtils.kt b/core/src/software/aws/toolkits/core/utils/CollectionUtils.kt new file mode 100644 index 0000000000..1bb1bbb17f --- /dev/null +++ b/core/src/software/aws/toolkits/core/utils/CollectionUtils.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.core.utils + +/** + * Removes all items in this collection and replaces them with the items in the [other] collection + */ +fun MutableCollection.replace(other: Collection) { + clear() + addAll(other) +} + +/** + * Removes all items in this map and replaces them with the items in the [other] map + */ +fun MutableMap.replace(other: Map) { + clear() + putAll(other) +} diff --git a/core/src/software/aws/toolkits/core/utils/ConcurrencyUtils.kt b/core/src/software/aws/toolkits/core/utils/ConcurrencyUtils.kt deleted file mode 100644 index e0544cbbdb..0000000000 --- a/core/src/software/aws/toolkits/core/utils/ConcurrencyUtils.kt +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.core.utils - -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CompletionStage -import java.util.concurrent.Executors -import java.util.concurrent.RunnableFuture -import java.util.function.Supplier - -fun failedFuture(t: Throwable): CompletableFuture = CompletableFuture().also { - it.completeExceptionally(t) -} - -private val pool = Executors.newCachedThreadPool() -fun RunnableFuture.toCompletableFuture(): CompletableFuture { - run() - - return CompletableFuture.supplyAsync(Supplier { get() }, pool) -} - -fun Iterable>.allOf(): CompletionStage = CompletableFuture.allOf(*this.map { it.toCompletableFuture() }.toTypedArray()) diff --git a/core/src/software/aws/toolkits/core/utils/LogUtils.kt b/core/src/software/aws/toolkits/core/utils/LogUtils.kt index ace48dd926..370150496c 100644 --- a/core/src/software/aws/toolkits/core/utils/LogUtils.kt +++ b/core/src/software/aws/toolkits/core/utils/LogUtils.kt @@ -1,6 +1,7 @@ // Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +@file:Suppress("LazyLog") package software.aws.toolkits.core.utils import org.slf4j.Logger diff --git a/core/src/software/aws/toolkits/core/utils/PathUtils.kt b/core/src/software/aws/toolkits/core/utils/PathUtils.kt index 06ec32273f..9e209a1a32 100644 --- a/core/src/software/aws/toolkits/core/utils/PathUtils.kt +++ b/core/src/software/aws/toolkits/core/utils/PathUtils.kt @@ -3,15 +3,26 @@ package software.aws.toolkits.core.utils +import org.slf4j.Logger import java.io.InputStream import java.io.OutputStream import java.nio.charset.Charset +import java.nio.file.AccessMode 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.AclEntry +import java.nio.file.attribute.AclFileAttributeView import java.nio.file.attribute.FileTime import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions +import java.nio.file.attribute.UserPrincipal +import kotlin.io.path.getPosixFilePermissions +import kotlin.io.path.isRegularFile + +val POSIX_OWNER_ONLY_FILE = setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE) +val POSIX_OWNER_ONLY_DIR = setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE) fun Path.inputStream(): InputStream = Files.newInputStream(this) fun Path.inputStreamIfExists(): InputStream? = try { @@ -20,26 +31,184 @@ fun Path.inputStreamIfExists(): InputStream? = try { null } -fun Path.touch() { - this.createParentDirectories() +fun Path.touch(restrictToOwner: Boolean = false) { try { - Files.createFile(this) - } catch (_: FileAlreadyExistsException) { } + if (!restrictToOwner || !hasPosixFilePermissions()) { + Files.createFile(this) + } else { + Files.createFile(this, PosixFilePermissions.asFileAttribute(POSIX_OWNER_ONLY_FILE)) + } + } catch (_: FileAlreadyExistsException) {} } fun Path.outputStream(): OutputStream { this.createParentDirectories() return Files.newOutputStream(this) } -fun Path.createParentDirectories() = Files.createDirectories(this.parent) + +fun Path.createParentDirectories(restrictToOwner: Boolean = false) = if (!restrictToOwner || !hasPosixFilePermissions()) { + Files.createDirectories(this.parent) +} else { + Files.createDirectories(this.parent, PosixFilePermissions.asFileAttribute(POSIX_OWNER_ONLY_DIR)) +} + 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.appendText(text: String, charset: Charset = Charsets.UTF_8) = toFile().appendText(text, charset) + +// Comes from PosixFileAttributeView#name() +fun Path.hasPosixFilePermissions() = "posix" in this.fileSystem.supportedFileAttributeViews() fun Path.filePermissions(permissions: Set) { - // Comes from PosixFileAttributeView#name() - if ("posix" in this.fileSystem.supportedFileAttributeViews()) { + if (hasPosixFilePermissions()) { Files.setPosixFilePermissions(this, permissions) } } + +fun Path.tryDirOp(log: Logger, block: Path.() -> Unit) { + try { + log.debug { "dir op on $this" } + block(this) + } catch (e: Exception) { + if (e !is java.nio.file.AccessDeniedException && e !is kotlin.io.AccessDeniedException) { + throw e + } + + if (!hasPosixFilePermissions()) { + throw tryAugmentExceptionMessage(e, this) + } + + log.info(e) { "Attempting to handle ADE for directory operation" } + try { + var parent = if (this.isRegularFile()) { parent } else { this } + + while (parent != null) { + if (!parent.exists()) { + log.info { "${parent.toAbsolutePath()}: does not exist yet" } + } else { + if (tryOrNull { parent.fileSystem.provider().checkAccess(parent, AccessMode.READ, AccessMode.WRITE, AccessMode.EXECUTE) } != null) { + log.debug { "$parent has rwx, exiting" } + // can assume parent permissions are correct + break + } + + log.debug { "fixing perms for $parent" } + parent.tryFixPerms(log, POSIX_OWNER_ONLY_DIR) + } + + parent = parent.parent + } + } catch (e2: Exception) { + log.warn(e2) { "Encountered error while handling ADE for ${e.message}" } + + throw tryAugmentExceptionMessage(e, this) + } + + log.info { "Done attempting to handle ADE for directory operation" } + block(this) + } +} + +fun Path.tryFileOp(log: Logger, block: Path.() -> T) = + try { + log.debug { "file op on $this" } + block(this) + } catch (e: Exception) { + if (e !is java.nio.file.AccessDeniedException && e !is kotlin.io.AccessDeniedException) { + throw e + } + + if (!hasPosixFilePermissions()) { + throw tryAugmentExceptionMessage(e, this) + } + + log.info(e) { "Attempting to handle ADE for file operation" } + try { + log.debug { "fixing perms for $this" } + tryFixPerms(log, POSIX_OWNER_ONLY_FILE) + } catch (e2: Exception) { + log.warn(e2) { "Encountered error while handling ADE for ${e.message}" } + + throw tryAugmentExceptionMessage(e, this) + } + + log.info { "Done attempting to handle ADE for file operation" } + block(this) + } + +private fun Path.tryFixPerms(log: Logger, desiredPermissions: Set) { + // TODO: consider handling linux ACLs + // only try ops if we own the file + // (ab)use invariant that chmod only works if you are root or the file owner + val perms = tryOrLogShortException(log) { Files.getPosixFilePermissions(this) } + val ownership = tryOrLogShortException(log) { Files.getOwner(this) } + + log.info { "Permissions for ${toAbsolutePath()}: $ownership, $perms" } + if (perms != null && ownership != null) { + if (ownership.name != "root" && tryOrNull { filePermissions(perms) } != null) { + val permissions = perms + desiredPermissions + log.info { "Setting perms for ${toAbsolutePath()}: $permissions" } + filePermissions(permissions) + } + } +} + +private fun tryAugmentExceptionMessage(e: Exception, path: Path): Exception { + if (e !is java.nio.file.AccessDeniedException && e !is kotlin.io.AccessDeniedException) { + return e + } + + var potentialProblem = if (path.exists()) { path } else { path.parent } + var acls: List? = null + var ownership: UserPrincipal? = null + while (potentialProblem != null) { + acls = tryOrNull { Files.getFileAttributeView(potentialProblem, AclFileAttributeView::class.java).acl } + ownership = tryOrNull { Files.getOwner(potentialProblem) } + + if (acls != null || ownership != null) { + break + } + + potentialProblem = potentialProblem.parent + } + + val message = buildString { + // $path is automatically added to the front of the exception message + appendLine("Exception trying to perform operation") + + if (potentialProblem != null) { + append("Potential issue is with $potentialProblem") + + if (ownership != null) { + append(", which has owner: $ownership") + } + + if (acls != null) { + append(", and ACL entries for: ${acls.map { it.principal() }}") + } + + val posixPermissions = tryOrNull { PosixFilePermissions.toString(potentialProblem.getPosixFilePermissions()) } + if (posixPermissions != null) { + append(", and POSIX permissions: $posixPermissions") + } + } + } + + return when (e) { + is kotlin.io.AccessDeniedException -> kotlin.io.AccessDeniedException(e.file, e.other, message) + is java.nio.file.AccessDeniedException -> java.nio.file.AccessDeniedException(e.file, e.otherFile, message) + // should never happen + else -> e + }.also { + it.stackTrace = e.stackTrace + } +} + +private fun tryOrLogShortException(log: Logger, block: () -> T) = try { + block() +} catch (e: Exception) { + log.warn { "${e::class.simpleName}: ${e.message}" } + null +} diff --git a/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt b/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt index dec31059fc..fc0d590ea9 100644 --- a/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt +++ b/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt @@ -3,6 +3,7 @@ package software.aws.toolkits.core.utils +import java.io.FileInputStream import java.io.InputStream import java.nio.file.Files import java.nio.file.Path @@ -16,6 +17,9 @@ import java.util.concurrent.CompletionStage interface RemoteResourceResolver { fun resolve(resource: RemoteResource): CompletionStage } +interface RemoteResolveParser { + fun canBeParsed(data: InputStream): Boolean +} class DefaultRemoteResourceResolver( private val urlFetcher: UrlFetcher, @@ -61,7 +65,12 @@ class DefaultRemoteResourceResolver( when { downloaded != null -> { LOG.debug { "Downloaded new file $downloaded, replacing old file $expectedLocation" } - Files.move(downloaded, expectedLocation, StandardCopyOption.REPLACE_EXISTING) + val isParsingSuccess = FileInputStream(downloaded.toFile()).use { + resource.remoteResolveParser?.canBeParsed(it) ?: true + } + if (isParsingSuccess) { + Files.move(downloaded, expectedLocation, StandardCopyOption.REPLACE_EXISTING) + } } current != null -> LOG.debug { "No new file available - re-using current file $current" } initialValue != null -> { @@ -105,4 +114,5 @@ interface RemoteResource { val name: String val ttl: Duration? get() = null val initialValue: (() -> InputStream)? get() = null + val remoteResolveParser: RemoteResolveParser? get() = null } diff --git a/core/src/software/aws/toolkits/core/utils/SensitiveField.kt b/core/src/software/aws/toolkits/core/utils/SensitiveField.kt new file mode 100644 index 0000000000..f1d2d37c2b --- /dev/null +++ b/core/src/software/aws/toolkits/core/utils/SensitiveField.kt @@ -0,0 +1,43 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.utils + +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties + +@Target(AnnotationTarget.PROPERTY) +annotation class SensitiveField + +fun redactedString(o: Any): String { + val clazz = o::class + if (!clazz.isData) { + error("Only supports redacting data classes") + } + + return buildString { + append(clazz.simpleName) + append("(") + + val properties = o::class.memberProperties + properties.forEachIndexed { i, prop -> + append(prop.name) + append("=") + if (prop.hasAnnotation()) { + if (prop.getter.call(o) == null) { + append("null") + } else { + append("") + } + } else { + append(prop.getter.call(o)) + } + + if (i != properties.size - 1) { + append(", ") + } + } + + append(")") + } +} diff --git a/core/src/software/aws/toolkits/core/utils/StringUtils.kt b/core/src/software/aws/toolkits/core/utils/StringUtils.kt index 38426ff068..a41a2eee2d 100644 --- a/core/src/software/aws/toolkits/core/utils/StringUtils.kt +++ b/core/src/software/aws/toolkits/core/utils/StringUtils.kt @@ -10,3 +10,5 @@ fun String.splitNoBlank(vararg delimiters: Char, ignoreCase: Boolean = false, li split(*delimiters, ignoreCase = ignoreCase, limit = limit).filter { it.isNotBlank() } fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } + +fun String.htmlWrap() = """${this.replace("\n", "
")}""" diff --git a/core/src/software/aws/toolkits/core/utils/Waiter.kt b/core/src/software/aws/toolkits/core/utils/Waiter.kt index 1fdfc72d17..ec03fc7be5 100644 --- a/core/src/software/aws/toolkits/core/utils/Waiter.kt +++ b/core/src/software/aws/toolkits/core/utils/Waiter.kt @@ -4,7 +4,6 @@ package software.aws.toolkits.core.utils import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking import software.amazon.awssdk.awscore.exception.AwsServiceException import java.time.Duration import java.time.Instant @@ -15,29 +14,6 @@ import kotlin.reflect.KClass object Waiters { private val LOG = getLogger() - /** - * Creates a waiter that attempts executing the provided [call] until the specified conditions are met. - * - * @param T The response type of the [call] - * @param succeedOn The condition on the response under which the thing we are trying is complete. Defaults to if the call succeeds, we stop wating - * @param failOn The condition on the response under which the thing we are trying has already failed and further attempts are pointless. Defaults to always try again - * @param exceptionsToStopOn The exception types that should be considered a success and stop waiting. Default to never stop on any exception - * @param exceptionsToIgnore The exception types that should be ignored if the thing we are trying throws them. Default to not ignoring any exceptions and let it bubble out - * @param maxDuration The max amount of time we want to wait for - * @param call The function we want to keep retrying - */ - fun waitUntilBlocking( - succeedOn: (T) -> Boolean = { true }, - failOn: (T) -> Boolean = { false }, - exceptionsToStopOn: Set> = emptySet(), - exceptionsToIgnore: Set> = emptySet(), - maxDuration: Duration = Duration.ofMinutes(1), - // The status pulling method to get the latest resource - call: () -> T - ): T? = runBlocking { - waitUntil(succeedOn, failOn, exceptionsToStopOn, exceptionsToIgnore, maxDuration, call) - } - /** * Creates a waiter that attempts executing the provided [call] until the specified conditions are met. * diff --git a/core/src/software/aws/toolkits/core/utils/ZipUtils.kt b/core/src/software/aws/toolkits/core/utils/ZipUtils.kt index ff3d3c7cb5..01d184c4ed 100644 --- a/core/src/software/aws/toolkits/core/utils/ZipUtils.kt +++ b/core/src/software/aws/toolkits/core/utils/ZipUtils.kt @@ -7,7 +7,6 @@ import java.io.InputStream import java.nio.file.Files import java.nio.file.Path import java.util.zip.ZipEntry -import java.util.zip.ZipFile import java.util.zip.ZipOutputStream /** @@ -41,10 +40,3 @@ fun createTemporaryZipFile(block: (ZipOutputStream) -> Unit): Path { ZipOutputStream(Files.newOutputStream(file)).use(block) return file } - -/** - * Returns a list of the file names in the Zip archive - */ -fun zipEntries(zipFile: Path): List = ZipFile(zipFile.toFile()).use { zip -> - zip.entries().asSequence().filterNot { it.isDirectory }.mapNotNull { it.name }.toList() -} diff --git a/core/tst-resources/jsonSampleFailure.json b/core/tst-resources/jsonSampleFailure.json new file mode 100644 index 0000000000..2bbf3fd3c1 --- /dev/null +++ b/core/tst-resources/jsonSampleFailure.json @@ -0,0 +1,20 @@ +{ + "partitions" : [ { + "defaults" : { + "hostname" : "{service}.{region}.{dnsSuffix}", + "protocols" : [ "https" ], + "signatureVersions" : [ "v4" ], + "variants" : [ { + "dnsSuffix" : "amazonaws.com", + "hostname" : "{service}-fips.{region}.{dnsSuffix}", + "tags" : [ "fips" ] + }, { + "dnsSuffix" : "api.aws", + "hostname" : "{service}-fips.{region}.{dnsSuffix}", + "tags" : [ "dualstack", "fips" ] + }, { + "dnsSuffix" : "api.aws", + "hostname" : "{service}.{region}.{dnsSuffix}", + "tags" : [ "dualstack" ] + } ] + }, diff --git a/core/tst-resources/jsonSampleSuccess.json b/core/tst-resources/jsonSampleSuccess.json new file mode 100644 index 0000000000..2287c5a778 --- /dev/null +++ b/core/tst-resources/jsonSampleSuccess.json @@ -0,0 +1,63 @@ +{ + "partitions" : [ { + "defaults" : { + "hostname" : "{service}.{region}.{dnsSuffix}", + "protocols" : [ "https" ], + "signatureVersions" : [ "v4" ], + "variants" : [ { + "dnsSuffix" : "amazonaws.com", + "hostname" : "{service}-fips.{region}.{dnsSuffix}", + "tags" : [ "fips" ] + }, { + "dnsSuffix" : "api.aws", + "hostname" : "{service}-fips.{region}.{dnsSuffix}", + "tags" : [ "dualstack", "fips" ] + }, { + "dnsSuffix" : "api.aws", + "hostname" : "{service}.{region}.{dnsSuffix}", + "tags" : [ "dualstack" ] + } ] + }, + "dnsSuffix" : "amazonaws.com", + "partition" : "aws", + "partitionName" : "AWS Standard", + "regionRegex" : "^(us|eu|ap|sa|ca|me|af)\\-\\w+\\-\\d+$", + "regions" : { + "af-south-1" : { + "description" : "Africa (Cape Town)" + }, + "ap-east-1" : { + "description" : "Asia Pacific (Hong Kong)" + } + }, + "services" : { + "a4b" : { + "endpoints" : { + "us-east-1" : { } + } + }, + "access-analyzer" : { + "endpoints" : { + "af-south-1" : { }, + "ap-east-1" : { }, + "ca-central-1" : { + "variants" : [ { + "hostname" : "access-analyzer-fips.ca-central-1.amazonaws.com", + "tags" : [ "fips" ] + } ] + }, + "eu-west-2" : { }, + "eu-west-3" : { }, + "fips-ca-central-1" : { + "credentialScope" : { + "region" : "ca-central-1" + }, + "deprecated" : true, + "hostname" : "access-analyzer-fips.ca-central-1.amazonaws.com" + } + } + } + } + } ], + "version" : 3 +} diff --git a/core/tst-resources/mockito-extensions/org.mockito.plugins.MockMaker b/core/tst-resources/mockito-extensions/org.mockito.plugins.MockMaker deleted file mode 100644 index 1f0955d450..0000000000 --- a/core/tst-resources/mockito-extensions/org.mockito.plugins.MockMaker +++ /dev/null @@ -1 +0,0 @@ -mock-maker-inline diff --git a/core/tst-resources/sampleLambdaEvent.json b/core/tst-resources/sampleLambdaEvent.json new file mode 100644 index 0000000000..13121b6d8c --- /dev/null +++ b/core/tst-resources/sampleLambdaEvent.json @@ -0,0 +1,4 @@ +{ + "key1": "value1", + "key2": "value2" +} diff --git a/core/tst-resources/xmlSampleFailure.xml b/core/tst-resources/xmlSampleFailure.xml new file mode 100644 index 0000000000..285ca3e577 --- /dev/null +++ b/core/tst-resources/xmlSampleFailure.xml @@ -0,0 +1,13 @@ + + + + + + Alexa End Session + alexa-end-session.json + + + Alexa Intent - Answer + alexa-intent-answer.json + + diff --git a/core/tst-resources/xmlSampleSuccess.xml b/core/tst-resources/xmlSampleSuccess.xml new file mode 100644 index 0000000000..7d3c143d94 --- /dev/null +++ b/core/tst-resources/xmlSampleSuccess.xml @@ -0,0 +1,13 @@ + + + + + + Alexa End Session + alexa-end-session.json + + + Alexa Intent - Answer + alexa-intent-answer.json + + diff --git a/core/tst/software/aws/toolkits/core/credentials/AwsCredentialsExtensionsTest.kt b/core/tst/software/aws/toolkits/core/credentials/AwsCredentialsExtensionsTest.kt new file mode 100644 index 0000000000..658795da8a --- /dev/null +++ b/core/tst/software/aws/toolkits/core/credentials/AwsCredentialsExtensionsTest.kt @@ -0,0 +1,64 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.credentials + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.aws.toolkits.core.utils.test.aString + +class AwsCredentialsExtensionsTest { + + @Test + fun `can convert basic credentials to environment variables`() { + val credentials = AwsBasicCredentials.create(aString(), aString()) + assertThat(credentials.toEnvironmentVariables()).hasSize(2) + .containsEntry("AWS_ACCESS_KEY_ID", credentials.accessKeyId()) + .containsEntry("AWS_SECRET_ACCESS_KEY", credentials.secretAccessKey()) + } + + @Test + fun `can convert session credentials to environment variables`() { + val credentials = AwsSessionCredentials.create(aString(), aString(), aString()) + assertThat(credentials.toEnvironmentVariables()).hasSize(3) + .containsEntry("AWS_ACCESS_KEY_ID", credentials.accessKeyId()) + .containsEntry("AWS_SECRET_ACCESS_KEY", credentials.secretAccessKey()) + .containsEntry("AWS_SESSION_TOKEN", credentials.sessionToken()) + } + + @Test + fun `can add environment variables to an existing env map`() { + val credentials = AwsSessionCredentials.create(aString(), aString(), aString()) + val env = mutableMapOf() + + credentials.mergeWithExistingEnvironmentVariables(env) + + assertThat(env).containsOnlyKeys("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN") + } + + @Test + fun `existing credentials are not replaced by default`() { + val credentials = AwsBasicCredentials.create(aString(), aString()) + val existingToken = aString() + val env = mutableMapOf("AWS_SESSION_TOKEN" to existingToken) + + credentials.mergeWithExistingEnvironmentVariables(env) + + assertThat(env).hasSize(1).containsEntry("AWS_SESSION_TOKEN", existingToken) + } + + @Test + fun `existing credentials can be replaced`() { + val credentials = AwsBasicCredentials.create(aString(), aString()) + val existingToken = aString() + val env = mutableMapOf("AWS_SESSION_TOKEN" to existingToken) + + credentials.mergeWithExistingEnvironmentVariables(env, replace = true) + + assertThat(env).hasSize(2) + .containsEntry("AWS_ACCESS_KEY_ID", credentials.accessKeyId()) + .containsEntry("AWS_SECRET_ACCESS_KEY", credentials.secretAccessKey()) + } +} diff --git a/core/tst/software/aws/toolkits/core/credentials/Mocks.kt b/core/tst/software/aws/toolkits/core/credentials/Mocks.kt index a5860bc8b5..2556fe9898 100644 --- a/core/tst/software/aws/toolkits/core/credentials/Mocks.kt +++ b/core/tst/software/aws/toolkits/core/credentials/Mocks.kt @@ -3,7 +3,7 @@ package software.aws.toolkits.core.credentials -import com.nhaarman.mockitokotlin2.mock +import org.mockito.kotlin.mock import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider import software.aws.toolkits.core.utils.test.aString @@ -17,7 +17,7 @@ fun aCredentialsIdentifier( displayName: String = aString(), factoryId: String = aString(), defaultRegionId: String? = null -) = object : CredentialIdentifierBase() { +) = object : CredentialIdentifierBase(CredentialType.StaticProfile) { override val id: String = id override val displayName: String = displayName override val factoryId: String = factoryId diff --git a/core/tst/software/aws/toolkits/core/credentials/sso/DiskCacheTest.kt b/core/tst/software/aws/toolkits/core/credentials/sso/DiskCacheTest.kt deleted file mode 100644 index 791d64cc1c..0000000000 --- a/core/tst/software/aws/toolkits/core/credentials/sso/DiskCacheTest.kt +++ /dev/null @@ -1,312 +0,0 @@ -// 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/lambda/LambdaSampleEventProviderTest.kt b/core/tst/software/aws/toolkits/core/lambda/LambdaSampleEventProviderTest.kt index ec83119a0e..04ac131d1b 100644 --- a/core/tst/software/aws/toolkits/core/lambda/LambdaSampleEventProviderTest.kt +++ b/core/tst/software/aws/toolkits/core/lambda/LambdaSampleEventProviderTest.kt @@ -3,13 +3,13 @@ package software.aws.toolkits.core.lambda -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify import org.assertj.core.api.Assertions.assertThat import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import software.aws.toolkits.core.utils.RemoteResourceResolver import java.util.concurrent.CompletableFuture @@ -37,20 +37,22 @@ class LambdaSampleEventProviderTest { second.json - """.trimIndent() + """.trimIndent() ) - val firstContent = """ + val firstContent = + """ { "hello": "world" } - """.trimIndent() + """.trimIndent() firstFile.writeText(firstContent) - val secondContent = """ + val secondContent = + """ ["hello"] - """.trimIndent() + """.trimIndent() secondFile.writeText(secondContent) @@ -86,7 +88,7 @@ class LambdaSampleEventProviderTest { first.json - """.trimIndent() + """.trimIndent() ) val resourceResolver = mock { diff --git a/core/tst/software/aws/toolkits/core/parser/EndpointsJsonValidatorTest.kt b/core/tst/software/aws/toolkits/core/parser/EndpointsJsonValidatorTest.kt new file mode 100644 index 0000000000..bcccc65d6e --- /dev/null +++ b/core/tst/software/aws/toolkits/core/parser/EndpointsJsonValidatorTest.kt @@ -0,0 +1,24 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.parser + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import software.aws.toolkits.core.region.EndpointsJsonValidator + +class EndpointsJsonValidatorTest { + @Test + fun `endpoints json file parsing succeeds`() { + EndpointsJsonValidatorTest::class.java.getResourceAsStream("/jsonSampleSuccess.json").use { + assertThat(EndpointsJsonValidator.canBeParsed(it)).isTrue + } + } + + @Test + fun `endpoints json file parsing fails`() { + EndpointsJsonValidatorTest::class.java.getResourceAsStream("/jsonSampleFailure.json").use { + assertThat(EndpointsJsonValidator.canBeParsed(it)).isFalse + } + } +} diff --git a/core/tst/software/aws/toolkits/core/parser/LambdaManifestValidatorTest.kt b/core/tst/software/aws/toolkits/core/parser/LambdaManifestValidatorTest.kt new file mode 100644 index 0000000000..da0d4ca4f2 --- /dev/null +++ b/core/tst/software/aws/toolkits/core/parser/LambdaManifestValidatorTest.kt @@ -0,0 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.parser + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import software.aws.toolkits.core.lambda.LambdaManifestValidator + +class LambdaManifestValidatorTest { + + @Test + fun `manifest xml file parsing succeeds`() { + LambdaManifestValidatorTest::class.java.getResourceAsStream("/xmlSampleSuccess.xml").use { + assertThat(LambdaManifestValidator.canBeParsed(it)).isTrue + } + } + + @Test + fun `manifest xml file parsing fails`() { + LambdaManifestValidatorTest::class.java.getResourceAsStream("/xmlSampleFailure.xml").use { + assertThat(LambdaManifestValidator.canBeParsed(it)).isFalse + } + } +} diff --git a/core/tst/software/aws/toolkits/core/parser/LambdaSampleEventJsonValidatorTest.kt b/core/tst/software/aws/toolkits/core/parser/LambdaSampleEventJsonValidatorTest.kt new file mode 100644 index 0000000000..63b80d58ef --- /dev/null +++ b/core/tst/software/aws/toolkits/core/parser/LambdaSampleEventJsonValidatorTest.kt @@ -0,0 +1,23 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.parser + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import software.aws.toolkits.core.lambda.LambdaSampleEventJsonValidator +class LambdaSampleEventJsonValidatorTest { + @Test + fun `lambda sample event json file parsing succeeds`() { + LambdaSampleEventJsonValidatorTest::class.java.getResourceAsStream("/sampleLambdaEvent.json").use { + assertThat(LambdaSampleEventJsonValidator.canBeParsed(it)).isTrue + } + } + + @Test + fun `lambda sample event json file parsing fails`() { + LambdaSampleEventJsonValidatorTest::class.java.getResourceAsStream("/jsonSampleFailure.json").use { + assertThat(LambdaSampleEventJsonValidator.canBeParsed(it)).isFalse + } + } +} diff --git a/core/tst/software/aws/toolkits/core/region/AwsRegionTest.kt b/core/tst/software/aws/toolkits/core/region/AwsRegionTest.kt index cfce0d283d..e1dc7704d5 100644 --- a/core/tst/software/aws/toolkits/core/region/AwsRegionTest.kt +++ b/core/tst/software/aws/toolkits/core/region/AwsRegionTest.kt @@ -5,40 +5,99 @@ package software.aws.toolkits.core.region import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import org.junit.experimental.runners.Enclosed 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) { - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{2}") - fun data(): Collection> = listOf( - arrayOf(AwsRegion("ap-northeast-1", "Asia Pacific (Tokyo)", "aws"), "Asia Pacific", "Tokyo (ap-northeast-1)"), - arrayOf(AwsRegion("ca-central-1", "Canada (Central)", "aws"), "North America", "Canada Central (ca-central-1)"), - arrayOf(AwsRegion("eu-central-1", "EU (Frankfurt)", "aws"), "Europe", "Frankfurt (eu-central-1)"), - arrayOf(AwsRegion("eu-south-1", "Europe (Milan)", "aws"), "Europe", "Milan (eu-south-1)"), - arrayOf(AwsRegion("sa-east-1", "South America (Sao Paulo)", "aws"), "South America", "Sao Paulo (sa-east-1)"), - arrayOf(AwsRegion("us-east-1", "US East (N. Virginia)", "aws"), "North America", "N. Virginia (us-east-1)"), - arrayOf(AwsRegion("us-west-1", "US West (N. California)", "aws"), "North America", "N. California (us-west-1)"), - arrayOf(AwsRegion("cn-north-1", "China (Beijing)", "aws"), "China", "Beijing (cn-north-1)"), - arrayOf(AwsRegion("us-gov-west-1", "AWS GovCloud (US)", "aws"), "North America", "AWS GovCloud US (us-gov-west-1)"), - arrayOf(AwsRegion("me-south-1", "Middle East (Bahrain)", "aws"), "Middle East", "Bahrain (me-south-1)"), - arrayOf(AwsRegion("af-south-1", "Africa (Cape Town)", "aws"), "Africa", "Cape Town (af-south-1)") - ) - } +@RunWith(Enclosed::class) +class AwsRegionTest { + + @RunWith(Parameterized::class) + class NameAndCategorizationTest(private val region: AwsRegion, private val expectedCategory: String, private val expectedDisplayName: String) { + @Test + fun `display name should be correct`() { + assertThat(region.displayName).isEqualTo(expectedDisplayName) + } - @Test - fun displayNameShouldMatch() { - assertThat(region.displayName).isEqualTo(expectedDisplayName) + @Test + fun `category should match`() { + assertThat(region.category).isEqualTo(expectedCategory) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{2}") + fun data(): Collection> = listOf( + arrayOf(AwsRegion("ap-northeast-1", "Asia Pacific (Tokyo)", "aws"), "Asia Pacific", "Tokyo (ap-northeast-1)"), + arrayOf(AwsRegion("ca-central-1", "Canada (Central)", "aws"), "North America", "Canada Central (ca-central-1)"), + arrayOf(AwsRegion("eu-central-1", "EU (Frankfurt)", "aws"), "Europe", "Frankfurt (eu-central-1)"), + arrayOf(AwsRegion("eu-south-1", "Europe (Milan)", "aws"), "Europe", "Milan (eu-south-1)"), + arrayOf(AwsRegion("sa-east-1", "South America (Sao Paulo)", "aws"), "South America", "Sao Paulo (sa-east-1)"), + arrayOf(AwsRegion("us-east-1", "US East (N. Virginia)", "aws"), "North America", "N. Virginia (us-east-1)"), + arrayOf(AwsRegion("us-west-1", "US West (N. California)", "aws"), "North America", "N. California (us-west-1)"), + arrayOf(AwsRegion("cn-north-1", "China (Beijing)", "aws"), "China", "Beijing (cn-north-1)"), + arrayOf(AwsRegion("us-gov-west-1", "AWS GovCloud (US)", "aws"), "North America", "AWS GovCloud US (us-gov-west-1)"), + arrayOf(AwsRegion("me-south-1", "Middle East (Bahrain)", "aws"), "Middle East", "Bahrain (me-south-1)"), + arrayOf(AwsRegion("af-south-1", "Africa (Cape Town)", "aws"), "Africa", "Cape Town (af-south-1)") + ) + } } - @Test - fun categoryShouldMatch() { - assertThat(region.category).isEqualTo(expectedCategory) + class ExtensionFunctionTests { + + private val region = anAwsRegion() + + @Test + fun `mergeWithExistingEnvironmentVariables puts basic settings in the map`() { + val env = mutableMapOf() + + region.mergeWithExistingEnvironmentVariables(env) + + assertThat(env).hasSize(2) + .containsEntry("AWS_REGION", region.id) + .containsEntry("AWS_DEFAULT_REGION", region.id) + } + + @Test + fun `mergeWithExistingEnvironmentVariables does not replace existing AWS_REGION`() { + val existing = aString() + val env = mutableMapOf( + "AWS_REGION" to existing + ) + + region.mergeWithExistingEnvironmentVariables(env) + + assertThat(env).hasSize(1).containsEntry("AWS_REGION", existing) + } + + @Test + fun `mergeWithExistingEnvironmentVariables does not replace existing AWS_DEFAULT_REGION`() { + val existing = aString() + val env = mutableMapOf( + "AWS_DEFAULT_REGION" to existing + ) + + region.mergeWithExistingEnvironmentVariables(env) + + assertThat(env).hasSize(1).containsEntry("AWS_DEFAULT_REGION", existing) + } + + @Test + fun `mergeWithExistingEnvironmentVariables can force replace existing`() { + val existing = aString() + val env = mutableMapOf( + "AWS_REGION" to existing, + "AWS_DEFAULT_REGION" to existing + ) + + region.mergeWithExistingEnvironmentVariables(env, replace = true) + + assertThat(env).hasSize(2) + .containsEntry("AWS_REGION", region.id) + .containsEntry("AWS_DEFAULT_REGION", region.id) + } } } @@ -46,7 +105,7 @@ fun anAwsRegion(id: String = aRegionId(), name: String = aString(), 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) + val compass = arrayOf("north", "south", "east", "west", "central").random() + val count = Random.nextInt(1, 100) return "$prefix-$compass-$count" } diff --git a/core/tst/software/aws/toolkits/core/rules/EcrTemporaryRepositoryRule.kt b/core/tst/software/aws/toolkits/core/rules/EcrTemporaryRepositoryRule.kt new file mode 100644 index 0000000000..4db7f61370 --- /dev/null +++ b/core/tst/software/aws/toolkits/core/rules/EcrTemporaryRepositoryRule.kt @@ -0,0 +1,48 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.rules + +import org.junit.rules.ExternalResource +import software.amazon.awssdk.services.ecr.EcrClient +import software.amazon.awssdk.services.ecr.model.Repository +import software.amazon.awssdk.services.ecr.model.RepositoryNotFoundException +import software.aws.toolkits.core.utils.RuleUtils + +class EcrTemporaryRepositoryRule(private val ecrClientSupplier: () -> EcrClient) : ExternalResource() { + constructor(ecrClient: EcrClient) : this({ ecrClient }) + + private val repositories = mutableListOf() + + /** + * Creates a temporary repository with the optional prefix (or calling class if prefix is omitted) + */ + fun createRepository(prefix: String = RuleUtils.prefixFromCallingClass()): Repository { + val repositoryName: String = RuleUtils.randomName(prefix).lowercase() + val client = ecrClientSupplier() + + // note there is no waiter for this + val repo = client.createRepository { it.repositoryName(repositoryName) } + + repositories.add(repositoryName) + + return repo.repository() + } + + override fun after() { + val exceptions = repositories.mapNotNull { deleteRepository(it) } + if (exceptions.isNotEmpty()) { + throw RuntimeException("Failed to delete all repositories. \n\t- ${exceptions.map { it.message }.joinToString("\n\t- ")}") + } + } + + private fun deleteRepository(repository: String): Exception? = try { + ecrClientSupplier().deleteRepository { it.repositoryName(repository).force(true) } + null + } catch (e: Exception) { + when (e) { + is RepositoryNotFoundException -> null + else -> RuntimeException("Failed to delete repository: $repository - ${e.message}", e) + } + } +} diff --git a/core/tst/software/aws/toolkits/core/rules/EnvironmentVariableHelper.kt b/core/tst/software/aws/toolkits/core/rules/EnvironmentVariableHelper.kt index a339a2674c..166de73b8f 100644 --- a/core/tst/software/aws/toolkits/core/rules/EnvironmentVariableHelper.kt +++ b/core/tst/software/aws/toolkits/core/rules/EnvironmentVariableHelper.kt @@ -14,6 +14,7 @@ import java.security.PrivilegedAction class EnvironmentVariableHelper : ExternalResource() { private val originalEnvironmentVariables = System.getenv().toMap() private val modifiableMap = getProcessEnvMap() ?: getEnvMap() + @Volatile private var mutated = false @@ -36,9 +37,11 @@ class EnvironmentVariableHelper : ExternalResource() { private fun getField(processEnvironment: Class<*>, obj: Any?, fieldName: String): MutableMap? = try { val declaredField = processEnvironment.getDeclaredField(fieldName) - AccessController.doPrivileged(PrivilegedAction { - declaredField.isAccessible = true - }) + AccessController.doPrivileged( + PrivilegedAction { + declaredField.isAccessible = true + } + ) @Suppress("UNCHECKED_CAST") declaredField.get(obj) as MutableMap } catch (_: NoSuchFieldException) { diff --git a/core/tst/software/aws/toolkits/core/rules/S3TemporaryBucketRule.kt b/core/tst/software/aws/toolkits/core/rules/S3TemporaryBucketRule.kt index 0a0a2ef0c4..bbb58e8b1c 100644 --- a/core/tst/software/aws/toolkits/core/rules/S3TemporaryBucketRule.kt +++ b/core/tst/software/aws/toolkits/core/rules/S3TemporaryBucketRule.kt @@ -8,22 +8,23 @@ import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.NoSuchBucketException import software.aws.toolkits.core.s3.deleteBucketAndContents import software.aws.toolkits.core.utils.RuleUtils -import software.aws.toolkits.core.utils.Waiters.waitUntilBlocking -class S3TemporaryBucketRule(private val s3Client: S3Client) : ExternalResource() { +class S3TemporaryBucketRule(private val s3ClientSupplier: () -> S3Client) : ExternalResource() { + constructor(s3Client: S3Client) : this({ s3Client }) + private val buckets = mutableListOf() /** * Creates a temporary bucket with the optional prefix (or calling class if prefix is omitted) */ fun createBucket(prefix: String = RuleUtils.prefixFromCallingClass()): String { - val bucketName: String = RuleUtils.randomName(prefix) - s3Client.createBucket { it.bucket(bucketName) } + val bucketName: String = RuleUtils.randomName(prefix).lowercase() + val client = s3ClientSupplier() + + client.createBucket { it.bucket(bucketName) } // Wait for bucket to be ready - waitUntilBlocking(exceptionsToIgnore = setOf(NoSuchBucketException::class)) { - s3Client.headBucket { it.bucket(bucketName) } - } + client.waiter().waitUntilBucketExists { it.bucket(bucketName) } buckets.add(bucketName) @@ -38,7 +39,7 @@ class S3TemporaryBucketRule(private val s3Client: S3Client) : ExternalResource() } private fun deleteBucketAndContents(bucket: String): Exception? = try { - s3Client.deleteBucketAndContents(bucket) + s3ClientSupplier().deleteBucketAndContents(bucket) null } catch (e: Exception) { when (e) { diff --git a/core/tst/software/aws/toolkits/core/rules/SystemPropertyHelper.kt b/core/tst/software/aws/toolkits/core/rules/SystemPropertyHelper.kt index 5e767bb136..cfeca9ccb6 100644 --- a/core/tst/software/aws/toolkits/core/rules/SystemPropertyHelper.kt +++ b/core/tst/software/aws/toolkits/core/rules/SystemPropertyHelper.kt @@ -7,7 +7,7 @@ import org.junit.rules.ExternalResource import java.util.Properties /** - * A utility that can temporarily forcibly set environment variables and + * A utility that can temporarily forcibly set system properties and * then allows resetting them to the original values. */ class SystemPropertyHelper : ExternalResource() { diff --git a/core/tst/software/aws/toolkits/core/telemetry/TelemetryBatcherTest.kt b/core/tst/software/aws/toolkits/core/telemetry/TelemetryBatcherTest.kt index e533fb903e..bceeff9ebc 100644 --- a/core/tst/software/aws/toolkits/core/telemetry/TelemetryBatcherTest.kt +++ b/core/tst/software/aws/toolkits/core/telemetry/TelemetryBatcherTest.kt @@ -3,18 +3,18 @@ package software.aws.toolkits.core.telemetry -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.doAnswer -import com.nhaarman.mockitokotlin2.doThrow -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.stub -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verifyBlocking -import com.nhaarman.mockitokotlin2.verifyZeroInteractions import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.ArgumentMatchers.anyCollection +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.stubbing.Answer import software.amazon.awssdk.core.exception.SdkServiceException import java.util.concurrent.CountDownLatch @@ -125,7 +125,7 @@ class TelemetryBatcherTest { batcher.enqueue(createEmptyMetricEvent()) batcher.flush(false) - verifyZeroInteractions(publisher) + verifyNoMoreInteractions(publisher) assertThat(batcher.eventQueue).isEmpty() } @@ -139,7 +139,7 @@ class TelemetryBatcherTest { batcher.flush(false) - verifyZeroInteractions(publisher) + verifyNoMoreInteractions(publisher) assertThat(batcher.eventQueue).isEmpty() } diff --git a/core/tst/software/aws/toolkits/core/utils/CollectionUtilsTest.kt b/core/tst/software/aws/toolkits/core/utils/CollectionUtilsTest.kt new file mode 100644 index 0000000000..2636084204 --- /dev/null +++ b/core/tst/software/aws/toolkits/core/utils/CollectionUtilsTest.kt @@ -0,0 +1,27 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.utils + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CollectionUtilsTest { + + @Test + fun `collection items are replaced`() { + val source = mutableListOf("hello") + + source.replace(listOf("world")) + + assertThat(source).containsOnly("world") + } + + @Test + fun `map entries are replaced`() { + val source = mutableMapOf("foo" to "bar") + source.replace(mapOf("hello" to "world")) + + assertThat(source).containsOnlyKeys("hello") + } +} diff --git a/core/tst/software/aws/toolkits/core/utils/DelegateSdkConsumers.kt b/core/tst/software/aws/toolkits/core/utils/DelegateSdkConsumers.kt index 69c85a3e07..b59d463bef 100644 --- a/core/tst/software/aws/toolkits/core/utils/DelegateSdkConsumers.kt +++ b/core/tst/software/aws/toolkits/core/utils/DelegateSdkConsumers.kt @@ -3,34 +3,52 @@ package software.aws.toolkits.core.utils -import com.nhaarman.mockitokotlin2.KStubbing -import com.nhaarman.mockitokotlin2.withSettings import org.mockito.Mockito import org.mockito.invocation.InvocationOnMock +import org.mockito.kotlin.KStubbing +import org.mockito.kotlin.withSettings import org.mockito.stubbing.Answer +import software.amazon.awssdk.core.SdkClient import software.amazon.awssdk.core.SdkRequest import kotlin.reflect.full.isSubclassOf -class DelegateSdkConsumers : Answer { +/** + * Answer that inspects the target invocation and determines if it should call the real method or respond with a mock answer. + * This is tied to the implementation of the Java SDK V2's generated client interfaces where lambda based calls use a default implementation to delegate + * eventually down to a method that takes a built SdkRequest type. The final concrete method is coded to always throw an exception so that is the method that we + * will mock. + * + * This will handle "simple" methods that they generate as well such as ListBuckets that takes 0 arguments. + */ +class DelegateSdkConsumers(private val sdkClass: Class<*>) : Answer { override fun answer(invocation: InvocationOnMock): Any? { val method = invocation.method - return if (method.isDefault && - method?.parameters?.getOrNull(0)?.type?.kotlin?.isSubclassOf(SdkRequest::class) != true - ) { + return if (method.name == "waiter") { + createWaiter(invocation.method.returnType, invocation) + } else if (method.isDefault && method?.parameters?.getOrNull(0)?.type?.kotlin?.isSubclassOf(SdkRequest::class) != true) { invocation.callRealMethod() } else { Mockito.RETURNS_DEFAULTS.answer(invocation) } } + + private fun createWaiter(waiterType: Class<*>, invocation: InvocationOnMock): Any? { + val builder = waiterType.getDeclaredMethod("builder").invoke(null) + with(builder::class.java) { + getDeclaredMethod("client", sdkClass).invoke(builder, invocation.mock) + return getDeclaredMethod("build").invoke(builder) + } + } } -inline fun delegateMock(): T = Mockito.mock( +inline fun delegateMock(verboseLogging: Boolean = false): T = Mockito.mock( T::class.java, withSettings( - defaultAnswer = DelegateSdkConsumers() + verboseLogging = verboseLogging, + defaultAnswer = DelegateSdkConsumers(T::class.java) ) ) -inline fun delegateMock(stubbing: KStubbing.(T) -> Unit): T = delegateMock().apply { +inline fun delegateMock(stubbing: KStubbing.(T) -> Unit): T = delegateMock().apply { KStubbing(this).stubbing(this) } diff --git a/core/tst/software/aws/toolkits/core/utils/IntegrationTestCredentials.kt b/core/tst/software/aws/toolkits/core/utils/IntegrationTestCredentials.kt new file mode 100644 index 0000000000..db5648b40e --- /dev/null +++ b/core/tst/software/aws/toolkits/core/utils/IntegrationTestCredentials.kt @@ -0,0 +1,39 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.utils + +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.credentials.ContainerCredentialsProvider +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.amazon.awssdk.utils.SdkAutoCloseable +import java.util.UUID + +/** + * Creates an [AwsCredentialsProvider] meant to be used in integration tests + * + * If the environment variable `ASSUME_ROLE_ARN` is set, it will be assumed using the default credential chain as the source credentials. + * If it is not set, we will just use the default credential provider chain + */ +fun createIntegrationTestCredentialProvider(): AwsCredentialsProvider { + // TODO: Finish https://github.com/aws/aws-toolkit-jetbrains/pull/2193 and revert back to Default Chain + val defaultCredentials = ContainerCredentialsProvider.builder().build() + + return System.getenv("ASSUME_ROLE_ARN")?.takeIf { it.isNotEmpty() }?.let { role -> + val sessionName = UUID.randomUUID().toString() + val stsClient = StsClient.builder().credentialsProvider(defaultCredentials).build() + val credentialsProvider = StsAssumeRoleCredentialsProvider.builder() + .stsClient(stsClient) + .refreshRequest(AssumeRoleRequest.builder().roleArn(role).roleSessionName(sessionName).build()) + .build() + + // Wrap this in SdkAutoClosable so we have a hook to close this STS client else IntelliJ will say we thread leak + return object : AwsCredentialsProvider by credentialsProvider, SdkAutoCloseable { + override fun close() { + stsClient.close() + } + } + } ?: defaultCredentials +} diff --git a/core/tst/software/aws/toolkits/core/utils/LogUtilsTest.kt b/core/tst/software/aws/toolkits/core/utils/LogUtilsTest.kt index d39b86bfe7..472117e9c2 100644 --- a/core/tst/software/aws/toolkits/core/utils/LogUtilsTest.kt +++ b/core/tst/software/aws/toolkits/core/utils/LogUtilsTest.kt @@ -1,18 +1,19 @@ // Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +@file:Suppress("LazyLog") package software.aws.toolkits.core.utils -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.eq -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.reset -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyZeroInteractions -import com.nhaarman.mockitokotlin2.whenever import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever import org.slf4j.Logger import org.slf4j.event.Level @@ -45,7 +46,7 @@ class LogUtilsTest { @Test fun nullableIsNotOkInTryOrThrow() { - val exception = catch { log.tryOrThrow("message") { mightBeNull(shouldBeNull = true) } } + val exception = catch { log.tryOrThrow("message") { mightBeNull(shouldBeNull = true) } } verify(log).error(any(), eq(exception)) } @@ -59,13 +60,13 @@ class LogUtilsTest { @Test fun nullableIsOkInTryOrThrowNullable() { log.tryOrThrowNullable("message") { null } - verifyZeroInteractions(log) + verifyNoMoreInteractions(log) } @Test fun nullIsOkInTryOrNull() { log.tryOrNull("message") { null } - verifyZeroInteractions(log) + verifyNoMoreInteractions(log) } @Test @@ -174,5 +175,6 @@ class LogUtilsTest { "hello" } + @Suppress("FunctionOnlyReturningConstant") private fun willNeverBeNull(): String = "hello" } diff --git a/core/tst/software/aws/toolkits/core/utils/RemoteResourceResolverTest.kt b/core/tst/software/aws/toolkits/core/utils/RemoteResourceResolverTest.kt index 02dc511b94..2c80b39542 100644 --- a/core/tst/software/aws/toolkits/core/utils/RemoteResourceResolverTest.kt +++ b/core/tst/software/aws/toolkits/core/utils/RemoteResourceResolverTest.kt @@ -3,17 +3,18 @@ package software.aws.toolkits.core.utils -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.doAnswer -import com.nhaarman.mockitokotlin2.eq -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify import org.assertj.core.api.Assertions.assertThat import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.mockito.invocation.InvocationOnMock +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import software.aws.toolkits.core.lambda.LambdaManifestValidator import java.io.InputStream import java.nio.file.Files import java.nio.file.Path @@ -40,8 +41,8 @@ class RemoteResourceResolverTest { val resource = resource() - val firstCall = sut.resolve(resource).toCompletableFuture().get() - val secondCall = sut.resolve(resource).toCompletableFuture().get() + val firstCall = sut.resolve(resource).unwrap() + val secondCall = sut.resolve(resource).unwrap() assertThat(firstCall).isEqualTo(secondCall) assertThat(firstCall).hasContent("data") @@ -52,7 +53,7 @@ class RemoteResourceResolverTest { @Test fun expiredFileIsDownloadedAgain() { val urlFetcher = mock { - on { fetch(eq(PRIMARY_URL), any()) }.doAnswer(writeDataToFile("first")).doAnswer(writeDataToFile("second")) + on { fetch(eq(PRIMARY_URL), any()) }.doAnswer(writeDataToFile("data1")).doAnswer(writeDataToFile("data2")) } val sut = DefaultRemoteResourceResolver(urlFetcher, tempPath.newFolder().toPath(), immediateExecutor) @@ -64,10 +65,29 @@ class RemoteResourceResolverTest { val secondCall = sut.resolve(resource).toCompletableFuture().get() assertThat(firstCall).isEqualTo(secondCall) - assertThat(secondCall).hasContent("second") + + assertThat(secondCall).hasContent("data2") verify(urlFetcher, times(2)).fetch(eq(PRIMARY_URL), any()) } + @Test + fun downloadedParseFailedSkippedMove() { + val urlFetcher = mock { + on { fetch(eq(PRIMARY_URL), any()) }.doAnswer(writeDataToFile(FAIL)) + } + + val cachePath = tempPath.newFolder().toPath() + val sut = DefaultRemoteResourceResolver(urlFetcher, cachePath, immediateExecutor) + + val resource = xmlResource() + + val firstCall = sut.resolve(resource).unwrap() + val secondCall = sut.resolve(resource).unwrap() + + assertThat(firstCall).isEqualTo(secondCall) + assertThat(firstCall.exists()).isFalse() + } + @Test fun failureToHitUrlFallsBackToCurrentCopy() { val urlFetcher = mock { @@ -114,7 +134,6 @@ class RemoteResourceResolverTest { @Test fun canFallbackDownListOfUrls() { - val urlFetcher = mock { on { fetch(eq(PRIMARY_URL), any()) }.thenThrow(RuntimeException("BOOM!")) on { fetch(eq(SECONDARY_URL), any()) }.doAnswer(writeDataToFile("data")) @@ -126,25 +145,51 @@ class RemoteResourceResolverTest { } private companion object { + val LOG = getLogger() + fun resource( name: String = "resource", urls: List = listOf(PRIMARY_URL), ttl: Duration? = Duration.ofMillis(1000), - initialValue: InputStream? = null + initialValue: InputStream? = null, + ) = object : RemoteResource { + override val urls: List = urls + override val name: String = name + override val ttl: Duration? = ttl + override val initialValue = initialValue?.let { { it } } + } + + fun xmlResource( + name: String = "resource", + urls: List = listOf(PRIMARY_URL), + ttl: Duration? = Duration.ofMillis(1000), + initialValue: InputStream? = null, + remoteResolveParser: RemoteResolveParser? = LambdaManifestValidator ) = object : RemoteResource { override val urls: List = urls override val name: String = name override val ttl: Duration? = ttl override val initialValue = initialValue?.let { { it } } + override val remoteResolveParser: RemoteResolveParser = remoteResolveParser as LambdaManifestValidator } fun writeDataToFile(data: String): (InvocationOnMock) -> Unit = { invocation -> - (invocation.arguments[1] as Path).writeText(data) + val path = invocation.arguments[1] as Path + path.writeText(data) + // It's possible for it to be done writing but path.exists to not work yet which + // makes the canDownloadAFileOnce test fail (on CodeBuild). + while (!path.exists()) { + LOG.debug { "writeDataToFile path does not exist yet: $path" } + Thread.sleep(10) + } } val immediateExecutor: (Callable) -> CompletionStage = { CompletableFuture.completedFuture(it.call()) } const val PRIMARY_URL = "http://example.com" const val SECONDARY_URL = "http://example2.com" + const val FAIL = "" + + "data" + + "<>" } } diff --git a/core/tst/software/aws/toolkits/core/utils/RuleUtils.kt b/core/tst/software/aws/toolkits/core/utils/RuleUtils.kt index 3cb636a72c..5556fe2ba5 100644 --- a/core/tst/software/aws/toolkits/core/utils/RuleUtils.kt +++ b/core/tst/software/aws/toolkits/core/utils/RuleUtils.kt @@ -7,13 +7,14 @@ import java.util.Random object RuleUtils { fun randomName(prefix: String = "a", length: Int = 63): String { + val characters = ('0'..'9') + ('A'..'Z') + ('a'..'Z') val userName = System.getProperty("user.name", "unknown") - return "${prefix.toLowerCase()}-${userName.toLowerCase()}-${Random().nextInt(10000)}".take(length) + return "${prefix.lowercase()}-${userName.lowercase()}-${List(length) { characters.random() }.joinToString("")}".take(length) } fun prefixFromCallingClass(): String { - val callingClass = Thread.currentThread().stackTrace[3].className - return callingClass.substringAfterLast(".") + val callingClass = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE).callerClass + return callingClass.simpleName } 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/RuleUtilsTest.kt b/core/tst/software/aws/toolkits/core/utils/RuleUtilsTest.kt new file mode 100644 index 0000000000..c1402150fd --- /dev/null +++ b/core/tst/software/aws/toolkits/core/utils/RuleUtilsTest.kt @@ -0,0 +1,27 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.core.utils + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test + +class RuleUtilsTest { + private lateinit var callingClass: String + + @Before + fun setUp() { + callingClass = RuleUtils.prefixFromCallingClass() + } + + @Test + fun `late init before works`() { + assertThat(callingClass).isEqualTo("RuleUtilsTest") + } + + @Test + fun `inline works`() { + assertThat(RuleUtils.prefixFromCallingClass()).isEqualTo("RuleUtilsTest") + } +} diff --git a/core/tst/software/aws/toolkits/core/utils/test/AssertJAsserts.kt b/core/tst/software/aws/toolkits/core/utils/test/AssertJAsserts.kt index 07aab489ca..106d7720e8 100644 --- a/core/tst/software/aws/toolkits/core/utils/test/AssertJAsserts.kt +++ b/core/tst/software/aws/toolkits/core/utils/test/AssertJAsserts.kt @@ -3,8 +3,18 @@ package software.aws.toolkits.core.utils.test +import org.assertj.core.api.IterableAssert +import org.assertj.core.api.ListAssert import org.assertj.core.api.ObjectAssert @Suppress("UNCHECKED_CAST") val ObjectAssert.notNull: ObjectAssert get() = this.isNotNull as ObjectAssert + +@Suppress("UNCHECKED_CAST") +inline fun IterableAssert<*>.hasOnlyElementsOfType(): IterableAssert = + hasOnlyElementsOfType(SubType::class.java) as IterableAssert + +@Suppress("UNCHECKED_CAST") +inline fun ListAssert<*>.hasOnlyOneElementOfType(): ObjectAssert = + (hasOnlyElementsOfType(SubType::class.java) as ListAssert).singleElement() diff --git a/core/tst/software/aws/toolkits/core/utils/test/TestUtilsTest.kt b/core/tst/software/aws/toolkits/core/utils/test/TestUtilsTest.kt index 2e90092f65..83b3a5b5de 100644 --- a/core/tst/software/aws/toolkits/core/utils/test/TestUtilsTest.kt +++ b/core/tst/software/aws/toolkits/core/utils/test/TestUtilsTest.kt @@ -3,11 +3,11 @@ package software.aws.toolkits.core.utils.test -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import java.time.Duration class TestUtilsTest { diff --git a/designs/credentialManagement/credentialManagement.md b/designs/credentialManagement/credentialManagement.md index 6cb560e707..c75a21c0c3 100644 --- a/designs/credentialManagement/credentialManagement.md +++ b/designs/credentialManagement/credentialManagement.md @@ -17,14 +17,14 @@ 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. +1. `AwsRegion` - Data 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. +1. `CredentialIdentifier` - Represents the globally unique identifier for a possible credential profile in the toolkit. This identifier must be deterministic, +meaning a credential source should have identical `CredentialIdentifier` values when represented by different instances, or when used 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. @@ -69,8 +69,9 @@ If the call fails, we consider the credentials to be invalid. 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. +The concept of _Active Connection Settings_ represents the user's currently selected credentials and region. They are used by the toolkit: +* to perform actions in the AWS Explorer +* as defaults when presenting more than one option in a dialog 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. diff --git a/designs/credentialManagement/images/classDiagram.svg b/designs/credentialManagement/images/classDiagram.svg index de97bcd5a8..7acc439c49 100644 --- a/designs/credentialManagement/images/classDiagram.svg +++ b/designs/credentialManagement/images/classDiagram.svg @@ -1,20 +1,19 @@ -AwsRegionString 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()InheritanceCreatesmany1Ownsmany1AwsRegionString partitionIdString nameString idCredentialIdentifierString factoryIdString displayNameString idCredentialsChangeEventadded: List<CredentialIdentifier>,modified: List<CredentialIdentifier>,removed: List<CredentialIdentifier>CredentialsChangeListeneronChange(CredentialsChangeEventCredentialManagerList<CredentialIdentifier> getCredentialIdentifiers()CredentialIdentifier getCredentialIdentifierById(String id)ToolkitCredentialsProvider getAwsCredentialProvider(CredentialIdentifier, AwsRegion)CredentialProviderFactoryString idvoid setUp(callback CredentialsChangeListener)ToolkitCredentialsProvider createAwsCredentialProvider(CredentialIdentifier, AwsRegion, Suppier<SdkHttpClient>)ToolkitCredentialsProviderString displayNameString idAwsCredentialsProviderAwsCredentials resolveCredentials()InheritanceCreatesmany1Ownsmany1 +--> \ No newline at end of file diff --git a/detekt-rules/build.gradle.kts b/detekt-rules/build.gradle.kts new file mode 100644 index 0000000000..13b426c44b --- /dev/null +++ b/detekt-rules/build.gradle.kts @@ -0,0 +1,26 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +plugins { + id("toolkit-kotlin-conventions") + id("toolkit-testing") +} + +dependencies { + compileOnly(libs.detekt.api) + + testImplementation(libs.detekt.test) + testImplementation(libs.junit4) + testImplementation(libs.assertj) + + testRuntimeOnly(libs.slf4j.api) + testRuntimeOnly(libs.junit5.jupiterVintage) +} + +tasks.test { + useJUnitPlatform() +} + +tasks.jar { + duplicatesStrategy = DuplicatesStrategy.WARN +} diff --git a/detekt-rules/detekt.yml b/detekt-rules/detekt.yml new file mode 100644 index 0000000000..f05ffeeffc --- /dev/null +++ b/detekt-rules/detekt.yml @@ -0,0 +1,239 @@ +build: + excludeCorrectable: false + +config: + validation: true + +processors: + active: true + exclude: + - 'DetektProgressListener' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FileBasedFindingsReport' + +output-reports: + active: true + +comments: + active: true + AbsentOrWrongFileLicense: + active: true + licenseTemplateFile: ./license.template + licenseTemplateIsRegex: true + +complexity: + active: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: true + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + EmptyFunctionBlock: + active: false + +exceptions: + # there might actually be some useful rules in here + active: false + +# unfortunately, does not respect .editorconfig in the project folder +# this configuration reflects the delta from the default "formatting" (ktlint) rule set +formatting: + active: true + android: false + autoCorrect: true + AnnotationOnSeparateLine: + active: false + autoCorrect: true + AnnotationSpacing: + active: false + autoCorrect: true + ArgumentListWrapping: + active: true + autoCorrect: true + indentSize: 4 + maxLineLength: 160 + Indentation: + active: true + autoCorrect: true + indentSize: 4 + excludes: [ '**/TelemetryDefinitions.kt' ] + MaximumLineLength: + active: true + maxLineLength: 160 + ignoreBackTickedIdentifier: true + NoWildcardImports: + # no `packagesToUseImportOnDemandProperty` because we don't want to allow any star imports + active: true + ParameterListWrapping: + active: true + indentSize: 4 + maxLineLength: 160 + ParameterWrapping: + active: true + indentSize: 4 + maxLineLength: 160 + PropertyWrapping: + active: true + indentSize: 4 + maxLineLength: 160 + SpacingBetweenDeclarationsWithComments: + active: true + autoCorrect: true + excludes: [ '**/icons/**' ] + Wrapping: + active: true + autoCorrect: true + indentSize: 4 + maxLineLength: 160 + +naming: + active: true + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + FunctionNaming: + active: true + functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' + excludeClassPattern: '$^' + ignoreAnnotated: [ 'Composable' ] + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + # prefer rule provided by ktlint + MatchingDeclarationName: + active: false + MemberNameEqualsClassName: + active: false + NoNameShadowing: + active: true + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + SpreadOperator: + active: false + +potential-bugs: + active: true + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: false + IgnoredReturnValue: + active: false + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + MapGetWithNotNullAssertionOperator: + active: false + NullableToStringCall: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + # doesn't seem to work correctly on our codebase + # qodana provides coverage for this + UnreachableCode: + active: false + UnsafeCallOnNullableType: + active: true + UnsafeCast: + active: true + UnusedUnaryOperator: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: true + +style: + ExpressionBodySyntax: + active: true + includeLineWrapping: true + ForbiddenComment: + active: false + MagicNumber: + active: false + # prefer rule provided by ktlint + MaxLineLength: + active: false + # prefer rule provided by ktlint + ModifierOrder: + active: false + # prefer rule provided by ktlint + NewLineAtEndOfFile: + active: false + NoTabs: + active: true + ReturnCount: + active: false + ThrowsCount: + active: false + UnnecessaryAbstractClass: + active: false + UnusedPrivateMember: + allowedNames: '(_|ignored|expected|serialVersionUID|createUIComponents)' + VarCouldBeVal: + active: false diff --git a/detekt-rules/license.template b/detekt-rules/license.template new file mode 100644 index 0000000000..5bce42c196 --- /dev/null +++ b/detekt-rules/license.template @@ -0,0 +1 @@ +^// Copyright \d{4} Amazon.com, Inc. or its affiliates. All Rights Reserved.\n// SPDX-License-Identifier: Apache-2.0\n? diff --git a/detekt-rules/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/detekt-rules/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider new file mode 100644 index 0000000000..ecba26700c --- /dev/null +++ b/detekt-rules/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider @@ -0,0 +1 @@ +software.aws.toolkits.gradle.detekt.rules.CustomRuleSetProvider diff --git a/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt new file mode 100644 index 0000000000..d6fe598a7e --- /dev/null +++ b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.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.gradle.detekt.rules + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.psi.KtImportList + +class BannedImportsRule : Rule() { + override val issue = Issue("BannedImports", Severity.Defect, "Imports banned by the project", Debt.FIVE_MINS) + + override fun visitImportList(importList: KtImportList) { + super.visitImportList(importList) + importList.imports.forEach { element -> + val importedFqName = element.importedFqName?.asString() + if (importedFqName == "org.assertj.core.api.Assertions") { + report( + CodeSmell( + issue, + Entity.from(element), + message = "Import the assertion you want to use directly instead of importing the top level Assertions" + ) + ) + } + + if (importedFqName?.startsWith("org.hamcrest") == true) { + report( + CodeSmell( + issue, + Entity.from(element), + message = "Use AssertJ instead of Hamcrest assertions" + ) + ) + } + + if (importedFqName?.startsWith("kotlin.test.assert") == true && + importedFqName.startsWith("kotlin.test.assertNotNull") == false + ) { + report( + CodeSmell( + issue, + Entity.from(element), + message = "Use AssertJ instead of Kotlin test assertions" + ) + ) + } + + if (importedFqName?.contains("kotlinx.coroutines.Dispatchers") == true) { + report( + CodeSmell( + issue, + Entity.from(element), + message = "Use contexts from contexts.kt instead of Dispatchers" + ) + ) + } + + if (importedFqName == "com.intellij.ui.layout.panel") { + report( + CodeSmell( + issue, + Entity.from(element), + message = "Use com.intellij.ui.dsl.builder.panel from Kotlin UI DSL Version 2" + ) + ) + } + } + } +} diff --git a/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedPatternRule.kt b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedPatternRule.kt new file mode 100644 index 0000000000..a676bb754b --- /dev/null +++ b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedPatternRule.kt @@ -0,0 +1,55 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +@file:Suppress("BannedPattern") +package software.aws.toolkits.gradle.detekt.rules + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.psi.KtFile + +class BannedPatternRule(private val patterns: List) : Rule() { + override val issue = Issue("BannedPattern", Severity.Defect, "Banned calls", Debt.FIVE_MINS) + + override fun visitKtFile(file: KtFile) { + var offset = 0 + file.text.split("\n").forEachIndexed { _, text -> + patterns.forEach { pattern -> + val match = pattern.regex.find(text) ?: return@forEach + report( + CodeSmell( + issue, + Entity.from(file, offset + match.range.first), + message = pattern.message + ) + ) + } + // account for delimiter + offset += text.length + 1 + } + } + + companion object { + val DEFAULT_PATTERNS = listOf( + BannedPattern("Runtime\\.valueOf".toRegex(), "Runtime.valueOf is banned, use Runtime.fromValue instead."), + BannedPattern( + """com\.intellij\.openapi\.actionSystem\.DataKeys""".toRegex(), + "DataKeys is not available in all IDEs, use LangDataKeys instead" + ), + BannedPattern( + """PsiUtil\.getPsiFile""".toRegex(), + "PsiUtil (java-api.jar) is not available in all IDEs, use PsiManager.getInstance(project).findFile() instead" + ), + BannedPattern( + """com\.intellij\.psi\.util\.PsiUtil$""".toRegex(), + "PsiUtil (java-api.jar) is not available in all IDEs, use PsiUtilCore or PsiManager instead (platform-api.jar)" + ) + ) + } +} + +data class BannedPattern(val regex: Regex, val message: String) diff --git a/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/CustomRuleSetProvider.kt b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/CustomRuleSetProvider.kt new file mode 100644 index 0000000000..2b8683719d --- /dev/null +++ b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/CustomRuleSetProvider.kt @@ -0,0 +1,21 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.detekt.rules + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.RuleSet +import io.gitlab.arturbosch.detekt.api.RuleSetProvider + +class CustomRuleSetProvider : RuleSetProvider { + override val ruleSetId: String = "CustomDetektRules" + override fun instance(config: Config): RuleSet = RuleSet( + ruleSetId, + listOf( + BannedPatternRule(BannedPatternRule.DEFAULT_PATTERNS), + LazyLogRule(), + DialogModalityRule(), + BannedImportsRule() + ) + ) +} diff --git a/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/DialogModalityRule.kt b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/DialogModalityRule.kt new file mode 100644 index 0000000000..8187f52610 --- /dev/null +++ b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/DialogModalityRule.kt @@ -0,0 +1,41 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.detekt.rules + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.psiUtil.containingClass +import org.jetbrains.kotlin.psi.psiUtil.getSuperNames + +class DialogModalityRule : Rule() { + override val issue = Issue("RunInEdtWithoutModalityInDialog", Severity.Defect, "Use ModalityState when calling runInEdt in dialogs", Debt.FIVE_MINS) + + override fun visitCallExpression(element: KtCallExpression) { + super.visitCallExpression(element) + val callee = element.calleeExpression as? KtNameReferenceExpression ?: return + if (callee.getReferencedName() != "runInEdt") return + val clz = element.containingClass() ?: return + if (clz.getSuperNames().none { it in KNOWN_DIALOG_SUPER_TYPES }) return + + if (element.valueArguments.none { it.text == "ModalityState.any()" }) { + report( + CodeSmell( + issue, + Entity.from(element), + message = "Call to runInEdt without ModalityState.any() within Dialog will not run until Dialog exits." + ) + ) + } + } + + companion object { + private val KNOWN_DIALOG_SUPER_TYPES = setOf("DialogWrapper") + } +} diff --git a/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/LazyLogRule.kt b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/LazyLogRule.kt new file mode 100644 index 0000000000..ec66b51363 --- /dev/null +++ b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/LazyLogRule.kt @@ -0,0 +1,64 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.detekt.rules + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import io.gitlab.arturbosch.detekt.api.internal.RequiresTypeResolution +import io.gitlab.arturbosch.detekt.rules.fqNameOrNull +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.psiUtil.getCallNameExpression +import org.jetbrains.kotlin.resolve.BindingContext +import org.jetbrains.kotlin.resolve.calls.util.getReceiverExpression +import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall + +@RequiresTypeResolution +class LazyLogRule : Rule() { + override val issue = Issue("LazyLog", Severity.Style, "Use lazy logging syntax (e.g. warning {\"abc\"} ) instead of warning(\"abc\")", Debt.FIVE_MINS) + + // UI tests have issues with this TODO see if we want multiple detekt.yml files or disable for certain modules in this rule + private val optOut = setOf("software.aws.toolkits.jetbrains.uitests") + + override fun visitCallExpression(element: KtCallExpression) { + super.visitCallExpression(element) + element.getCallNameExpression()?.let { + if (!logMethods.contains(it.text)) { + return + } + + if (optOut.any { name -> element.containingKtFile.packageFqName.asString().startsWith(name) }) { + return + } + + if (bindingContext == BindingContext.EMPTY) return + val resolvedCall = it.getResolvedCall(bindingContext) + val type = resolvedCall?.extensionReceiver?.type?.fqNameOrNull()?.asString() + ?: resolvedCall?.dispatchReceiver?.type?.fqNameOrNull()?.asString() + + if (type !in loggers) { + return + } + + if (element.lambdaArguments.size != 1) { + val receiverName = resolvedCall?.getReceiverExpression()?.text ?: type + report( + CodeSmell( + issue, + Entity.from(element), + message = "Use the lambda version of $receiverName.${it.text} instead" + ) + ) + } + } + } + + companion object { + private val logMethods = setOf("error", "warn", "info", "debug", "trace") + val loggers = setOf("org.slf4j.Logger") + } +} diff --git a/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt new file mode 100644 index 0000000000..5370420f75 --- /dev/null +++ b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt @@ -0,0 +1,60 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.detekt.rules + +import io.gitlab.arturbosch.detekt.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")) + .singleElement() + .matches { it.id == "BannedImports" && it.message == "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")) + .singleElement() + .matches { it.id == "BannedImports" && it.message == "Use AssertJ instead of Hamcrest assertions" } + } + + @Test + fun `Importing Kotlin test assert fails`() { + assertThat(rule.lint("import kotlin.test.assertTrue")) + .singleElement() + .matches { it.id == "BannedImports" && it.message == "Use AssertJ instead of Kotlin test assertions" } + assertThat(rule.lint("import kotlin.test.assertFalse")) + .singleElement() + .matches { it.id == "BannedImports" && it.message == "Use AssertJ instead of Kotlin test assertions" } + } + + @Test + fun `Importing kotlin test notNull succeeds`() { + assertThat(rule.lint("import kotlin.test.assertNotNull")).isEmpty() + } + + @Test + fun `Importing Assert assertThat succeeds`() { + assertThat(rule.lint("import org.assertj.core.api.Assertions.assertThat")).isEmpty() + } + + @Test + fun `Importing Dispatchers fails`() { + assertThat(rule.lint("import kotlinx.coroutines.Dispatchers")) + .singleElement() + .matches { it.id == "BannedImports" && it.message == "Use contexts from contexts.kt instead of Dispatchers" } + } + + @Test + fun `Importing Dispatchers statically fails`() { + assertThat(rule.lint("import kotlinx.coroutines.Dispatchers.IO")) + .singleElement() + .matches { it.id == "BannedImports" && it.message == "Use contexts from contexts.kt instead of Dispatchers" } + } +} diff --git a/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedPatternRuleTest.kt b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedPatternRuleTest.kt new file mode 100644 index 0000000000..8c3b99d537 --- /dev/null +++ b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedPatternRuleTest.kt @@ -0,0 +1,80 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +@file:Suppress("BannedPattern") +package software.aws.toolkits.gradle.detekt.rules + +import io.gitlab.arturbosch.detekt.test.lint +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class BannedPatternRuleTest { + @Test + fun classContainingRegexCreatesError() { + val rule = BannedPatternRule(listOf(BannedPattern("""blah\(\)""".toRegex(), "Use of method blah() is banned."))) + assertThat( + rule.lint( + """ + fun hello() { + blah() + } + """.trimIndent() + ) + ) + .singleElement() + .matches { + it.id == "BannedPattern" && + it.message == "Use of method blah() is banned." && + it.location.source.line == 2 && + it.location.source.column == 5 + } + } + + @Test + fun forbidPsiUtil() { + val rule = BannedPatternRule(BannedPatternRule.DEFAULT_PATTERNS) + assertThat( + rule.lint( + """ + import com.intellij.psi.util.PsiUtil + class DockerfileParser(private val project: Project) { + fun parse(virtualFile: VirtualFile): DockerfileDetails? { + val psiFile = PsiUtil.getPsiFile(project, virtualFile) + } + } + """.trimIndent() + ) + ) + .hasSize(2) + .anyMatch { + it.id == "BannedPattern" && + it.message == "PsiUtil (java-api.jar) is not available in all IDEs, use PsiUtilCore or PsiManager instead (platform-api.jar)" && + it.location.source.line == 1 && + it.location.source.column == 8 + } + .anyMatch { + it.id == "BannedPattern" && + it.message == "PsiUtil (java-api.jar) is not available in all IDEs, use PsiManager.getInstance(project).findFile() instead" && + it.location.source.line == 4 && + it.location.source.column == 23 + } + } + + @Test + fun allowPsiUtilCore() { + val rule = BannedPatternRule(BannedPatternRule.DEFAULT_PATTERNS) + assertThat( + rule.lint( + """ + import com.intellij.psi.util.PsiUtilCore + class DockerfileParser(private val project: Project) { + fun parse(virtualFile: VirtualFile): DockerfileDetails? { + val psiFile = PsiUtilCore.getPsiFile(project, virtualFile) + } + } + """.trimIndent() + ) + ) + .hasSize(0) + } +} diff --git a/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/DialogModalityRuleTest.kt b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/DialogModalityRuleTest.kt new file mode 100644 index 0000000000..09c3f409d8 --- /dev/null +++ b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/DialogModalityRuleTest.kt @@ -0,0 +1,57 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.detekt.rules + +import io.gitlab.arturbosch.detekt.test.lint +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class DialogModalityRuleTest { + private val rule = DialogModalityRule() + + @Test + fun runInEdtCallsShouldSpecifyModalityWhenCalledWithinDialog() { + val code = """ + class Blah : DialogWrapper { + fun blah() { + runInEdt { } + } + } + """ + assertThat(rule.lint(code)).singleElement() + .matches { + it.id == "RunInEdtWithoutModalityInDialog" && + it.message == "Call to runInEdt without ModalityState.any() within Dialog will not run until Dialog exits." + } + } + + @Test + fun callsThatSpecifyModalityAnyAreFine() { + val code = """ + class Blah : DialogWrapper { + fun blah() { + runInEdt(ModalityState.any()) { } + } + } + """.trimIndent() + assertThat(rule.lint(code)).isEmpty() + } + + @Test + fun callsThatSpecifyWrongModalityAreNotFine() { + val code = """ + class Blah : DialogWrapper() { + fun blah() { + runInEdt(ModalityState.current()) { } + } + } + """ + + assertThat(rule.lint(code)).singleElement() + .matches { + it.id == "RunInEdtWithoutModalityInDialog" && + it.message == "Call to runInEdt without ModalityState.any() within Dialog will not run until Dialog exits." + } + } +} diff --git a/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/LazyLogRuleTest.kt b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/LazyLogRuleTest.kt new file mode 100644 index 0000000000..a9b0835414 --- /dev/null +++ b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/LazyLogRuleTest.kt @@ -0,0 +1,93 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.gradle.detekt.rules + +import io.github.detekt.test.utils.createEnvironment +import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.io.File + +class LazyLogRuleTest { + private val rule = LazyLogRule() + private val environment = createEnvironment( + additionalRootPaths = LazyLogRule.loggers.map { + File(Class.forName(it).protectionDomain.codeSource.location.path) + } + ).env + + @Test + fun lambdaIsUsedToLog() { + assertThat( + rule.compileAndLintWithContext( + environment, + """ +import org.slf4j.LoggerFactory + +val LOG = LoggerFactory.getLogger("") +fun foo() { + LOG.debug { "Hi" } +} + """.trimIndent() + ) + ).isEmpty() + } + + @Test + fun methodCallIsUsedToLog() { + assertThat( + rule.compileAndLintWithContext( + environment, + """ +import org.slf4j.LoggerFactory + +val LOG = LoggerFactory.getLogger("") +fun foo() { + LOG.debug("Hi") +} + """.trimIndent() + ) + ).singleElement() + .matches { + it.id == "LazyLog" && it.message == "Use the lambda version of LOG.debug instead" + } + } + + @Test + fun lambdaIsUsedToLogButWithException() { + assertThat( + rule.compileAndLintWithContext( + environment, + """ +import org.slf4j.LoggerFactory + +val LOG = LoggerFactory.getLogger("") +fun foo() { + val e = RuntimeException() + LOG.debug(e) {"Hi" } +} + """.trimIndent() + ) + ).isEmpty() + } + + @Test + fun methodCallIsUsedToLogInUiTests() { + assertThat( + rule.compileAndLintWithContext( + environment, + """ +package software.aws.toolkits.jetbrains.uitests.really.cool.test + +import org.slf4j.LoggerFactory + +val LOG = LoggerFactory.getLogger("") +fun foo() { + LOG.debug("Hi") +} + """.trimIndent() + ) + ).isEmpty() + } +} diff --git a/gradle.properties b/gradle.properties index 7017be1642..7e7f0c1522 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,31 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=1.18-SNAPSHOT +toolkitVersion=2.3-SNAPSHOT # Publish Settings publishToken= publishChannel= -# Common dependencies -ideProfileName=2019.3 -kotlinVersion=1.3.70 -awsSdkVersion=2.13.58 -coroutinesVersion=1.3.3 -ideaPluginVersion=0.4.20 -ktlintVersion=0.36.0 -jacksonVersion=2.9.8 -telemetryVersion=0.0.34 - -assertjVersion=3.15.0 -junitVersion=4.12 -junit5Version=5.6.2 -mockitoKotlinVersion=2.2.0 -mockitoVersion=3.4.0 +ideProfileName=2023.3 remoteRobotPort=8080 -remoteRobotVersion=0.9.35 -uiTestFixturesVersion=1.1.18 # Code style kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000000..b9b13c374f --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,111 @@ +[versions] +apacheCommons = "2.8.0" +assertJ = "3.20.2" # Upgrading leads to SAM errors: https://youtrack.jetbrains.com/issue/KT-17765 +# match with /settings.gradle.kts +awsSdk = "2.20.111" +commonmark = "0.17.1" +detekt = "1.23.0" +intellijGradle = "1.13.2" +intellijRemoteRobot = "0.11.18" +jackson = "2.15.1" +jacoco = "0.8.11" +jgit = "6.5.0.202303070854-r" +junit4 = "4.13.2" +junit5 = "5.10.1" +# https://plugins.jetbrains.com/docs/intellij/kotlin.html#adding-kotlin-support +# https://kotlinlang.org/docs/releases.html#release-details +kotlin = "1.9.21" +# set in /settings.gradle.kts +kotlinCoroutines = "1.7.3" +mockito = "4.6.1" +mockitoKotlin = "4.0.0" +mockk = "1.13.8" +node-gradle = "7.0.1" +telemetryGenerator = "1.0.176" +testLogger = "3.1.0" +testRetry = "1.5.2" +slf4j = "1.7.36" +sshd = "2.11.0" +wiremock = "2.35.0" +zjsonpatch = "0.4.11" + +[libraries] +assertj = { module = "org.assertj:assertj-core", version.ref = "assertJ" } +aws-apacheClient = { module = "software.amazon.awssdk:apache-client", version.ref = "awsSdk" } +aws-apprunner = { module = "software.amazon.awssdk:apprunner", version.ref = "awsSdk" } +aws-bom = { module = "software.amazon.awssdk:bom", version.ref = "awsSdk" } +aws-cloudcontrol = { module = "software.amazon.awssdk:cloudcontrol", version.ref = "awsSdk" } +aws-cloudformation = { module = "software.amazon.awssdk:cloudformation", version.ref = "awsSdk" } +aws-cloudwatchlogs = { module = "software.amazon.awssdk:cloudwatchlogs", version.ref = "awsSdk" } +aws-codecatalyst = { module = "software.amazon.awssdk:codecatalyst", version.ref = "awsSdk" } +aws-codeGen = { module = "software.amazon.awssdk:codegen", version.ref = "awsSdk" } +aws-cognitoidentity = { module = "software.amazon.awssdk:cognitoidentity", version.ref = "awsSdk" } +aws-crt = { module = "software.amazon.awssdk:aws-crt-client", version.ref = "awsSdk" } +aws-dynamodb = { module = "software.amazon.awssdk:dynamodb", version.ref = "awsSdk" } +aws-ec2 = { module = "software.amazon.awssdk:ec2", version.ref = "awsSdk" } +aws-ecr = { module = "software.amazon.awssdk:ecr", version.ref = "awsSdk" } +aws-ecs = { module = "software.amazon.awssdk:ecs", version.ref = "awsSdk" } +aws-iam = { module = "software.amazon.awssdk:iam", version.ref = "awsSdk" } +aws-jsonProtocol = { module = "software.amazon.awssdk:aws-json-protocol", version.ref = "awsSdk" } +aws-lambda = { module = "software.amazon.awssdk:lambda", version.ref = "awsSdk" } +aws-queryProtocol = { module = "software.amazon.awssdk:aws-query-protocol", version.ref = "awsSdk" } +aws-rds = { module = "software.amazon.awssdk:rds", version.ref = "awsSdk" } +aws-redshift = { module = "software.amazon.awssdk:redshift", version.ref = "awsSdk" } +aws-s3 = { module = "software.amazon.awssdk:s3", version.ref = "awsSdk" } +aws-schemas = { module = "software.amazon.awssdk:schemas", version.ref = "awsSdk" } +aws-secretsmanager = { module = "software.amazon.awssdk:secretsmanager", version.ref = "awsSdk" } +aws-services = { module = "software.amazon.awssdk:services", version.ref = "awsSdk" } +aws-sns = { module = "software.amazon.awssdk:sns", version.ref = "awsSdk" } +aws-sqs = { module = "software.amazon.awssdk:sqs", version.ref = "awsSdk" } +aws-sso = { module = "software.amazon.awssdk:sso", version.ref = "awsSdk" } +aws-ssooidc = { module = "software.amazon.awssdk:ssooidc", version.ref = "awsSdk" } +aws-sts = { module = "software.amazon.awssdk:sts", version.ref = "awsSdk" } +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } +commons-io = { module = "commons-io:commons-io", version.ref = "apacheCommons" } +detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } +detekt-formattingRules = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } +detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } +gradlePlugin-detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } +gradlePlugin-intellij = { module = "org.jetbrains.intellij:org.jetbrains.intellij.gradle.plugin", version.ref = "intellijGradle" } +gradlePlugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +gradlePlugin-testLogger = { module = "com.adarshr:gradle-test-logger-plugin", version.ref = "testLogger" } +gradlePlugin-testRetry = { module = "org.gradle:test-retry-gradle-plugin", version.ref = "testRetry" } +intellijRemoteFixtures = { module = "com.intellij.remoterobot:remote-fixtures", version.ref = "intellijRemoteRobot" } +intellijRemoteRobot = { module = "com.intellij.remoterobot:remote-robot", version.ref = "intellijRemoteRobot" } +jackson-datetime = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } +jackson-xml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-xml", version.ref = "jackson" } +jackson-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } +jacoco = { module = "org.jacoco:org.jacoco.core", version.ref = "jacoco" } +jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" } +junit4 = { module = "junit:junit", version.ref = "junit4" } +junit5-bom = { module = "org.junit:junit-bom", version.ref = "junit5" } +junit5-jupiterApi = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } +junit5-jupiterEngine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } +junit5-jupiterVintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } +kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } +kotlin-coroutinesDebug = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-debug", version.ref = "kotlinCoroutines" } +kotlin-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinCoroutines" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +kotlin-stdLibJdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } +mockk = { module = "io.mockk:mockk", version.ref="mockk" } +telemetryGenerator = { module = "software.aws.toolkits:telemetry-generator", version.ref = "telemetryGenerator" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +sshd-core = { module = "org.apache.sshd:sshd-core", version.ref = "sshd" } +sshd-scp = { module = "org.apache.sshd:sshd-scp", version.ref = "sshd" } +sshd-sftp = { module = "org.apache.sshd:sshd-sftp", version.ref = "sshd" } +wiremock = { module = "com.github.tomakehurst:wiremock-jre8", version.ref = "wiremock" } +zjsonpatch = { module = "com.flipkart.zjsonpatch:zjsonpatch", version.ref = "zjsonpatch" } + +[bundles] +jackson = ["jackson-datetime", "jackson-kotlin", "jackson-yaml", "jackson-xml"] +kotlin = ["kotlin-stdLibJdk8", "kotlin-reflect"] +mockito = ["mockito-core", "mockito-kotlin"] +sshd = ["sshd-core", "sshd-scp", "sshd-sftp"] + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +node-gradle = { id = "com.github.node-gradle.node", version.ref = "node-gradle" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f3d88b1c2f..ccebba7710 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 186b71557c..bdc9a83b1e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 2fe81a7d95..79a61d421c 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,78 +17,113 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -97,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -105,79 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 24467a141f..6689b85bee 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,10 +25,14 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @@ -37,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -51,7 +55,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -61,38 +65,26 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/intellij/build.gradle.kts b/intellij/build.gradle.kts new file mode 100644 index 0000000000..255b557277 --- /dev/null +++ b/intellij/build.gradle.kts @@ -0,0 +1,86 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import software.aws.toolkits.gradle.intellij.IdeVersions + +plugins { + id("org.jetbrains.intellij") + id("toolkit-testing") // Needed so the coverage configurations are present + id("toolkit-detekt") +} + +val ideProfile = IdeVersions.ideProfile(project) + +val toolkitVersion: String by project +val publishToken: String by project +val publishChannel: String by project + +// please check changelog generation logic if this format is changed +// also sync with gateway version +version = "$toolkitVersion-${ideProfile.shortName}" + +val resharperDlls = configurations.create("resharperDlls") { + isCanBeConsumed = false +} + +val gatewayResources = configurations.create("gatewayResources") { + isCanBeConsumed = false +} + +intellij { + pluginName.set("aws-toolkit-jetbrains") + + version.set(ideProfile.community.version()) + localPath.set(ideProfile.community.localPath()) + + updateSinceUntilBuild.set(false) + instrumentCode.set(false) +} + +tasks.prepareSandbox { + from(resharperDlls) { + into("aws-toolkit-jetbrains/dotnet") + } + from(gatewayResources) { + into("aws-toolkit-jetbrains/gateway-resources") + } +} + +tasks.publishPlugin { + token.set(publishToken) + channels.set(publishChannel.split(",").map { it.trim() }) +} + +tasks.check { + dependsOn(tasks.verifyPlugin) +} + +// We have no source in this project, so skip test task +tasks.test { + enabled = false +} + +dependencies { + implementation(project(":jetbrains-core", "instrumentedJar")) + implementation(project(":jetbrains-ultimate", "instrumentedJar")) + project.findProject(":jetbrains-gateway")?.let { + // does this need to be the instrumented variant? + implementation(it) + gatewayResources(project(":jetbrains-gateway", configuration = "gatewayResources")) + } + project.findProject(":jetbrains-rider")?.let { + // does this need to be the instrumented variant? + implementation(it) + resharperDlls(project(":jetbrains-rider", configuration = "resharperDlls")) + } +} + +configurations { + // Make sure we exclude stuff we either A) ships with IDE, B) we don't use to cut down on size + runtimeClasspath { + exclude(group = "org.slf4j") + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") + exclude(group = "software.amazon.awssdk", module = "netty-nio-client") + } +} diff --git a/intellijJVersions.gradle b/intellijJVersions.gradle deleted file mode 100644 index 92e1558420..0000000000 --- a/intellijJVersions.gradle +++ /dev/null @@ -1,207 +0,0 @@ -static def ideProfiles() { - return [ - "2019.3": [ - "sinceVersion": "193", - "untilVersion": "193.*", - "products" : [ - "IC": [ - sdkVersion: "IC-2019.3", - plugins : [ - "org.jetbrains.plugins.terminal", - "org.jetbrains.plugins.yaml", - "PythonCore:193.5233.139", - "java", - "com.intellij.gradle", - "org.jetbrains.idea.maven", - "Docker:193.5233.140" - ] - ], - "IU": [ - sdkVersion: "IU-2019.3", - plugins : [ - "org.jetbrains.plugins.terminal", - "Pythonid:193.5233.109", - "org.jetbrains.plugins.yaml", - "JavaScript", - "JavaScriptDebugger", - ] - ], - "RD": [ - sdkVersion : "RD-2019.3.4", - rdGenVersion: "0.193.146", - nugetVersion: "2019.3.4", - plugins : [ - "org.jetbrains.plugins.yaml" - ] - ] - ] - ], - "2020.1": [ - "sinceVersion": "201", - "untilVersion": "201.*", - "products" : [ - "IC": [ - sdkVersion: "IC-2020.1", - plugins : [ - "org.jetbrains.plugins.terminal", - "org.jetbrains.plugins.yaml", - "PythonCore:201.6668.31", - "java", - "com.intellij.gradle", - "org.jetbrains.idea.maven", - "Docker:201.6668.30" - ] - ], - "IU": [ - sdkVersion: "IU-2020.1", - plugins : [ - "org.jetbrains.plugins.terminal", - "Pythonid:201.6668.31", - "org.jetbrains.plugins.yaml", - "JavaScript", - "JavaScriptDebugger", - "com.intellij.database", - ] - ], - "RD": [ - sdkVersion : "RD-2020.1.0", - rdGenVersion: "0.201.69", - 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" - ] - ] - ] - ] - ] -} - -def idePlugins(String productCode) { - return ideProduct(productCode).plugins -} - -def ideSdkVersion(String productCode) { - return ideProduct(productCode).sdkVersion -} - -private def ideProduct(String productCode) { - def product = ideProfile()["products"][productCode] - if (product == null) { - throw new IllegalArgumentException("Unknown IDE product `$productCode` for ${resolveIdeProfileName()}") - } - return product -} - -def ideSinceVersion() { - def guiVersion = ideProfile()["sinceVersion"] - if (guiVersion == null) { - throw new IllegalArgumentException("Missing 'sinceVersion' key for ${resolveIdeProfileName()}") - } - return guiVersion -} - -def ideUntilVersion() { - def guiVersion = ideProfile()["untilVersion"] - if (guiVersion == null) { - throw new IllegalArgumentException("Missing 'untilVersion' 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 - if (rdGen == null) { - throw new IllegalArgumentException("Missing 'rdGenVersion' in 'RD' product for ${resolveIdeProfileName()}") - } - return rdGen -} - -// https://www.nuget.org/packages/JetBrains.Rider.SDK/ -def riderNugetSdkVersion() { - def rdGen = ideProduct("RD").nugetVersion - if (rdGen == null) { - throw new IllegalArgumentException("Missing 'nugetVersion' in 'RD' product for ${resolveIdeProfileName()}") - } - return rdGen -} - -def ideProfile() { - def profileName = resolveIdeProfileName() - def profile = ideProfiles()[profileName] - if (profile == null) { - throw new IllegalArgumentException("Unknown ideProfile `$profileName`") - } - - return profile -} - -def resolveIdeProfileName() { - if (System.env.ALTERNATIVE_IDE_PROFILE_NAME) { - return System.env.ALTERNATIVE_IDE_PROFILE_NAME - } - - return project.ideProfileName -} - - -static def shortenVersion(String ver) { - try { - def result = ver =~ /^\d\d(\d{2})[\\.](\d)/ - if (result) { - return result.group(1) + result.group(2) - } - } catch (Exception ignored) { - } - return ver -} - -ext { - ideProfiles = this.&ideProfiles - idePlugins = this.&idePlugins - ideSdkVersion = this.&ideSdkVersion - ideSinceVersion = this.&ideSinceVersion - ideUntilVersion = this.&ideUntilVersion - ideProfile = this.&ideProfile - rdGenVersion = this.&rdGenVersion - riderNugetSdkVersion = this.&riderNugetSdkVersion - resolveIdeProfileName = this.&resolveIdeProfileName - shortenVersion = this.&shortenVersion -} diff --git a/jetbrains-core/build.gradle.kts b/jetbrains-core/build.gradle.kts index aa9998b3a9..5b063f9271 100644 --- a/jetbrains-core/build.gradle.kts +++ b/jetbrains-core/build.gradle.kts @@ -1,72 +1,48 @@ // 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 io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask +import software.aws.toolkits.gradle.buildMetadata +import software.aws.toolkits.gradle.changelog.tasks.GeneratePluginChangeLog +import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions +import software.aws.toolkits.gradle.isCi 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 + +val toolkitVersion: String by project +val ideProfile = IdeVersions.ideProfile(project) plugins { - id("org.jetbrains.intellij") + id("toolkit-kotlin-conventions") + id("toolkit-testing") + id("toolkit-integration-testing") + id("toolkit-intellij-subplugin") } -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") + classpath(libs.telemetryGenerator) } } -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 +intellijToolkit { + ideFlavor.set(IdeFlavor.IC) } -patchPluginXml.setSinceBuild(ideSinceVersion()) -patchPluginXml.setUntilBuild(ideUntilVersion()) - -configurations { - testArtifacts +sourceSets { + main { + java.srcDir("${project.buildDir}/generated-src") + } } val generateTelemetry = tasks.register("generateTelemetry") { - inputFiles = listOf() + inputFiles = listOf(file("${project.projectDir}/resources/telemetryOverride.json")) 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") +tasks.compileKotlin { + dependsOn(generateTelemetry) } val changelog = tasks.register("pluginChangeLog") { @@ -76,32 +52,109 @@ val changelog = tasks.register("pluginChangeLog") { tasks.jar { dependsOn(changelog) - archiveBaseName.set("aws-intellij-toolkit-core") - from(changelog.get().changeLogFile) { + from(changelog) { into("META-INF") } } +val gatewayPluginXml = tasks.create("patchPluginXmlForGateway") { + pluginXmlFiles.set(tasks.patchPluginXml.map { it.pluginXmlFiles }.get()) + destinationDir.set(project.buildDir.resolve("patchedPluginXmlFilesGW")) + + val buildSuffix = if (!project.isCi()) "+${buildMetadata()}" else "" + version.set("GW-$toolkitVersion-${ideProfile.shortName}$buildSuffix") +} + +val gatewayArtifacts by configurations.creating { + isCanBeConsumed = true + isCanBeResolved = false + // share same dependencies as default configuration + extendsFrom(configurations["implementation"], configurations["runtimeOnly"]) +} + +val gatewayJar = tasks.create("gatewayJar") { + dependsOn(tasks.instrumentedJar) + + archiveBaseName.set("aws-toolkit-jetbrains-IC-GW") + from(tasks.instrumentedJar.get().outputs.files.map { zipTree(it) }) { + exclude("**/plugin.xml") + exclude("**/plugin-intellij.xml") + exclude("**/inactive") + } + + from(gatewayPluginXml) { + into("META-INF") + } + + val pluginGateway = sourceSets.main.get().resources.first { it.name == "plugin-gateway.xml" } + from(pluginGateway) { + into("META-INF") + } +} + +artifacts { + add("gatewayArtifacts", gatewayJar) +} + +tasks.prepareSandbox { + // you probably do not want to modify this. + // this affects the IDE sandbox / build for `:jetbrains-core`, but will not propogate to the build generated by `:intellij` + // (which is what is ultimately published to the marketplace) + // without additional effort +} + +tasks.testJar { + // classpath.index is a duplicated + duplicatesStrategy = DuplicatesStrategy.INCLUDE +} + +tasks.processTestResources { + // TODO how can we remove this. Fails due to: + // "customerUploadedEventSchemaMultipleTypes.json.txt is a duplicate but no duplicate handling strategy has been set" + duplicatesStrategy = DuplicatesStrategy.INCLUDE +} + 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") + api(libs.aws.apacheClient) + api(libs.aws.apprunner) + api(libs.aws.cloudcontrol) + api(libs.aws.cloudformation) + api(libs.aws.cloudwatchlogs) + api(libs.aws.codecatalyst) + api(libs.aws.dynamodb) + api(libs.aws.ec2) + api(libs.aws.ecr) + api(libs.aws.ecs) + api(libs.aws.iam) + api(libs.aws.lambda) + api(libs.aws.rds) + api(libs.aws.redshift) + api(libs.aws.s3) + api(libs.aws.schemas) + api(libs.aws.secretsmanager) + api(libs.aws.sns) + api(libs.aws.sqs) + api(libs.aws.services) + + implementation(project(":mynah-ui")) + implementation(libs.aws.crt) + implementation(libs.bundles.jackson) + implementation(libs.zjsonpatch) + implementation(libs.commonmark) 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") + testImplementation(libs.mockk) + testImplementation(libs.kotlin.coroutinesTest) + testImplementation(libs.kotlin.coroutinesDebug) + testImplementation(libs.wiremock) +} + +// fix implicit dependency on generated source +tasks.withType { + dependsOn(generateTelemetry) +} - integrationTestImplementation("org.eclipse.jetty:jetty-servlet:9.4.15.v20190215") - integrationTestImplementation("org.eclipse.jetty:jetty-proxy:9.4.15.v20190215") +tasks.withType { + dependsOn(generateTelemetry) } diff --git a/jetbrains-core/detekt-baseline-integrationTest.xml b/jetbrains-core/detekt-baseline-integrationTest.xml new file mode 100644 index 0000000000..6476cce61e --- /dev/null +++ b/jetbrains-core/detekt-baseline-integrationTest.xml @@ -0,0 +1,21 @@ + + + + + IgnoredReturnValue:CodeWhispererCompletionIntegrationTest.kt$CodeWhispererCompletionIntegrationTest$generateCompletionsPaginator(any()) + UnsafeCallOnNullableType:CreateFunctionIntegrationTest.kt$CreateFunctionIntegrationTest$AwsRegionProvider.getInstance()[Region.US_WEST_2.id()]!! + UnsafeCallOnNullableType:EcrPullIntegrationTest.kt$EcrPullIntegrationTest$dockerAdapter.buildLocalImage(dockerfile)!! + UnsafeCallOnNullableType:EcrPullIntegrationTest.kt$EcrPullIntegrationTest$ecrRule.createRepository().toToolkitEcrRepository()!! + UnsafeCallOnNullableType:EcrPushIntegrationTest.kt$EcrPushIntegrationTest$dockerAdapter.buildLocalImage(dockerfile)!! + UnsafeCallOnNullableType:EcrPushIntegrationTest.kt$EcrPushIntegrationTest$ecrRule.createRepository().toToolkitEcrRepository()!! + UnsafeCallOnNullableType:InteractiveBearerTokenProviderIntegrationTest.kt$InteractiveBearerTokenProviderIntegrationTest$initialToken!! + UnsafeCallOnNullableType:JavaAwsConnectionExtensionIntegrationTest.kt$JavaAwsConnectionExtensionIntegrationTest$CompilerProjectExtension.getInstance(project)!! + UnsafeCallOnNullableType:JavaAwsConnectionExtensionIntegrationTest.kt$JavaAwsConnectionExtensionIntegrationTest$LocalFileSystem.getInstance().refreshAndFindFileByPath(jdkHome)!! + UnsafeCallOnNullableType:JavaAwsConnectionExtensionIntegrationTest.kt$JavaAwsConnectionExtensionIntegrationTest$SdkConfigurationUtil.setupSdk(emptyArray(), jdkHomeDir, JavaSdk.getInstance(), false, null, jdkName)!! + UnsafeCallOnNullableType:JavaLocalLambdaRunConfigurationIntegrationTest.kt$JavaLocalLambdaRunConfigurationIntegrationTest$projectRule.fixture.tempDirFixture.createFile("tmp", "\"Hello World\"").canonicalPath!! + UnsafeCallOnNullableType:PythonLocalLambdaRunConfigurationIntegrationTest.kt$PythonLocalLambdaRunConfigurationIntegrationTest$FileDocumentManager.getInstance().getDocument(lambdaClass.virtualFile)!! + UnsafeCallOnNullableType:PythonLocalLambdaRunConfigurationIntegrationTest.kt$PythonLocalLambdaRunConfigurationIntegrationTest$LambdaRuntime.fromValue(runtime)!! + UnsafeCallOnNullableType:PythonLocalLambdaRunConfigurationIntegrationTest.kt$PythonLocalLambdaRunConfigurationIntegrationTest$projectRule.fixture.tempDirFixture.createFile("tmp", "Hello World").canonicalPath!! + UseOrEmpty:CodeWhispererCodeScanIntegrationTest.kt$CodeWhispererCodeScanIntegrationTest$file.virtualFile.extension ?: "" + + diff --git a/jetbrains-core/detekt-baseline-main.xml b/jetbrains-core/detekt-baseline-main.xml new file mode 100644 index 0000000000..35adf33da8 --- /dev/null +++ b/jetbrains-core/detekt-baseline-main.xml @@ -0,0 +1,262 @@ + + + + + BannedImports:CawsCloneDialogComponent.kt$import com.intellij.ui.layout.panel + BannedImports:CreateEcrRepoDialog.kt$import com.intellij.ui.layout.panel + BannedImports:CreateIamServiceRoleDialog.kt$import com.intellij.ui.layout.panel + BannedImports:CreationPanel.kt$import com.intellij.ui.layout.panel + BannedImports:DeleteResourceDialog.kt$import com.intellij.ui.layout.panel + BannedImports:DeployServerlessApplicationDialog.kt$import com.intellij.ui.layout.panel + BannedImports:DynamicResourcesConfigurable.kt$import com.intellij.ui.layout.panel + BannedImports:EnableDisableExecuteCommandWarning.kt$import com.intellij.ui.layout.panel + BannedImports:ExperimentConfigurable.kt$import com.intellij.ui.layout.panel + BannedImports:LambdaSettingsConfigurable.kt$import com.intellij.ui.layout.panel + BannedImports:OpenShellInContainerDialog.kt$import com.intellij.ui.layout.panel + BannedImports:PauseServiceAction.kt$import com.intellij.ui.layout.panel + BannedImports:PullFromRepositoryAction.kt$import com.intellij.ui.layout.panel + BannedImports:PushToRepositoryAction.kt$import com.intellij.ui.layout.panel + BannedImports:ResumeServiceAction.kt$import com.intellij.ui.layout.panel + BannedImports:RunCommandDialog.kt$import com.intellij.ui.layout.panel + BannedImports:SamInitSelectionPanel.kt$import com.intellij.ui.layout.panel + BannedImports:SchemaSelectionPanel.kt$import com.intellij.ui.layout.panel + BannedImports:SdkSelectionPanel.kt$import com.intellij.ui.layout.panel + BannedImports:SearchPanel.kt$import com.intellij.ui.layout.panel + BannedImports:SonoLoginOverlay.kt$import com.intellij.ui.layout.panel + BannedImports:TaskRoleNotFoundWarningDialog.kt$import com.intellij.ui.layout.panel + BannedImports:ToolConfigurable.kt$import com.intellij.ui.layout.panel + BannedImports:UploadFunctionContinueDialog.kt$import com.intellij.ui.layout.panel + BannedImports:ValidatingPanel.kt$import com.intellij.ui.layout.panel + BannedImports:ViewResourceDialog.kt$import com.intellij.ui.layout.panel + CommentWrapping:Attributes.kt$NullAttribute$/*Dynamo always expects the NUL field to contain true */ + CommentWrapping:ConfigureMaxResultsAction.kt$ConfigureMaxResultsAction$/* popup */ + CommentWrapping:CredentialIdentifierSelector.kt$CredentialIdentifierSelector.Companion$/* Guarded by apply check */ + CommentWrapping:ProjectFileBrowseListener.kt$/* infer disposable from UI context */ + CommentWrapping:S3VirtualBucket.kt$S3VirtualBucket$/* Unit tests refuse to open this in an editor if this is true */ + CommentWrapping:SamInitSelectionPanel.kt$SamInitSelectionPanel$/* Only available in PyCharm! */ + CommentWrapping:SamInitSelectionPanel.kt$SamInitSelectionPanel$/* Used in Rider to refresh the validation */ + DestructuringDeclarationWithTooManyEntries:CodeScanSessionConfig.kt$CodeScanSessionConfig$val (includedSourceFiles, payloadSize, totalLines, _) = includeDependencies() + DestructuringDeclarationWithTooManyEntries:CodeWhispererCodeReferenceManager.kt$CodeWhispererCodeReferenceManager$val (_, editor, _, caretPosition) = requestContext + DestructuringDeclarationWithTooManyEntries:CodeWhispererCodeReferenceManager.kt$CodeWhispererCodeReferenceManager.<no name provided>$val (localEditor, highlighter, codeContent, referenceContent) = it + DestructuringDeclarationWithTooManyEntries:CodeWhispererPopupManager.kt$CodeWhispererPopupManager$val (_, _, recommendationContext, popup) = states + DestructuringDeclarationWithTooManyEntries:CodeWhispererTelemetryService.kt$CodeWhispererTelemetryService$val (payloadContext, serviceInvocationContext, codeScanJobId, totalIssues, reason) = codeScanEvent.codeScanResponseContext + DestructuringDeclarationWithTooManyEntries:CodeWhispererTelemetryService.kt$CodeWhispererTelemetryService$val (project, _, triggerTypeInfo, caretPosition) = requestContext + DestructuringDeclarationWithTooManyEntries:CodeWhispererTelemetryService.kt$CodeWhispererTelemetryService$val (requestId, detail, _, isDiscarded) = detailContext + DestructuringDeclarationWithTooManyEntries:JavaCodeScanSessionConfig.kt$JavaCodeScanSessionConfig$val (sourceFiles, srcPayloadSize, totalLines, buildPaths) = includeDependencies() + ExpressionBodySyntax:CawsProjectListRenderer.kt$CawsProjectListRenderer.<no name provided>$return myContext + ExpressionBodySyntax:CodeWhispererTelemetryService.kt$CodeWhispererTelemetryService$return previousUserTriggerDecisionTimestamp?.let { Duration.between(it, Instant.now()).toMillis().toDouble() } + Filename:AwsSettingsPanel.kt$software.aws.toolkits.jetbrains.core.credentials.AwsSettingsPanel.kt + Filename:CawsSpaceProjectInfo.kt$software.aws.toolkits.jetbrains.services.caws.CawsSpaceProjectInfo.kt + Filename:CognitoIdentityProvider.kt$software.aws.toolkits.jetbrains.services.telemetry.CognitoIdentityProvider.kt + Filename:ShowLogsAroundAction.kt$software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.ShowLogsAroundAction.kt + Filename:contexts.kt$software.aws.toolkits.jetbrains.core.coroutines.contexts.kt + Filename:scopes.kt$software.aws.toolkits.jetbrains.core.coroutines.scopes.kt + ForbiddenVoid:DownloadCodeForSchemaDialog.kt$DownloadCodeForSchemaDialog$Void + ForbiddenVoid:SchemaViewer.kt$SchemaPreviewer$Void + ForbiddenVoid:SchemaViewer.kt$SchemaViewer$Void + ImplicitDefaultLocale:CloudFormationTemplate.kt$CloudFormationTemplate.Companion$templateFile.extension?.toLowerCase() + ImplicitDefaultLocale:CodeWhispererColorUtil.kt$CodeWhispererColorUtil$String.format("#%02x%02x%02x", this.red, this.green, this.blue) + ImplicitDefaultLocale:DynamicResourceStateChangedNotificationHandler.kt$DynamicResourceStateChangedNotificationHandler$state.operation.name.toLowerCase() + ImplicitDefaultLocale:DynamicResourcesUpdateManager.kt$DynamicResourceUpdateManager$message("dynamic_resources.editor.submitResourceUpdateRequest_text").toLowerCase() + ImplicitDefaultLocale:DynamicResourcesUpdateManager.kt$DynamicResourceUpdateManager$message("general.delete").toLowerCase() + ImplicitDefaultLocale:DynamicResourcesUpdateManager.kt$DynamicResourceUpdateManager$mutation.operation.name.toLowerCase() + ImplicitDefaultLocale:PathMapper.kt$PathMapper.Companion$localPath.toLowerCase() + ImplicitDefaultLocale:ResourceSelector.kt$ResourceSelector$it.toString().toLowerCase() + ImplicitDefaultLocale:SchemaCodeGenUtils.kt$SchemaCodeGenUtils.CodeGenPackageBuilder$segment.toLowerCase() + LoopWithTooManyJumpStatements:CodeWhispererEditorManager.kt$CodeWhispererEditorManager$while + LoopWithTooManyJumpStatements:DownloadObjectAction.kt$DownloadObjectAction$for + NoNameShadowing:CloudControlApiResources.kt$CloudControlApiResources${ it.typeName() } + NoNameShadowing:CodeStoragePanel.kt$CodeStoragePanel${ it.repositoryName == ecrDialog.repoName } + NoNameShadowing:CodeStoragePanel.kt$CodeStoragePanel${ sourceBucket.reload(forceFetch = true) sourceBucket.selectedItem = it } + NoNameShadowing:CreationPanel.kt$CreationPanel${ cpu = it } + NoNameShadowing:CreationPanel.kt$CreationPanel${ memory = it } + NoNameShadowing:CredentialIdentifierSelector.kt$CredentialIdentifierSelector${ comboBoxModel.add(it) } + NoNameShadowing:CredentialIdentifierSelector.kt$CredentialIdentifierSelector${ it.id == identifierId } + NoNameShadowing:DeployServerlessApplicationDialog.kt$DeployServerlessApplicationDialog${ it.repositoryName == ecrDialog.repoName } + NoNameShadowing:DeployServerlessApplicationDialog.kt$DeployServerlessApplicationDialog${ s3BucketSelector.reload(forceFetch = true) s3BucketSelector.selectedItem = it } + NoNameShadowing:DownloadCodeForSchemaDialog.kt$DownloadCodeForSchemaDialog${ val fileEditorManager = FileEditorManager.getInstance(project) fileEditorManager.openTextEditor(OpenFileDescriptor(project, it), true) } + NoNameShadowing:ExplorerToolWindow.kt$ExplorerToolWindow${ it.lastPathComponent } + NoNameShadowing:ExplorerToolWindow.kt$ExplorerToolWindow${ it.userObject } + NoNameShadowing:Iam.kt$Iam${ it.roleName(role.roleName()) } + NoNameShadowing:Iam.kt$Iam${ it.roleName(roleName) .policyName(roleName) .policyDocument(policy) } + NoNameShadowing:LocalPathProjectBaseCellEditor.kt$LocalPathProjectBaseCellEditor${ LocalFileSystem.getInstance().findFileByPath(it) } + NoNameShadowing:LocalPathProjectBaseCellEditor.kt$LocalPathProjectBaseCellEditor${ StringUtil.isNotEmpty(it) } + NoNameShadowing:RuntimeGroup.kt$RuntimeGroup.Companion${ it.id == id } + NoNameShadowing:SelectSavedQuery.kt$SelectSavedQuery${ logGroups.text = it.logGroupNames().joinToString("\n") queryString.text = it.queryString() // reset to the start, since setting the text moves the cursor to the end, // which results in scrolling to the bottom right corner if there's enough text logGroups.caretPosition = 0 queryString.caretPosition = 0 } + TopLevelPropertyNaming:SqsUtils.kt$const val sqsPolicyStatementArray = "Statement" + UnnecessaryApply:CodeWhispererCodeScanEditorMouseMotionListener.kt$CodeWhispererCodeScanEditorMouseMotionListener$apply { size = preferredSize } + UnnecessaryApply:CreateIamServiceRoleDialog.kt$CreateIamServiceRoleDialog$apply { component.isEditable = false } + UnnecessaryApply:CreateQueuePanel.kt$CreateQueuePanel$apply { border = IdeBorderFactory.createBorder(SideBorder.TOP or SideBorder.BOTTOM or SideBorder.LEFT) } + UnnecessaryApply:CreationPanel.kt$CreationPanel$apply { component.toolTipText = message("apprunner.creation.panel.repository.url.tooltip") } + UnnecessaryApply:ExperimentConfigurable.kt$ExperimentConfigurable$apply { component.icon = AllIcons.General.Warning } + UnnecessaryApply:SendMessagePane.kt$SendMessagePane$apply { border = IdeBorderFactory.createBorder() } + UnnecessaryApply:SendMessagePane.kt$SendMessagePane$apply { emptyText.text = message("sqs.send.message.body.empty.text") } + UnnecessaryApply:SonoLoginOverlay.kt$SonoLoginOverlay$apply { applyToComponent { putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) } } + UnnecessaryFilter:CodeWhispererPopupManager.kt$CodeWhispererPopupManager$filter { isValidRecommendation(it, userInput, typeahead) } + UnsafeCallOnNullableType:CachingAsyncEvaluator.kt$CachingAsyncEvaluator$cachePromise.blockingGet(0)!! + UnsafeCallOnNullableType:CachingAsyncEvaluator.kt$CachingAsyncEvaluator$promise.blockingGet(blockingTime, blockingUnit)!! + UnsafeCallOnNullableType:CreateFunctionDialog.kt$CreateFunctionDialog$view.configSettings.iamRole.selected()!! + UnsafeCallOnNullableType:CredentialIdentifierSelector.kt$CredentialIdentifierSelector.Companion$it!! + UnsafeCallOnNullableType:DockerfileParser.kt$DockerfileParser$PsiManager.getInstance(project).findFile(virtualFile)!! + UnsafeCallOnNullableType:DownloadCodeForSchemaDialog.kt$DownloadCodeForSchemaDialog$view.language.selected()!! + UnsafeCallOnNullableType:DownloadCodeForSchemaDialog.kt$DownloadCodeForSchemaDialog$view.version.selected()!! + UnsafeCallOnNullableType:HandlerCompletionProvider.kt$HandlerCompletionProvider$handlerCompletion!! + UnsafeCallOnNullableType:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$promise.blockingGet(0)!! + UnsafeCallOnNullableType:PullFromRepositoryAction.kt$PullFromRepositoryDialog$imageSelector.selected()!! + UnsafeCallOnNullableType:PullFromRepositoryAction.kt$PullFromRepositoryDialog$repoSelector.selected()!! + UnsafeCallOnNullableType:SchemaResourceSelector.kt$SchemaResourceSelector$awsConnection!! + UnsafeCallOnNullableType:UpdateFunctionConfigDialog.kt$UpdateFunctionConfigDialog$view.configSettings.iamRole.selected()!! + UnusedPrivateProperty:CawsCloneDialogComponent.kt$CawsCloneDialogComponent$private val modalityState: ModalityState + UseCheckOrError:AwsConnectionExtension.kt$AwsConnectionRunConfigurationExtension$throw IllegalStateException(message("aws.notification.credentials_missing")) + UseCheckOrError:AwsConnectionExtension.kt$AwsConnectionRunConfigurationExtension$throw IllegalStateException(message("configure.validate.no_region_specified")) + UseCheckOrError:AwsConnectionManager.kt$throw IllegalStateException("Bug: Attempting to retrieve connection settings with invalid connection state") + UseCheckOrError:AwsConnectionManager.kt$throw IllegalStateException("Connection settings are not configured") + UseCheckOrError:AwsConsoleUrlFactory.kt$AwsConsoleUrlFactory$throw IllegalStateException("Partition '${region.partitionId}' is not supported") + UseCheckOrError:AwsRegionProvider.kt$AwsRegionProvider$throw IllegalStateException("Region provider data is missing default data") + UseCheckOrError:AwsResourceCache.kt$ExecutableBackedCacheResource$throw IllegalStateException((it as ExecutableInstance.BadExecutable).validationError) + UseCheckOrError:CawsParameterDescriptions.kt$throw IllegalStateException("Failed to locate parameterDescriptions.json") + UseCheckOrError:CliBasedStep.kt$CliBasedStep$throw IllegalStateException(message("general.execution.cli_error", exitCode)) + UseCheckOrError:CloudWatchActor.kt$CloudWatchLogsActor$throw IllegalStateException("Table does not support loadInitial") + UseCheckOrError:CloudWatchActor.kt$CloudWatchLogsActor$throw IllegalStateException("Table does not support loadInitialFilter") + UseCheckOrError:CloudWatchActor.kt$CloudWatchLogsActor$throw IllegalStateException("Table does not support loadInitialRange") + UseCheckOrError:CloudWatchLogGroup.kt$CloudWatchLogGroup$throw IllegalStateException(state.shortMessage) + UseCheckOrError:CreateFunctionDialog.kt$CreateFunctionDialog$throw IllegalStateException("Failed to locate module for $element") + UseCheckOrError:CreateFunctionDialog.kt$CreateFunctionDialog$throw IllegalStateException("LambdaBuilder for $runtime not found") + UseCheckOrError:CreateFunctionDialog.kt$CreateFunctionDialog$throw IllegalStateException("Runtime is missing when package type is Zip") + UseCheckOrError:CreationDialog.kt$CreationDialog$throw IllegalStateException("AppRunner creation dialog had no type selected!") + UseCheckOrError:CredentialChoice.kt$CredentialProviderSelector2$throw IllegalStateException("Can't get credential identifier when the selection is an invalid one") + UseCheckOrError:DataContextUtils.kt$throw IllegalStateException("Required dataId '${dataId.name}` was missing") + UseCheckOrError:DefaultToolManager.kt$DefaultToolManager$throw IllegalStateException( message( "executableCommon.latest_not_compatible", type.displayName, it.displayValue() ) ) + UseCheckOrError:DetailedLogRecord.kt$DetailedLogRecord.Companion$throw IllegalStateException("$log format does not appear to be in a valid format (<account-id>:<log-group-name>)") + UseCheckOrError:DownloadLogStream.kt$LogStreamDownloadToFileTask.<no name provided>$throw IllegalStateException("Log Stream was downloaded but does not exist on disk!") + UseCheckOrError:FileInfoCache.kt$FileInfoCache$throw IllegalStateException(message("general.file_not_found", entry)) + UseCheckOrError:FileInfoCache.kt$FileInfoCache$throw IllegalStateException(message("general.file_not_found", path)) + UseCheckOrError:HandlerCompletionProvider.kt$HandlerCompletionProvider$throw IllegalStateException("handlerCompletion must be defined if completion is enabled.") + UseCheckOrError:HandlerPanel.kt$HandlerPanel$throw IllegalStateException("Runtime was not set in the HandlerPanel") + UseCheckOrError:InsightsUtils.kt$throw IllegalStateException("CWL GetQueryResults returned record without @ptr field") + UseCheckOrError:JavaDebugSupport.kt$throw IllegalStateException("Attaching to the JVM failed! $debugHost:${debugPorts.first()}") + UseCheckOrError:JavaLambdaBuilder.kt$JavaLambdaBuilder$throw IllegalStateException(message("lambda.build.java.unsupported_build_system", module.name)) + UseCheckOrError:JavaLambdaBuilder.kt$JavaLambdaBuilder$throw IllegalStateException(message("lambda.build.unable_to_locate_project_root", module)) + UseCheckOrError:LambdaBuilder.kt$LambdaBuilder$throw IllegalStateException("Cannot map runtime $runtime to SDK runtime.") + UseCheckOrError:LambdaBuilder.kt$LambdaBuilder$throw IllegalStateException(message("lambda.build.module_with_no_content_root", module.name)) + UseCheckOrError:LambdaBuilder.kt$LambdaBuilder.Companion$throw IllegalStateException("Failed to locate module for ${psiFile.virtualFile}") + UseCheckOrError:LambdaConfigPanel.kt$LambdaConfigPanel$throw IllegalStateException("Unsupported package type ${packageType()}") + UseCheckOrError:LambdaUtils.kt$throw IllegalStateException("$this has bad minSamDebuggingVersion! It should be a semver string!") + UseCheckOrError:LambdaUtils.kt$throw IllegalStateException("$this has bad minSamInitVersion! It should be a semver string!") + UseCheckOrError:LambdaWorkflows.kt$throw IllegalStateException("Tried to update a lambda without valid AWS connection") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("Cannot map runtime $runtime to SDK runtime.") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("Function ${logicalId()} not found in template!") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("Image functions must be a SAM function") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("No image debugger with ID ${rawImageDebugger()}") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("Unable to get virtual file for path $dockerFilePath") + UseCheckOrError:LocalLambdaRunSettings.kt$HandlerRunSettings$throw IllegalStateException("Attempting to run SAM for unsupported runtime $runtime") + UseCheckOrError:LocalLambdaRunSettings.kt$ImageTemplateRunSettings$throw IllegalStateException("Attempting to run SAM for unsupported language ${imageDebugger.languageId}") + UseCheckOrError:LocalLambdaRunSettings.kt$TemplateRunSettings$throw IllegalStateException("Attempting to run SAM for unsupported runtime $runtime") + UseCheckOrError:LocalLambdaRunSettings.kt$throw IllegalStateException("Can't find debugger support for $this") + UseCheckOrError:OpenShellInContainerDialog.kt$OpenShellInContainerDialog$throw IllegalStateException("Task not Selected") + UseCheckOrError:ProfileCredentialProviderFactory.kt$ProfileCredentialProviderFactory$throw IllegalStateException("Profile $sourceProfileName looks to have been removed") + UseCheckOrError:ProfileCredentialProviderFactory.kt$ProfileCredentialProviderFactory$throw IllegalStateException("Profile ${profileProviderId.profileName} looks to have been removed") + UseCheckOrError:ProfileCredentialProviderFactory.kt$ProfileCredentialProviderFactory$throw IllegalStateException("ProfileCredentialProviderFactory can only handle ProfileCredentialsIdentifier, but got ${providerId::class}") + UseCheckOrError:PushToRepositoryAction.kt$PushToEcrDialog$throw IllegalStateException("image id was null") + UseCheckOrError:PushToRepositoryAction.kt$PushToEcrDialog$throw IllegalStateException("repository uri was null") + UseCheckOrError:PushToRepositoryAction.kt$PushToEcrDialog$throw IllegalStateException("run configuration was null") + UseCheckOrError:PushToRepositoryAction.kt$PushToEcrDialog$throw IllegalStateException() + UseCheckOrError:PythonLambdaBuilder.kt$PythonLambdaBuilder.Companion$throw IllegalStateException("Cannot locate requirements.txt in a parent directory of ${startLocation.path}") + UseCheckOrError:PythonLambdaHandlerResolver.kt$PythonLambdaHandlerResolver$throw IllegalStateException("Failed to locate requirements.txt") + UseCheckOrError:RemoteLambdaRunSettingsEditor.kt$RemoteLambdaRunSettingsEditor$throw IllegalStateException("functionSelector.reload() called before region/credentials set") + UseCheckOrError:Resources.kt$Function$throw IllegalStateException(message("cloudformation.invalid_property", key, type)) + UseCheckOrError:Resources.kt$SamFunction$throw IllegalStateException("Bad packageType somehow returned to code location: ${packageType()}") + UseCheckOrError:Resources.kt$SamFunction$throw IllegalStateException(message("cloudformation.missing_property", key, logicalName)) + UseCheckOrError:RetrieveSavedQueryDialog.kt$RetrieveSavedQueryDialog.<no name provided>$throw IllegalStateException("No query definition was selected") + UseCheckOrError:RunCommandDialog.kt$RunCommandDialog$throw IllegalStateException("Task not Selected") + UseCheckOrError:RuntimeGroup.kt$RuntimeGroup.Companion$throw IllegalStateException("No RuntimeGroup with id '$id' is registered") + UseCheckOrError:RuntimeGroup.kt$RuntimeGroupExtensionPointObject$throw IllegalStateException("Attempted to retrieve feature for unsupported runtime group $runtimeGroup") + UseCheckOrError:S3TreeNode.kt$S3TreeNode$throw IllegalStateException("$key has no parent!") + UseCheckOrError:SamInitSelectionPanel.kt$SamInitSelectionPanel$throw IllegalStateException("SemVer is invalid even with valid SAM executable") + UseCheckOrError:SamProjectWizard.kt$SamAppTemplateBased$throw IllegalStateException("Unknown packaging type: $packagingType") + UseCheckOrError:SamTemplateUtils.kt$SamTemplateUtils$throw IllegalStateException("$codeUri does not follow the format $S3_URI_PREFIX<bucket>/<key>") + UseCheckOrError:SamTemplateUtils.kt$SamTemplateUtils$throw IllegalStateException("$codeUri does not start with $S3_URI_PREFIX") + UseCheckOrError:SamTemplateUtils.kt$SamTemplateUtils$throw IllegalStateException("Unable to parse codeUri $codeUri") + UseCheckOrError:SamTemplateUtils.kt$SamTemplateUtils$throw IllegalStateException(message("cloudformation.invalid_property", "PackageType", type)) + UseCheckOrError:SamVersionCache.kt$SamVersionCache$throw IllegalStateException(message("executableCommon.empty_info", SamCommon.SAM_NAME)) + UseCheckOrError:SamVersionCache.kt$SamVersionCache$throw IllegalStateException(message("executableCommon.unexpected_output", SamCommon.SAM_NAME, output)) + UseCheckOrError:SamVersionCache.kt$SamVersionCache$throw IllegalStateException(message("executableCommon.version_parse_error", SamCommon.SAM_NAME, version)) + UseCheckOrError:SamVersionCache.kt$SamVersionCache$throw IllegalStateException(output) + UseCheckOrError:SchemaCodeDownloader.kt$SchemaCodeDownloader.Companion$throw IllegalStateException("Attempting to use SchemaCodeDownload without valid AWS connection") + UseCheckOrError:SchemaSelectionPanel.kt$SchemaSelectionPanel$throw IllegalStateException("Schemas is not supported by $this") + UseCheckOrError:SingleS3ObjectAction.kt$SingleS3ObjectAction$throw IllegalStateException("SingleActionNode should only have a single node, got $nodes") + UseCheckOrError:SqsWindowFactory.kt$SqsWindowFactory.Companion$throw IllegalStateException("Can't find tool window $TOOL_WINDOW_ID") + UseCheckOrError:SsmPlugin.kt$SsmPlugin$throw IllegalStateException("Failed to extract $displayName\nSTDOUT:${processOutput.stdout}\nSTDERR:${processOutput.stderr}") + UseCheckOrError:SsmPlugin.kt$SsmPlugin$throw IllegalStateException("Failed to find compatible SSM plugin: SystemInfo=${SystemInfo.OS_NAME}, Arch=${SystemInfo.OS_ARCH}") + UseCheckOrError:SsmPlugin.kt$SsmPlugin$throw IllegalStateException("Failed to locate $executableName under $installDir") + UseCheckOrError:SsmPlugin.kt$SsmPlugin$throw IllegalStateException("Unknown extension $extension") + UseCheckOrError:ToolType.kt$BaseToolType$throw IllegalStateException("Failed to determine version of ${SsmPlugin.displayName}") + UseCheckOrError:ToolkitToolWindow.kt$ToolkitToolWindow$throw IllegalStateException("Can't find tool window $toolWindowId") + UseCheckOrError:UpdateFunctionCodeDialog.kt$UpdateFunctionCodeDialog$throw IllegalStateException("Failed to locate module for $element") + UseCheckOrError:UpdateFunctionCodeDialog.kt$UpdateFunctionCodeDialog$throw IllegalStateException("LambdaBuilder for ${initialSettings.runtime} not found") + UseCheckOrError:UpdateFunctionCodeDialog.kt$UpdateFunctionCodeDialog$throw IllegalStateException("Runtime is missing when package type is Zip") + UseCheckOrError:UpdateFunctionCodePanel.kt$UpdateFunctionCodePanel$throw IllegalStateException("Unsupported package type $packageType") + UseCheckOrError:YamlCloudFormationTemplate.kt$YamlCloudFormationTemplate.YamlCloudFormationParameter$throw IllegalStateException(message("cloudformation.missing_property", key, logicalName)) + UseCheckOrError:YamlCloudFormationTemplate.kt$YamlCloudFormationTemplate.YamlGlobal$throw IllegalStateException(message("cloudformation.missing_property", key, logicalName)) + UseCheckOrError:YamlCloudFormationTemplate.kt$YamlCloudFormationTemplate.YamlResource$throw IllegalStateException(message("cloudformation.missing_property", key, logicalName)) + UseOrEmpty:AbstractActions.kt$SingleExplorerNodeActionGroup$e?.selectedNodes<T>()?.takeIf { it.size == 1 }?.first()?.let { getChildren(it, e) }?.toTypedArray() ?: emptyArray() + UseOrEmpty:AbstractActions.kt$this?.getData(ExplorerDataKeys.SELECTED_NODES)?.mapNotNull { it as? T } ?: emptyList() + UseOrEmpty:CodeScanSessionConfig.kt$CodeScanSessionConfig.Companion$file.extension ?: "" + UseOrEmpty:CodeWhispererCodeReferenceComponents.kt$CodeWhispererCodeReferenceComponents$path ?: "" + UseOrEmpty:CodeWhispererCodeScanException.kt$file ?: "" + UseOrEmpty:CodeWhispererCodeScanManager.kt$CodeWhispererCodeScanManager$scanNodesLookup[file]?.mapNotNull { node -> val issue = node.userObject as CodeWhispererCodeScanIssue if (issue.textRange?.overlaps(range) == true) node else null } ?: listOf() + UseOrEmpty:CodeWhispererService.kt$CodeWhispererService$e.requestId() ?: "" + UseOrEmpty:CodeWhispererService.kt$CodeWhispererService$exceptionType?.let { "Exception Type: $it, " } ?: "" + UseOrEmpty:CodeWhispererService.kt$CodeWhispererService$latency?.let { "Latency: $latency, " } ?: "" + UseOrEmpty:ConfigureLambdaDialog.kt$ConfigureLambdaDialog$view.lambdaFunction.selected()?.functionName() ?: "" + UseOrEmpty:CreationPanel.kt$CreationPanel$ecrUri ?: "" + UseOrEmpty:CreationPanel.kt$CreationPanel$startCommand ?: "" + UseOrEmpty:CredentialIdentifierSelector.kt$CredentialIdentifierSelector.<no name provided>$value?.displayName ?: "" + UseOrEmpty:DeployServerlessApplicationDialog.kt$DeployServerlessApplicationDialog$it.defaultValue() ?: "" + UseOrEmpty:DeployServerlessApplicationDialog.kt$DeployServerlessApplicationDialog$stackName ?: "" + UseOrEmpty:DevToolsToolWindow.kt$DevToolsToolWindow$tree.selectionPaths?.let { treePaths -> treePaths.map { it.lastPathComponent } .filterIsInstance<DefaultMutableTreeNode>() .map { it.userObject } .filterIsInstance<T>() .toList() } ?: emptyList<T>() + UseOrEmpty:DownloadCodeForSchemaDialog.kt$DownloadCodeForSchemaDialog$getContentRootOfCurrentFile() ?: "" + UseOrEmpty:DownloadCodeForSchemaDialog.kt$DownloadCodeForSchemaDialog$rootError.message ?: "" + UseOrEmpty:DynamoDbExplorerNodes.kt$DynamoDbTableNode$tryOrNull { nodeProject.getResourceIfPresent(StsResources.ACCOUNT) } ?: "" + UseOrEmpty:EventsTable.kt$EventsTableImpl$e.resourceStatusReason() ?: "" + UseOrEmpty:ExplorerToolWindow.kt$ExplorerToolWindow$awsTree.selectionPaths?.let { it.map { it.lastPathComponent } .filterIsInstance<DefaultMutableTreeNode>() .map { it.userObject } .filterIsInstance<T>() .toList() } ?: emptyList<T>() + UseOrEmpty:InsightsColumnInfo.kt$LogResultColumnRenderer$(value as? String)?.trim() ?: "" + UseOrEmpty:LocalLambdaRunSettingsEditor.kt$LocalLambdaRunSettingsEditor$configuration.handler() ?: "" + UseOrEmpty:LogGroupSelectorTable.kt$LogGroupSelectorTable.Companion.LogGroupNameColumnInfo$value ?: "" + UseOrEmpty:LogStreamEntry.kt$message()?.trim() ?: "" + UseOrEmpty:NotificationUtils.kt$<no name provided>$title ?: "" + UseOrEmpty:PauseServiceAction.kt$PauseServiceAction$e.awsErrorDetails()?.errorMessage() ?: "" + UseOrEmpty:ProfileCredentialProviderFactory.kt$ProfileCredentialProviderFactory$e.message?.let { ": $it" } ?: "" + UseOrEmpty:PythonCodeScanSessionConfig.kt$PythonCodeScanSessionConfig$importMatcher.group(1)?.plus(FILE_SEPARATOR) ?: "" + UseOrEmpty:PythonLambdaHandlerResolver.kt$PythonLambdaHandlerResolver$handler.substringBeforeLast('/', "").nullize(true)?.split("/") ?: emptyList() + UseOrEmpty:ResumeServiceAction.kt$ResumeServiceAction$e.awsErrorDetails()?.errorMessage() ?: "" + UseOrEmpty:S3ObjectAction.kt$S3ObjectAction$dataContext.getData(S3EditorDataKeys.SELECTED_NODES) ?: emptyList() + UseOrEmpty:S3TreeNode.kt$S3TreeDirectoryNode$response .contents() ?.filterNotNull() // filter out the directory root // if the root was a non-delimited prefix, it should not be filtered out ?.filterNot { it.key() == key && (this as? S3TreePrefixedDirectoryNode)?.isDelimited() != true } ?.map { S3TreeObjectNode(this, it.key(), it.size(), it.lastModified()) } ?: emptyList() + UseOrEmpty:S3TreeNode.kt$S3TreeDirectoryNode$response.commonPrefixes()?.map { S3TreeDirectoryNode(bucket, this, it.prefix()) } ?: emptyList() + UseOrEmpty:S3ViewerPanel.kt$S3ViewerPanel$filterComponent.text.nullize(nullizeSpaces = true) ?: "" + UseOrEmpty:SamCommon.kt$SamCommon$projectRootFile.listFiles( FileFilter { it.isFile && it.name.endsWith("yaml") || it.name.endsWith("yml") } )?.toList() ?: emptyList() + UseOrEmpty:SamInitRunner.kt$SamInitRunner$tempDir.listFiles()?.toList() ?: emptyList() + UseOrEmpty:SamTemplateUtils.kt$SamTemplateUtils$MAPPER.convertValue<Map<String, String>?>(globals) ?: emptyMap() + UseOrEmpty:SamTemplateUtils.kt$SamTemplateUtils$MAPPER.convertValue<Map<String, String>?>(variables) ?: emptyMap() + UseOrEmpty:SchemaSearchExecutor.kt$SchemaSearchExecutor$e.message ?: "" + UseOrEmpty:SubscribeSnsDialog.kt$SubscribeSnsDialog$view.topicSelector.selected()?.topicArn() ?: "" + UseOrEmpty:TableResults.kt$TableModel$columns.getKeysByValue(column)?.firstOrNull() ?: "" + UseOrEmpty:TableUtils.kt$LogStreamsStreamColumnRenderer$(value as? String)?.trim() ?: "" + UseOrEmpty:TemplateSettings.kt$TemplateSettings$it.canonicalPath ?: "" + UseOrEmpty:TemplateSettings.kt$TemplateSettings$path ?: "" + UseOrEmpty:ToolConfigurable.kt$ToolConfigurable$settings.getExecutablePath(toolType) ?: "" + UseOrEmpty:ToolkitCredentialProcessProvider.kt$ToolkitCredentialProcessProvider$errorOutput?.let { ": $it" } ?: "" + UseOrEmpty:ToolkitToolWindow.kt$ToolkitToolWindow$it.getUserData(AWS_TOOLKIT_TAB_ID_KEY) ?: "" + UseOrEmpty:UiUtils.kt$WrappingCellRenderer$(value as? String) ?: "" + UseOrEmpty:UpdateFunctionConfigDialog.kt$UpdateFunctionConfigDialog$initialSettings.envVariables ?: emptyMap() + UseOrEmpty:Updater.kt$Updater$eventsAndButtonStates?.first ?: emptyList() + UseOrEmpty:Updater.kt$Updater$stack?.outputs() ?: emptyList() + UseRequire:AwsSettingsConfigurable.kt$AwsSettingsConfigurable$throw IllegalArgumentException("Set file is not an executable") + UseRequire:FourPartVersion.kt$FourPartVersion.Companion$throw IllegalArgumentException("[$version] not in the format of MAJOR.MINOR.PATCH.BUILD") + UseRequire:ProfileUtils.kt$throw IllegalArgumentException(message("credentials.profile.assume_role.duplicate_source", currentProfileName)) + UseRequire:ProfileUtils.kt$throw IllegalArgumentException(message("credentials.profile.assume_role.missing_source", currentProfileName)) + UseRequire:Queue.kt$Queue$throw IllegalArgumentException(message("sqs.url.parse_error")) + UseRequire:SemanticVersion.kt$SemanticVersion.Companion$throw IllegalArgumentException("[$version] not in the format of MAJOR.MINOR.PATCH") + UselessCallOnNotNull:DeleteResourceDialog.kt$DeleteResourceDialog$comment.isNullOrEmpty() + + diff --git a/jetbrains-core/detekt-baseline-test.xml b/jetbrains-core/detekt-baseline-test.xml new file mode 100644 index 0000000000..e504aa89a7 --- /dev/null +++ b/jetbrains-core/detekt-baseline-test.xml @@ -0,0 +1,142 @@ + + + + + DestructuringDeclarationWithTooManyEntries:CodeWhispererJavaCodeScanTest.kt$CodeWhispererJavaCodeScanTest$val (includedSourceFiles, srcPayloadSize, totalLines, buildPaths) = payloadMetadata + DestructuringDeclarationWithTooManyEntries:CodeWhispererPythonCodeScanTest.kt$CodeWhispererPythonCodeScanTest$val (includedSourceFiles, srcPayloadSize, totalLines, buildPaths) = payloadMetadata + DestructuringDeclarationWithTooManyEntries:CodeWhispererStateTest.kt$CodeWhispererStateTest$val (actualProject, actualEditor, actualTriggerTypeInfo, actualCaretPosition, actualFileContextInfo) = actualRequestContext + DestructuringDeclarationWithTooManyEntries:CodeWhispererStateTest.kt$CodeWhispererStateTest$val (actualRequestId, actualRecommendationDetail, _, actualIsDiscarded) = actualDetailContext + ExpressionBodySyntax:CodeWhispererSettingsTest.kt$CodeWhispererSettingsTest.<no name provided>$return myToolWindows[id] + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_fail_autodetectBadSam_andManuallySetToBadSam() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_autodetectBadSam() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_autodetectValidSam() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_changedTelemetry() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_noOp() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_setSamEmpty() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_setValidSam() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test(expected = ConfigurationException::class) fun validate_fail_setBadSam() + FunctionNaming:CloudFormationParametersTest.kt$CloudFormationParametersTest$@Test fun mergeParameters_emptyRemote() + FunctionNaming:CloudFormationParametersTest.kt$CloudFormationParametersTest$@Test fun mergeParameters_emptyTemplate() + FunctionNaming:CloudFormationParametersTest.kt$CloudFormationParametersTest$@Test fun mergeParameters_withOverlap() + FunctionNaming:CloudFormationTemplateCanDeployTest.kt$CloudFormationTemplateCanDeployTest$@Test fun deployable_validatableEnough() + FunctionNaming:CloudFormationTemplateCanDeployTest.kt$CloudFormationTemplateCanDeployTest$@Test fun nonDeployable_emptyFile() + FunctionNaming:CloudFormationTemplateCanDeployTest.kt$CloudFormationTemplateCanDeployTest$@Test fun nonDeployable_incompleteResources() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listFunctions_lambdaFunction() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listFunctions_missingType() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listFunctions_serverlessAndLambdaFunctions() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listFunctions_serverlessFunction() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listResourcesByType_simpleTable() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listResources_fromFile() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listResources_nullType() + FunctionNaming:CreateBucketActionDialogTest.kt$CreateBucketActionDialogTest$@Test fun validateBucketName_emptyBucketName() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun InvalidNullArgs() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun InvalidNullArgs_Element() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun InvalidNullArgs_HandlerResolver() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun NonSamFunction() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun NonSamFunction_Substring() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun SamFunction() + FunctionNaming:CreateOrUpdateCredentialProfilesActionTest.kt$CreateOrUpdateCredentialProfilesActionTest$@Test fun bothFilesOpened_bothFilesExists() + FunctionNaming:CreateOrUpdateCredentialProfilesActionTest.kt$CreateOrUpdateCredentialProfilesActionTest$@Test fun configFileOpened_onlyConfigExists() + FunctionNaming:CreateOrUpdateCredentialProfilesActionTest.kt$CreateOrUpdateCredentialProfilesActionTest$@Test fun confirmConfigFileCreated_bothFilesDoNotExist() + FunctionNaming:CreateOrUpdateCredentialProfilesActionTest.kt$CreateOrUpdateCredentialProfilesActionTest$@Test fun credentialFileOpened_onlyCredentialsExists() + FunctionNaming:DefaultTelemetryPublisherTest.kt$DefaultTelemetryPublisherTest$@Test fun testPublish_withNamespace() + FunctionNaming:DefaultTelemetryPublisherTest.kt$DefaultTelemetryPublisherTest$@Test fun testPublish_withoutNamespace() + FunctionNaming:DeleteWaiterTest.kt$DeleteWaiterTest$@Test fun deleteSuccessful_stackNotExist() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun invalidStackName_Duplicate() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun invalidStackName_InvalidChars() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun invalidStackName_TooLong() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameterAllTypesValid_hasValues() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameterAllTypesValid_noValues() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_numberConstraintsInvalid() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_numberInvalid() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_numberTooBig() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_numberTooSmall() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringConstraintsInvalid() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringFailsRegex() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringRegex() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringTooLong() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringTooShort() + FunctionNaming:DeploySettingsTest.kt$DeploySettingsTest$@Test fun relativeSamPath_null() + FunctionNaming:DeploySettingsTest.kt$DeploySettingsTest$@Test fun relativeSamPath_root() + FunctionNaming:FileInfoCacheTest.kt$FileInfoCacheTest$@Test fun emptyCache_SingleExecutableRequest() + FunctionNaming:FileInfoCacheTest.kt$FileInfoCacheTest$@Test fun multipleThreads_SameSamPath() + FunctionNaming:FileInfoCacheTest.kt$FileInfoCacheTest$@Test fun nonEmptyCache_SingleExecutableRequest() + FunctionNaming:RetrieveSavedQueryDialogTest.kt$RetrieveSavedQueryDialogTest$@Test fun populateParentEditor_noLogGroups() + FunctionNaming:RetrieveSavedQueryDialogTest.kt$RetrieveSavedQueryDialogTest$@Test fun populateParentEditor_withLogGroups() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getCodeUri_multipleUris() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getCodeUri_noUri() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getCodeUri_samAndNotSam() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getCodeUri_singleUri() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getTemplateFromDirectory_singleYaml() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getVersion_Valid_exitNonZero() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getVersion_badPath() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test(expected = java.lang.AssertionError::class) fun getTemplateFromDirectory_multipleYaml() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test(expected = java.lang.AssertionError::class) fun getTemplateFromDirectory_noYaml() + FunctionNaming:SamVersionCacheTest.kt$SamVersionCacheTest$@Test fun errorCode_InvalidOption() + FunctionNaming:SamVersionCacheTest.kt$SamVersionCacheTest$@Test fun errorCode_RandomError() + FunctionNaming:SamVersionCacheTest.kt$SamVersionCacheTest$@Test fun successExecution_EmptyOutput() + FunctionOnlyReturningConstant:CodeWhispererCodeScanTestBase.kt$CodeWhispererCodeScanTestBase$protected fun getFakeRecommendationsOnNonExistentFile() + NoNameShadowing:CopyUrlActionTest.kt$CopyUrlActionTest${ // Actual format is implementation detail below S3VirtualBucket URL("https://s3/${it.getArgument<String>(0)}?version=${it.getArgument<String>(1)}") } + NoNameShadowing:CreateOrUpdateCredentialProfilesActionTest.kt$CreateOrUpdateCredentialProfilesActionTest${ it.getArgument<File>(0).writeText("hello") } + NoNameShadowing:CreateServiceRoleDialogTest.kt$CreateServiceRoleDialogTest${ it.roleName(name).arn("arn") } + NoNameShadowing:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest${ it.message.contains(message("serverless.application.deploy.validation.new.stack.name.invalid")) } + NoNameShadowing:EcsExecUtilsTest.kt$EcsExecUtilsTest${ it.sessionId(sessionId) it.tokenValue(token) it.streamUrl(streamUrl) } + NoNameShadowing:MockAwsConnectionManager.kt${ manager.changeCredentialProviderAndWait(it) } + NoNameShadowing:S3ViewerPanelTest.kt$S3ViewerPanelTest${ it.prefix("folder/") } + NoNameShadowing:S3VirtualBucketTest.kt$S3VirtualBucketTest${ @Suppress("UNCHECKED_CAST") val transformer = it.arguments[1] as ResponseTransformer<GetObjectResponse, GetObjectResponse> val data = "hello".toByteArray() transformer.transform( GetObjectResponse.builder() .eTag("1111") .lastModified(Instant.parse("1995-10-23T10:12:35Z")) .contentLength(data.size.toLong()) .build(), AbortableInputStream.create(data.inputStream()) ) } + NoNameShadowing:SchemaSearchDialogTest.kt$SchemaSearchDialogTest${ it.getArgument<OnSearchResultError>(3).invoke(searchError) latch.countDown() } + NoNameShadowing:SchemaSearchDialogTest.kt$SchemaSearchDialogTest${ it.getArgument<OnSearchResultReturned>(1).invoke(searchResults) latch.countDown() } + NoNameShadowing:SchemaSearchDialogTest.kt$SchemaSearchDialogTest${ it.getArgument<OnSearchResultReturned>(1).invoke(searchResultsPart1) it.getArgument<OnSearchResultError>(2).invoke(searchError) latch.countDown() } + NoNameShadowing:SchemaSearchDialogTest.kt$SchemaSearchDialogTest${ it.getArgument<OnSearchResultReturned>(1).invoke(searchResultsPart1) it.getArgument<OnSearchResultReturned>(1).invoke(searchResultsPart2) latch.countDown() } + NoNameShadowing:SchemaSearchDialogTest.kt$SchemaSearchDialogTest${ it.getArgument<OnSearchResultReturned>(2).invoke(searchResults) latch.countDown() } + TopLevelPropertyNaming:EventsFetcherTest.kt$private const val nonEmptyMessage = "Second call on the same page must not return anything" + TopLevelPropertyNaming:EventsFetcherTest.kt$private const val wrongPageMessage = "Wrong list of available pages" + UnnecessaryApply:ConfigureLambdaDialogTest.kt$ConfigureLambdaDialogTest$apply { configureLambda(TEST_FUNCTION_NAME) } + UnnecessaryApply:EventsFetcherTest.kt$EventsFetcherTest$apply { Assert.assertTrue(nonEmptyMessage, first.isEmpty()) } + UnnecessaryApply:EventsFetcherTest.kt$EventsFetcherTest$apply { expectRange("4096", "3073", first) } + UnnecessaryApply:EventsFetcherTest.kt$EventsFetcherTest$apply { expectRange("4097", "4097", first, expectedSize = 1) } + UnnecessaryApply:SendMessagePaneTest.kt$SendMessagePaneTest$apply { inputText.text = "" } + UnnecessaryApply:SendMessagePaneTest.kt$SendMessagePaneTest$apply { inputText.text = MESSAGE } + UnnecessaryFilter:CodeWhispererTelemetryTest.kt$CodeWhispererTelemetryTest$filter { !it.content().startsWith(typeahead) } + UnnecessaryFilter:CodeWhispererTelemetryTest.kt$CodeWhispererTelemetryTest$filter { it.content().isEmpty() } + UnsafeCallOnNullableType:CodeInsightTestFixtureRule.kt$ClearableLazy$_value!! + UnsafeCallOnNullableType:CodeInsightTestFixtureRule.kt$PsiManager.getInstance(project).findFile(file)!! + UnsafeCallOnNullableType:CodeInsightTestFixtureRule.kt$ref!! + UnsafeCallOnNullableType:CredentialManagerTest.kt$CredentialManagerTest.TestCredentialProviderFactory$credentialsMapping.remove(providerId)!! + UnsafeCallOnNullableType:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$VfsUtil.findFileByIoFile(dir.writeChild("path.yaml", byteArrayOf()).toFile(), true)!! + UnsafeCallOnNullableType:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest.TestParameter$getOptionalScalarProperty(key)!! + UnsafeCallOnNullableType:FileInfoCacheTest.kt$FileInfoCacheTest$infoProvider.evaluate(tempFile.absolutePath).blockingGet(0)!! + UnsafeCallOnNullableType:FileInfoCacheTest.kt$FileInfoCacheTest$pathPromise.blockingGet(0)!! + UnsafeCallOnNullableType:FileInfoCacheTest.kt$FileInfoCacheTest$pathTempFile1Promise.blockingGet(0)!! + UnsafeCallOnNullableType:FileInfoCacheTest.kt$FileInfoCacheTest$pathTempFile2Promise.blockingGet(0)!! + UnsafeCallOnNullableType:FileInfoCacheTest.kt$FileInfoCacheTest$result.blockingGet(0)!! + UnsafeCallOnNullableType:JavaTestUtils.kt$LocalFileSystem.getInstance().refreshAndFindFileByPath(jdkHome)!! + UnsafeCallOnNullableType:JavaTestUtils.kt$SdkConfigurationUtil.setupSdk(emptyArray(), jdkHomeDir, JavaSdk.getInstance(), false, null, jdkName)!! + UnsafeCallOnNullableType:JavaTestUtils.kt$psiFile.classes[0].allMethods[0].body!! + UnsafeCallOnNullableType:LambdaRunLineMarkerContributorTest.kt$LambdaRunLineMarkerContributorTest$it!! + UnsafeCallOnNullableType:LocalLambdaRunConfigurationTest.kt$LocalLambdaRunConfigurationTest$PsiDocumentManager.getInstance(projectRule.project).getDocument(eventFile)!! + UnsafeCallOnNullableType:MockResourceCache.kt$MockResourceCacheInterface$connectionManager.selectedCredentialIdentifier!! + UnsafeCallOnNullableType:MockResourceCache.kt$MockResourceCacheInterface$connectionManager.selectedRegion!! + UnsafeCallOnNullableType:PythonCodeInsightTestFixtureRule.kt$PythonCodeInsightTestFixtureRule$newFixture.tempDirFixture.getFile(".")!! + UnsafeCallOnNullableType:PythonLambdaBuilderTest.kt$PythonLambdaBuilderTest$psiFile.findTopLevelFunction("handle")!! + UnsafeCallOnNullableType:PythonLambdaHandlerResolverTest.kt$PythonLambdaHandlerResolverTest$Runtime.PYTHON3_9.runtimeGroup?.let { LambdaHandlerResolver.getInstanceOrNull(it) }!! + UnsafeCallOnNullableType:PythonLambdaHandlerResolverTest.kt$PythonLambdaHandlerResolverTest$pyElement.identifyingElement!! + UnsafeCallOnNullableType:RemoteLambdaRunConfigurationTest.kt$RemoteLambdaRunConfigurationTest$PsiDocumentManager.getInstance(projectRule.project).getDocument(eventFile)!! + UnsafeCallOnNullableType:RunConfigTestUtils.kt$ExecutorRegistry.getInstance().getExecutorById(executorId)!! + UnsafeCallOnNullableType:RunConfigTestUtils.kt$ProgramRunner.getRunner(executorId, runConfiguration)!! + UnsafeCallOnNullableType:ScopeTest.kt$ScopeTest$ProjectManagerEx.getInstanceEx().openProject(projectFile, options)!! + UnsafeCallOnNullableType:TelemetryServiceTest.kt$TelemetryServiceTest$ProjectManagerEx.getInstanceEx().openProject(projectFile, options)!! + UnsafeCallOnNullableType:ToolkitToolWindowTest.kt$ToolkitToolWindowTest$it!! + UnsafeCallOnNullableType:ToolkitToolWindowTest.kt$ToolkitToolWindowTest$jbToolWindowManager.getToolWindow(sut.toolWindowId)?.contentManager!! + UnsafeCallOnNullableType:WrapLogsActionTest.kt$WrapLogsActionTest$model.columnInfos[1].getRenderer(null)!! + UnsafeCallOnNullableType:YamlCloudFormationTemplateTest.kt$YamlCloudFormationTemplateTest$resource!! + UseCheckOrError:AwsRegionProviderTest.kt$AwsRegionProviderTest$throw IllegalStateException("Bad test data") + UseCheckOrError:JavaTestUtils.kt$throw IllegalStateException("Failed to locate $it") + UseCheckOrError:JavaTestUtils.kt$throw IllegalStateException("Failed to locate gradlew") + UseCheckOrError:MockClientManager.kt$MockClientManager$throw IllegalStateException("No mock registered for $sdkClass") + UseCheckOrError:RunWithRealCredentials.kt$RunWithRealCredentials.<no name provided>$throw IllegalStateException("Can't locate us-west-2") + UseCheckOrError:RunWithRealCredentials.kt$RunWithRealCredentials.<no name provided>$throw IllegalStateException("RunWithRealCredentials requires a default AWS profile!") + UseOrEmpty:EditAttributesDialogTest.kt$EditAttributesDialogTest$testMessageSize?.toString() ?: "" + UseOrEmpty:EditAttributesDialogTest.kt$EditAttributesDialogTest$testRetentionPeriod?.toString() ?: "" + + diff --git a/jetbrains-core/detekt-baseline.xml b/jetbrains-core/detekt-baseline.xml new file mode 100644 index 0000000000..945f39c065 --- /dev/null +++ b/jetbrains-core/detekt-baseline.xml @@ -0,0 +1,218 @@ + + + + + BannedImports:CawsCloneDialogComponent.kt$import com.intellij.ui.layout.panel + BannedImports:CreateEcrRepoDialog.kt$import com.intellij.ui.layout.panel + BannedImports:CreateIamServiceRoleDialog.kt$import com.intellij.ui.layout.panel + BannedImports:CreationPanel.kt$import com.intellij.ui.layout.panel + BannedImports:DeleteResourceDialog.kt$import com.intellij.ui.layout.panel + BannedImports:DeployServerlessApplicationDialog.kt$import com.intellij.ui.layout.panel + BannedImports:DynamicResourcesConfigurable.kt$import com.intellij.ui.layout.panel + BannedImports:EnableDisableExecuteCommandWarning.kt$import com.intellij.ui.layout.panel + BannedImports:ExperimentConfigurable.kt$import com.intellij.ui.layout.panel + BannedImports:LambdaSettingsConfigurable.kt$import com.intellij.ui.layout.panel + BannedImports:OpenShellInContainerDialog.kt$import com.intellij.ui.layout.panel + BannedImports:PauseServiceAction.kt$import com.intellij.ui.layout.panel + BannedImports:PullFromRepositoryAction.kt$import com.intellij.ui.layout.panel + BannedImports:PushToRepositoryAction.kt$import com.intellij.ui.layout.panel + BannedImports:ResumeServiceAction.kt$import com.intellij.ui.layout.panel + BannedImports:RunCommandDialog.kt$import com.intellij.ui.layout.panel + BannedImports:SamInitSelectionPanel.kt$import com.intellij.ui.layout.panel + BannedImports:SchemaSelectionPanel.kt$import com.intellij.ui.layout.panel + BannedImports:SdkSelectionPanel.kt$import com.intellij.ui.layout.panel + BannedImports:SearchPanel.kt$import com.intellij.ui.layout.panel + BannedImports:SonoLoginOverlay.kt$import com.intellij.ui.layout.panel + BannedImports:TaskRoleNotFoundWarningDialog.kt$import com.intellij.ui.layout.panel + BannedImports:ToolConfigurable.kt$import com.intellij.ui.layout.panel + BannedImports:UploadFunctionContinueDialog.kt$import com.intellij.ui.layout.panel + BannedImports:ValidatingPanel.kt$import com.intellij.ui.layout.panel + BannedImports:ViewResourceDialog.kt$import com.intellij.ui.layout.panel + CommentWrapping:Attributes.kt$NullAttribute$/*Dynamo always expects the NUL field to contain true */ + CommentWrapping:ConfigureMaxResultsAction.kt$ConfigureMaxResultsAction$/* popup */ + CommentWrapping:CredentialIdentifierSelector.kt$CredentialIdentifierSelector.Companion$/* Guarded by apply check */ + CommentWrapping:ProjectFileBrowseListener.kt$/* infer disposable from UI context */ + CommentWrapping:S3VirtualBucket.kt$S3VirtualBucket$/* Unit tests refuse to open this in an editor if this is true */ + CommentWrapping:SamInitSelectionPanel.kt$SamInitSelectionPanel$/* Only available in PyCharm! */ + CommentWrapping:SamInitSelectionPanel.kt$SamInitSelectionPanel$/* Used in Rider to refresh the validation */ + DestructuringDeclarationWithTooManyEntries:CodeScanSessionConfig.kt$CodeScanSessionConfig$val (includedSourceFiles, payloadSize, totalLines, _) = includeDependencies() + DestructuringDeclarationWithTooManyEntries:CodeWhispererCodeReferenceManager.kt$CodeWhispererCodeReferenceManager$val (_, editor, _, caretPosition) = requestContext + DestructuringDeclarationWithTooManyEntries:CodeWhispererCodeReferenceManager.kt$CodeWhispererCodeReferenceManager.<no name provided>$val (localEditor, highlighter, codeContent, referenceContent) = it + DestructuringDeclarationWithTooManyEntries:CodeWhispererJavaCodeScanTest.kt$CodeWhispererJavaCodeScanTest$val (includedSourceFiles, srcPayloadSize, totalLines, buildPaths) = payloadMetadata + DestructuringDeclarationWithTooManyEntries:CodeWhispererPopupManager.kt$CodeWhispererPopupManager$val (_, _, recommendationContext, popup) = states + DestructuringDeclarationWithTooManyEntries:CodeWhispererPythonCodeScanTest.kt$CodeWhispererPythonCodeScanTest$val (includedSourceFiles, srcPayloadSize, totalLines, buildPaths) = payloadMetadata + DestructuringDeclarationWithTooManyEntries:CodeWhispererStateTest.kt$CodeWhispererStateTest$val (actualProject, actualEditor, actualTriggerTypeInfo, actualCaretPosition, actualFileContextInfo) = actualRequestContext + DestructuringDeclarationWithTooManyEntries:CodeWhispererStateTest.kt$CodeWhispererStateTest$val (actualRequestId, actualRecommendationDetail, _, actualIsDiscarded) = actualDetailContext + DestructuringDeclarationWithTooManyEntries:CodeWhispererTelemetryService.kt$CodeWhispererTelemetryService$val (payloadContext, serviceInvocationContext, codeScanJobId, totalIssues, reason) = codeScanEvent.codeScanResponseContext + DestructuringDeclarationWithTooManyEntries:CodeWhispererTelemetryService.kt$CodeWhispererTelemetryService$val (project, _, triggerTypeInfo, caretPosition) = requestContext + DestructuringDeclarationWithTooManyEntries:CodeWhispererTelemetryService.kt$CodeWhispererTelemetryService$val (requestId, detail, _, isDiscarded) = detailContext + DestructuringDeclarationWithTooManyEntries:JavaCodeScanSessionConfig.kt$JavaCodeScanSessionConfig$val (sourceFiles, srcPayloadSize, totalLines, buildPaths) = includeDependencies() + ExpressionBodySyntax:CawsProjectListRenderer.kt$CawsProjectListRenderer.<no name provided>$return myContext + ExpressionBodySyntax:CodeWhispererSettingsTest.kt$CodeWhispererSettingsTest.<no name provided>$return myToolWindows[id] + ExpressionBodySyntax:CodeWhispererTelemetryService.kt$CodeWhispererTelemetryService$return previousUserTriggerDecisionTimestamp?.let { Duration.between(it, Instant.now()).toMillis().toDouble() } + Filename:AwsSettingsPanel.kt$software.aws.toolkits.jetbrains.core.credentials.AwsSettingsPanel.kt + Filename:CawsSpaceProjectInfo.kt$software.aws.toolkits.jetbrains.services.caws.CawsSpaceProjectInfo.kt + Filename:CognitoIdentityProvider.kt$software.aws.toolkits.jetbrains.services.telemetry.CognitoIdentityProvider.kt + Filename:ShowLogsAroundAction.kt$software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.ShowLogsAroundAction.kt + Filename:contexts.kt$software.aws.toolkits.jetbrains.core.coroutines.contexts.kt + Filename:scopes.kt$software.aws.toolkits.jetbrains.core.coroutines.scopes.kt + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_fail_autodetectBadSam_andManuallySetToBadSam() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_autodetectBadSam() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_autodetectValidSam() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_changedTelemetry() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_noOp() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_setSamEmpty() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test fun validate_ok_setValidSam() + FunctionNaming:AwsSettingsConfigurableTest.kt$AwsSettingsConfigurableTest$@Test(expected = ConfigurationException::class) fun validate_fail_setBadSam() + FunctionNaming:CloudFormationParametersTest.kt$CloudFormationParametersTest$@Test fun mergeParameters_emptyRemote() + FunctionNaming:CloudFormationParametersTest.kt$CloudFormationParametersTest$@Test fun mergeParameters_emptyTemplate() + FunctionNaming:CloudFormationParametersTest.kt$CloudFormationParametersTest$@Test fun mergeParameters_withOverlap() + FunctionNaming:CloudFormationTemplateCanDeployTest.kt$CloudFormationTemplateCanDeployTest$@Test fun deployable_validatableEnough() + FunctionNaming:CloudFormationTemplateCanDeployTest.kt$CloudFormationTemplateCanDeployTest$@Test fun nonDeployable_emptyFile() + FunctionNaming:CloudFormationTemplateCanDeployTest.kt$CloudFormationTemplateCanDeployTest$@Test fun nonDeployable_incompleteResources() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listFunctions_lambdaFunction() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listFunctions_missingType() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listFunctions_serverlessAndLambdaFunctions() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listFunctions_serverlessFunction() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listResourcesByType_simpleTable() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listResources_fromFile() + FunctionNaming:CloudFormationTemplateIndexTest.kt$CloudFormationTemplateIndexTest$@Test fun listResources_nullType() + FunctionNaming:CreateBucketActionDialogTest.kt$CreateBucketActionDialogTest$@Test fun validateBucketName_emptyBucketName() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun InvalidNullArgs() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun InvalidNullArgs_Element() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun InvalidNullArgs_HandlerResolver() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun NonSamFunction() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun NonSamFunction_Substring() + FunctionNaming:CreateLambdaFunctionActionTest.kt$CreateLambdaFunctionActionTest$@Test fun SamFunction() + FunctionNaming:CreateOrUpdateCredentialProfilesActionTest.kt$CreateOrUpdateCredentialProfilesActionTest$@Test fun bothFilesOpened_bothFilesExists() + FunctionNaming:CreateOrUpdateCredentialProfilesActionTest.kt$CreateOrUpdateCredentialProfilesActionTest$@Test fun configFileOpened_onlyConfigExists() + FunctionNaming:CreateOrUpdateCredentialProfilesActionTest.kt$CreateOrUpdateCredentialProfilesActionTest$@Test fun confirmConfigFileCreated_bothFilesDoNotExist() + FunctionNaming:CreateOrUpdateCredentialProfilesActionTest.kt$CreateOrUpdateCredentialProfilesActionTest$@Test fun credentialFileOpened_onlyCredentialsExists() + FunctionNaming:DefaultTelemetryPublisherTest.kt$DefaultTelemetryPublisherTest$@Test fun testPublish_withNamespace() + FunctionNaming:DefaultTelemetryPublisherTest.kt$DefaultTelemetryPublisherTest$@Test fun testPublish_withoutNamespace() + FunctionNaming:DeleteWaiterTest.kt$DeleteWaiterTest$@Test fun deleteSuccessful_stackNotExist() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun invalidStackName_Duplicate() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun invalidStackName_InvalidChars() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun invalidStackName_TooLong() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameterAllTypesValid_hasValues() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameterAllTypesValid_noValues() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_numberConstraintsInvalid() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_numberInvalid() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_numberTooBig() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_numberTooSmall() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringConstraintsInvalid() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringFailsRegex() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringRegex() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringTooLong() + FunctionNaming:DeploySamApplicationValidatorTest.kt$DeploySamApplicationValidatorTest$@Test fun templateParameter_stringTooShort() + FunctionNaming:DeploySettingsTest.kt$DeploySettingsTest$@Test fun relativeSamPath_null() + FunctionNaming:DeploySettingsTest.kt$DeploySettingsTest$@Test fun relativeSamPath_root() + FunctionNaming:FileInfoCacheTest.kt$FileInfoCacheTest$@Test fun emptyCache_SingleExecutableRequest() + FunctionNaming:FileInfoCacheTest.kt$FileInfoCacheTest$@Test fun multipleThreads_SameSamPath() + FunctionNaming:FileInfoCacheTest.kt$FileInfoCacheTest$@Test fun nonEmptyCache_SingleExecutableRequest() + FunctionNaming:RetrieveSavedQueryDialogTest.kt$RetrieveSavedQueryDialogTest$@Test fun populateParentEditor_noLogGroups() + FunctionNaming:RetrieveSavedQueryDialogTest.kt$RetrieveSavedQueryDialogTest$@Test fun populateParentEditor_withLogGroups() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getCodeUri_multipleUris() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getCodeUri_noUri() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getCodeUri_samAndNotSam() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getCodeUri_singleUri() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getTemplateFromDirectory_singleYaml() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getVersion_Valid_exitNonZero() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test fun getVersion_badPath() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test(expected = java.lang.AssertionError::class) fun getTemplateFromDirectory_multipleYaml() + FunctionNaming:SamCommonTest.kt$SamCommonTest$@Test(expected = java.lang.AssertionError::class) fun getTemplateFromDirectory_noYaml() + FunctionNaming:SamVersionCacheTest.kt$SamVersionCacheTest$@Test fun errorCode_InvalidOption() + FunctionNaming:SamVersionCacheTest.kt$SamVersionCacheTest$@Test fun errorCode_RandomError() + FunctionNaming:SamVersionCacheTest.kt$SamVersionCacheTest$@Test fun successExecution_EmptyOutput() + FunctionOnlyReturningConstant:CodeWhispererCodeScanTestBase.kt$CodeWhispererCodeScanTestBase$protected fun getFakeRecommendationsOnNonExistentFile() + ImplicitDefaultLocale:CodeWhispererColorUtil.kt$CodeWhispererColorUtil$String.format("#%02x%02x%02x", this.red, this.green, this.blue) + LoopWithTooManyJumpStatements:CodeWhispererEditorManager.kt$CodeWhispererEditorManager$while + LoopWithTooManyJumpStatements:DownloadObjectAction.kt$DownloadObjectAction$for + TopLevelPropertyNaming:EventsFetcherTest.kt$private const val nonEmptyMessage = "Second call on the same page must not return anything" + TopLevelPropertyNaming:EventsFetcherTest.kt$private const val wrongPageMessage = "Wrong list of available pages" + TopLevelPropertyNaming:SqsUtils.kt$const val sqsPolicyStatementArray = "Statement" + UnusedPrivateProperty:CawsCloneDialogComponent.kt$CawsCloneDialogComponent$private val modalityState: ModalityState + UnusedPrivateProperty:RaceDefaultToolkitAuthManagerTest.kt$RaceDefaultToolkitAuthManagerTest$val jobs = (1..50).map { // scope.async { ApplicationManager.getApplication().executeOnPooledThread { ToolkitAuthManager.getInstance().getConnection("id") } } + UnusedPrivateProperty:RaceDefaultToolkitAuthManagerTest.kt$RaceDefaultToolkitAuthManagerTest$val scope = projectCoroutineScope(projectRule.project) + UseCheckOrError:AwsConnectionManager.kt$throw IllegalStateException("Bug: Attempting to retrieve connection settings with invalid connection state") + UseCheckOrError:AwsConnectionManager.kt$throw IllegalStateException("Connection settings are not configured") + UseCheckOrError:AwsConsoleUrlFactory.kt$AwsConsoleUrlFactory$throw IllegalStateException("Partition '${region.partitionId}' is not supported") + UseCheckOrError:AwsRegionProvider.kt$AwsRegionProvider$throw IllegalStateException("Region provider data is missing default data") + UseCheckOrError:AwsRegionProviderTest.kt$AwsRegionProviderTest$throw IllegalStateException("Bad test data") + UseCheckOrError:CawsParameterDescriptions.kt$throw IllegalStateException("Failed to locate parameterDescriptions.json") + UseCheckOrError:CloudWatchActor.kt$CloudWatchLogsActor$throw IllegalStateException("Table does not support loadInitial") + UseCheckOrError:CloudWatchActor.kt$CloudWatchLogsActor$throw IllegalStateException("Table does not support loadInitialFilter") + UseCheckOrError:CloudWatchActor.kt$CloudWatchLogsActor$throw IllegalStateException("Table does not support loadInitialRange") + UseCheckOrError:CreateFunctionDialog.kt$CreateFunctionDialog$throw IllegalStateException("Failed to locate module for $element") + UseCheckOrError:CreateFunctionDialog.kt$CreateFunctionDialog$throw IllegalStateException("LambdaBuilder for $runtime not found") + UseCheckOrError:CreateFunctionDialog.kt$CreateFunctionDialog$throw IllegalStateException("Runtime is missing when package type is Zip") + UseCheckOrError:CreationDialog.kt$CreationDialog$throw IllegalStateException("AppRunner creation dialog had no type selected!") + UseCheckOrError:CredentialChoice.kt$CredentialProviderSelector2$throw IllegalStateException("Can't get credential identifier when the selection is an invalid one") + UseCheckOrError:DataContextUtils.kt$throw IllegalStateException("Required dataId '${dataId.name}` was missing") + UseCheckOrError:DetailedLogRecord.kt$DetailedLogRecord.Companion$throw IllegalStateException("$log format does not appear to be in a valid format (<account-id>:<log-group-name>)") + UseCheckOrError:DownloadLogStream.kt$LogStreamDownloadToFileTask.<no name provided>$throw IllegalStateException("Log Stream was downloaded but does not exist on disk!") + UseCheckOrError:HandlerCompletionProvider.kt$HandlerCompletionProvider$throw IllegalStateException("handlerCompletion must be defined if completion is enabled.") + UseCheckOrError:HandlerPanel.kt$HandlerPanel$throw IllegalStateException("Runtime was not set in the HandlerPanel") + UseCheckOrError:InsightsUtils.kt$throw IllegalStateException("CWL GetQueryResults returned record without @ptr field") + UseCheckOrError:JavaDebugSupport.kt$throw IllegalStateException("Attaching to the JVM failed! $debugHost:${debugPorts.first()}") + UseCheckOrError:JavaTestUtils.kt$throw IllegalStateException("Failed to locate $it") + UseCheckOrError:JavaTestUtils.kt$throw IllegalStateException("Failed to locate gradlew") + UseCheckOrError:LambdaBuilder.kt$LambdaBuilder$throw IllegalStateException("Cannot map runtime $runtime to SDK runtime.") + UseCheckOrError:LambdaBuilder.kt$LambdaBuilder.Companion$throw IllegalStateException("Failed to locate module for ${psiFile.virtualFile}") + UseCheckOrError:LambdaConfigPanel.kt$LambdaConfigPanel$throw IllegalStateException("Unsupported package type ${packageType()}") + UseCheckOrError:LambdaUtils.kt$throw IllegalStateException("$this has bad minSamDebuggingVersion! It should be a semver string!") + UseCheckOrError:LambdaUtils.kt$throw IllegalStateException("$this has bad minSamInitVersion! It should be a semver string!") + UseCheckOrError:LambdaWorkflows.kt$throw IllegalStateException("Tried to update a lambda without valid AWS connection") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("Cannot map runtime $runtime to SDK runtime.") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("Function ${logicalId()} not found in template!") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("Image functions must be a SAM function") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("No image debugger with ID ${rawImageDebugger()}") + UseCheckOrError:LocalLambdaRunConfiguration.kt$LocalLambdaRunConfiguration$throw IllegalStateException("Unable to get virtual file for path $dockerFilePath") + UseCheckOrError:LocalLambdaRunSettings.kt$HandlerRunSettings$throw IllegalStateException("Attempting to run SAM for unsupported runtime $runtime") + UseCheckOrError:LocalLambdaRunSettings.kt$ImageTemplateRunSettings$throw IllegalStateException("Attempting to run SAM for unsupported language ${imageDebugger.languageId}") + UseCheckOrError:LocalLambdaRunSettings.kt$TemplateRunSettings$throw IllegalStateException("Attempting to run SAM for unsupported runtime $runtime") + UseCheckOrError:LocalLambdaRunSettings.kt$throw IllegalStateException("Can't find debugger support for $this") + UseCheckOrError:MockClientManager.kt$MockClientManager$throw IllegalStateException("No mock registered for $sdkClass") + UseCheckOrError:OpenShellInContainerDialog.kt$OpenShellInContainerDialog$throw IllegalStateException("Task not Selected") + UseCheckOrError:ProfileCredentialProviderFactory.kt$ProfileCredentialProviderFactory$throw IllegalStateException("Profile $sourceProfileName looks to have been removed") + UseCheckOrError:ProfileCredentialProviderFactory.kt$ProfileCredentialProviderFactory$throw IllegalStateException("Profile ${profileProviderId.profileName} looks to have been removed") + UseCheckOrError:ProfileCredentialProviderFactory.kt$ProfileCredentialProviderFactory$throw IllegalStateException("ProfileCredentialProviderFactory can only handle ProfileCredentialsIdentifier, but got ${providerId::class}") + UseCheckOrError:PushToRepositoryAction.kt$PushToEcrDialog$throw IllegalStateException("image id was null") + UseCheckOrError:PushToRepositoryAction.kt$PushToEcrDialog$throw IllegalStateException("repository uri was null") + UseCheckOrError:PushToRepositoryAction.kt$PushToEcrDialog$throw IllegalStateException("run configuration was null") + UseCheckOrError:PushToRepositoryAction.kt$PushToEcrDialog$throw IllegalStateException() + UseCheckOrError:PythonLambdaBuilder.kt$PythonLambdaBuilder.Companion$throw IllegalStateException("Cannot locate requirements.txt in a parent directory of ${startLocation.path}") + UseCheckOrError:PythonLambdaHandlerResolver.kt$PythonLambdaHandlerResolver$throw IllegalStateException("Failed to locate requirements.txt") + UseCheckOrError:RemoteLambdaRunSettingsEditor.kt$RemoteLambdaRunSettingsEditor$throw IllegalStateException("functionSelector.reload() called before region/credentials set") + UseCheckOrError:Resources.kt$SamFunction$throw IllegalStateException("Bad packageType somehow returned to code location: ${packageType()}") + UseCheckOrError:RetrieveSavedQueryDialog.kt$RetrieveSavedQueryDialog.<no name provided>$throw IllegalStateException("No query definition was selected") + UseCheckOrError:RunCommandDialog.kt$RunCommandDialog$throw IllegalStateException("Task not Selected") + UseCheckOrError:RunWithRealCredentials.kt$RunWithRealCredentials.<no name provided>$throw IllegalStateException("Can't locate us-west-2") + UseCheckOrError:RunWithRealCredentials.kt$RunWithRealCredentials.<no name provided>$throw IllegalStateException("RunWithRealCredentials requires a default AWS profile!") + UseCheckOrError:RuntimeGroup.kt$RuntimeGroup.Companion$throw IllegalStateException("No RuntimeGroup with id '$id' is registered") + UseCheckOrError:RuntimeGroup.kt$RuntimeGroupExtensionPointObject$throw IllegalStateException("Attempted to retrieve feature for unsupported runtime group $runtimeGroup") + UseCheckOrError:S3TreeNode.kt$S3TreeNode$throw IllegalStateException("$key has no parent!") + UseCheckOrError:SamInitSelectionPanel.kt$SamInitSelectionPanel$throw IllegalStateException("SemVer is invalid even with valid SAM executable") + UseCheckOrError:SamProjectWizard.kt$SamAppTemplateBased$throw IllegalStateException("Unknown packaging type: $packagingType") + UseCheckOrError:SamTemplateUtils.kt$SamTemplateUtils$throw IllegalStateException("$codeUri does not follow the format $S3_URI_PREFIX<bucket>/<key>") + UseCheckOrError:SamTemplateUtils.kt$SamTemplateUtils$throw IllegalStateException("$codeUri does not start with $S3_URI_PREFIX") + UseCheckOrError:SamTemplateUtils.kt$SamTemplateUtils$throw IllegalStateException("Unable to parse codeUri $codeUri") + UseCheckOrError:SchemaCodeDownloader.kt$SchemaCodeDownloader.Companion$throw IllegalStateException("Attempting to use SchemaCodeDownload without valid AWS connection") + UseCheckOrError:SchemaSelectionPanel.kt$SchemaSelectionPanel$throw IllegalStateException("Schemas is not supported by $this") + UseCheckOrError:SingleS3ObjectAction.kt$SingleS3ObjectAction$throw IllegalStateException("SingleActionNode should only have a single node, got $nodes") + UseCheckOrError:SqsWindowFactory.kt$SqsWindowFactory.Companion$throw IllegalStateException("Can't find tool window $TOOL_WINDOW_ID") + UseCheckOrError:SsmPlugin.kt$SsmPlugin$throw IllegalStateException("Failed to extract $displayName\nSTDOUT:${processOutput.stdout}\nSTDERR:${processOutput.stderr}") + UseCheckOrError:SsmPlugin.kt$SsmPlugin$throw IllegalStateException("Failed to find compatible SSM plugin: SystemInfo=${SystemInfo.OS_NAME}, Arch=${SystemInfo.OS_ARCH}") + UseCheckOrError:SsmPlugin.kt$SsmPlugin$throw IllegalStateException("Failed to locate $executableName under $installDir") + UseCheckOrError:SsmPlugin.kt$SsmPlugin$throw IllegalStateException("Unknown extension $extension") + UseCheckOrError:ToolType.kt$BaseToolType$throw IllegalStateException("Failed to determine version of ${SsmPlugin.displayName}") + UseCheckOrError:ToolkitToolWindow.kt$ToolkitToolWindow$throw IllegalStateException("Can't find tool window $toolWindowId") + UseCheckOrError:UpdateFunctionCodeDialog.kt$UpdateFunctionCodeDialog$throw IllegalStateException("Failed to locate module for $element") + UseCheckOrError:UpdateFunctionCodeDialog.kt$UpdateFunctionCodeDialog$throw IllegalStateException("LambdaBuilder for ${initialSettings.runtime} not found") + UseCheckOrError:UpdateFunctionCodeDialog.kt$UpdateFunctionCodeDialog$throw IllegalStateException("Runtime is missing when package type is Zip") + UseCheckOrError:UpdateFunctionCodePanel.kt$UpdateFunctionCodePanel$throw IllegalStateException("Unsupported package type $packageType") + UseRequire:AwsSettingsConfigurable.kt$AwsSettingsConfigurable$throw IllegalArgumentException("Set file is not an executable") + UseRequire:FourPartVersion.kt$FourPartVersion.Companion$throw IllegalArgumentException("[$version] not in the format of MAJOR.MINOR.PATCH.BUILD") + UseRequire:SemanticVersion.kt$SemanticVersion.Companion$throw IllegalArgumentException("[$version] not in the format of MAJOR.MINOR.PATCH") + + diff --git a/jetbrains-core/it-resources/cloudDebugTestCluster.yaml b/jetbrains-core/it-resources/cloudDebugTestCluster.yaml deleted file mode 100644 index 308f8e498c..0000000000 --- a/jetbrains-core/it-resources/cloudDebugTestCluster.yaml +++ /dev/null @@ -1,152 +0,0 @@ -AWSTemplateFormatVersion: 2010-09-09 -Description: An ECS cluster for running integration tests for Cloud Debug - -Resources: - Cluster: - Type: AWS::ECS::Cluster - Properties: - ClusterName: 'CloudDebugTestECSCluster' - TaskDefinitionWithJava: - Type: AWS::ECS::TaskDefinition - DependsOn: LogGroup - Properties: - Family: !Join ['', [!Ref Cluster, TaskDefinitionWithJava]] - # awsvpc is required for Fargate - NetworkMode: awsvpc - RequiresCompatibilities: - - FARGATE - Cpu: 1024 - Memory: 2GB - ExecutionRoleArn: !Ref ExecutionRole - TaskRoleArn: !Ref TaskRole - ContainerDefinitions: - - Name: 'ContainerName' - Image: "amazoncorretto" - Command: - - 'tail -f /dev/null' - EntryPoint: - - 'sh' - - '-c' - PortMappings: - - ContainerPort: 80 - LogConfiguration: - LogDriver: awslogs - Options: - awslogs-region: !Ref AWS::Region - awslogs-group: !Ref LogGroup - awslogs-stream-prefix: ecs - TaskDefinitionWithNode: - Type: AWS::ECS::TaskDefinition - DependsOn: LogGroup - Properties: - Family: !Join ['', [!Ref Cluster, TaskDefinitionWithNode]] - # awsvpc is required for Fargate - NetworkMode: awsvpc - RequiresCompatibilities: - - FARGATE - Cpu: 1024 - Memory: 2GB - ExecutionRoleArn: !Ref ExecutionRole - TaskRoleArn: !Ref TaskRole - ContainerDefinitions: - - Name: 'ContainerName' - Image: "node" - Command: - - 'tail -f /dev/null' - EntryPoint: - - 'sh' - - '-c' - PortMappings: - - ContainerPort: 80 - LogConfiguration: - LogDriver: awslogs - Options: - awslogs-region: !Ref AWS::Region - awslogs-group: !Ref LogGroup - awslogs-stream-prefix: ecs - LogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: !Join ['', [/ecs/, !Ref Cluster]] - ExecutionRole: - Type: AWS::IAM::Role - Properties: - RoleName: 'CloudDebugECSTaskExecutionRole' - AssumeRolePolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: ecs-tasks.amazonaws.com - Action: 'sts:AssumeRole' - ManagedPolicyArns: - - 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy' - TaskRole: - Type: AWS::IAM::Role - Properties: - RoleName: 'CloudDebugTaskRole' - AssumeRolePolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: ecs-tasks.amazonaws.com - Action: 'sts:AssumeRole' - ManagedPolicyArns: - - 'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore' - VPC: - Type: AWS::EC2::VPC - Properties: - CidrBlock: 172.31.0.0/16 - EnableDnsSupport: true - EnableDnsHostnames: true - InstanceTenancy: default - InternetGateway: - Type: AWS::EC2::InternetGateway - VPCGatewayAttachment: - Type: AWS::EC2::VPCGatewayAttachment - Properties: - VpcId: !Ref VPC - InternetGatewayId: !Ref InternetGateway - SubnetA: - Type: AWS::EC2::Subnet - Properties: - VpcId: !Ref VPC - CidrBlock: 172.31.0.0/20 - AvailabilityZone: !Select - - 0 - - Fn::GetAZs: !Ref 'AWS::Region' - MapPublicIpOnLaunch: true - RouteTable: - Type: AWS::EC2::RouteTable - Properties: - VpcId: !Ref VPC - InternetRoute: - Type: AWS::EC2::Route - DependsOn: VPCGatewayAttachment - Properties: - DestinationCidrBlock: 0.0.0.0/0 - GatewayId: !Ref InternetGateway - RouteTableId: !Ref RouteTable - SubnetARouteTableAssociation: - Type: AWS::EC2::SubnetRouteTableAssociation - Properties: - RouteTableId: !Ref RouteTable - SubnetId: !Ref SubnetA - SecurityGroup: - Type: AWS::EC2::SecurityGroup - Properties: - GroupName: "Full Internet Group" - GroupDescription: "All traffic IN/OUT." - VpcId: !Ref VPC - SecurityGroupIngress: - - IpProtocol: -1 - CidrIp: 0.0.0.0/0 - SecurityGroupEgress: - - IpProtocol: -1 - CidrIp: 0.0.0.0/0 -Outputs: - SubnetA: - Value: !Ref SubnetA - SecurityGroup: - Value: !Ref SecurityGroup - TaskRole: - Value: !Ref TaskRole diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/core/AwsSdkClientProxyTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/core/AwsSdkClientProxyTest.kt index 712bd1ad77..b552744671 100644 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/core/AwsSdkClientProxyTest.kt +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/core/AwsSdkClientProxyTest.kt @@ -10,10 +10,6 @@ import com.intellij.testFramework.ApplicationRule import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.runInEdtAndWait import com.intellij.util.net.HttpConfigurable -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.spy -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify import org.eclipse.jetty.proxy.ConnectHandler import org.eclipse.jetty.proxy.ProxyServlet import org.eclipse.jetty.server.Server @@ -24,6 +20,10 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3Client @@ -111,7 +111,7 @@ class AwsSdkClientProxyTest { private fun makeAwsCall() { S3Client.builder() .region(Region.US_WEST_2) - .httpClient(AwsSdkClient.getInstance().sdkHttpClient) + .httpClient(AwsSdkClient.getInstance().sharedSdkClient()) .build().use { it.listBuckets() } diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/InteractiveBearerTokenProviderIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/InteractiveBearerTokenProviderIntegrationTest.kt new file mode 100644 index 0000000000..5d8580274c --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/InteractiveBearerTokenProviderIntegrationTest.kt @@ -0,0 +1,83 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sso.bearer + +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.ApplicationExtension +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assumptions.assumeThat +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.io.TempDir +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.credentials.sso.AccessTokenCacheKey +import software.aws.toolkits.jetbrains.core.credentials.sso.DiskCache +import software.aws.toolkits.jetbrains.utils.extensions.SsoLogin +import software.aws.toolkits.jetbrains.utils.extensions.SsoLoginExtension +import java.nio.file.Path +import java.time.Instant + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +@ExtendWith(ApplicationExtension::class, SsoLoginExtension::class) +@SsoLogin("codecatalyst-test-account") +@DisabledIfEnvironmentVariable(named = "IS_PROD", matches = "false") +class InteractiveBearerTokenProviderIntegrationTest { + companion object { + @JvmStatic + @TempDir + private lateinit var diskCachePath: Path + + private val testScopes = listOf("sso:account:access") + private val diskCache by lazy { DiskCache(cacheDir = diskCachePath) } + private val cacheKey = AccessTokenCacheKey(SONO_REGION, SONO_URL, testScopes) + } + + @Test + @Order(1) + fun `test Builder ID login`() { + val initialToken = diskCache.loadAccessToken(cacheKey) + assertThat(initialToken).isNull() + + val sut = InteractiveBearerTokenProvider( + startUrl = SONO_URL, + region = SONO_REGION, + scopes = testScopes, + cache = diskCache, + id = "test" + ) + + sut.reauthenticate() + assertThat(sut.resolveToken()).isNotNull() + + Disposer.dispose(sut) + } + + @Test + @Order(2) + fun `test token refresh`() { + val initialToken = diskCache.loadAccessToken(cacheKey) + assumeThat(initialToken).isNotNull + + diskCache.saveAccessToken(cacheKey, initialToken!!.copy(accessToken = "invalid", expiresAt = Instant.EPOCH)) + val sut = InteractiveBearerTokenProvider( + startUrl = SONO_URL, + region = SONO_REGION, + scopes = testScopes, + cache = diskCache, + id = "test" + ) + + assertThat(sut.resolveToken()).satisfies { + assertThat(it).isNotNull() + assertThat(it).isNotEqualTo(initialToken) + } + + Disposer.dispose(sut) + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/core/execution/JavaAwsConnectionExtensionIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/core/execution/JavaAwsConnectionExtensionIntegrationTest.kt new file mode 100644 index 0000000000..a0ce7176ef --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/core/execution/JavaAwsConnectionExtensionIntegrationTest.kt @@ -0,0 +1,137 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.execution + +import com.intellij.compiler.CompilerTestUtil +import com.intellij.execution.RunManager +import com.intellij.execution.application.ApplicationConfiguration +import com.intellij.execution.application.ApplicationConfigurationType +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.projectRoots.JavaSdk +import com.intellij.openapi.projectRoots.ProjectJdkTable +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.roots.CompilerProjectExtension +import com.intellij.openapi.roots.ModuleRootModificationUtil +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess +import com.intellij.testFramework.IdeaTestUtil +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.runInEdtAndWait +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.core.compileProjectAndWait +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerRule +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule +import software.aws.toolkits.jetbrains.utils.executeRunConfigurationAndWait +import software.aws.toolkits.jetbrains.utils.rules.ExperimentRule +import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.utils.rules.addClass +import software.aws.toolkits.jetbrains.utils.rules.addModule + +class JavaAwsConnectionExtensionIntegrationTest { + + @Before + fun setUp() { + CompilerTestUtil.enableExternalCompiler() + } + + @After + fun tearDown() { + CompilerTestUtil.disableExternalCompiler(projectRule.project) + } + + @Rule + @JvmField + val projectRule = HeavyJavaCodeInsightTestFixtureRule() + + @Rule + @JvmField + val regionProviderRule = MockRegionProviderRule() + + @Rule + @JvmField + val credentialManagerRule = MockCredentialManagerRule() + + @Rule + @JvmField + val experiment = ExperimentRule(JavaAwsConnectionExperiment) + + @Test + fun connectionDetailsAreInjected() { + val fixture = projectRule.fixture + + val module = fixture.addModule("main") + + val psiClass = fixture.addClass( + module, + """ + package com.example; + + public class AnyOldClass { + public static void main(String[] args) { + System.out.println(System.getenv("AWS_REGION")); + } + } + """ + ) + + val mockRegion = regionProviderRule.createAwsRegion() + val mockCredential = credentialManagerRule.createCredentialProvider() + val runManager = RunManager.getInstance(projectRule.project) + val configuration = runManager.createConfiguration("test", ApplicationConfigurationType::class.java) + val runConfiguration = configuration.configuration as ApplicationConfiguration + runConfiguration.putCopyableUserData( + AWS_CONNECTION_RUN_CONFIGURATION_KEY, + AwsCredentialInjectionOptions { + region = mockRegion.id + credential = mockCredential.id + } + ) + + runReadAction { + runConfiguration.setMainClass(psiClass) + } + + compileModule(module) + + assertThat(executeRunConfigurationAndWait(runConfiguration).stdout).isEqualToIgnoringWhitespace(mockRegion.id) + } + + private fun compileModule(module: Module) { + setUpCompiler() + compileProjectAndWait(module.project) + } + + private fun setUpCompiler() { + val project = projectRule.project + val modules = ModuleManager.getInstance(project).modules + + WriteCommandAction.writeCommandAction(project).run { + val compilerExtension = CompilerProjectExtension.getInstance(project)!! + compilerExtension.compilerOutputUrl = projectRule.fixture.tempDirFixture.findOrCreateDir("out").url + val jdkHome = IdeaTestUtil.requireRealJdkHome() + VfsRootAccess.allowRootAccess(projectRule.fixture.testRootDisposable, jdkHome) + val jdkHomeDir = LocalFileSystem.getInstance().refreshAndFindFileByPath(jdkHome)!! + val jdkName = "Real JDK" + val jdk = SdkConfigurationUtil.setupSdk(emptyArray(), jdkHomeDir, JavaSdk.getInstance(), false, null, jdkName)!! + + ProjectJdkTable.getInstance().addJdk(jdk, projectRule.fixture.testRootDisposable) + + for (module in modules) { + ModuleRootModificationUtil.setModuleSdk(module, jdk) + } + } + + runInEdtAndWait { + PlatformTestUtil.saveProject(project) + CompilerTestUtil.saveApplicationSettings() + } + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionExtensionIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionExtensionIntegrationTest.kt new file mode 100644 index 0000000000..de822864ca --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionExtensionIntegrationTest.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.core.execution + +import com.intellij.execution.ExecutorRegistry +import com.intellij.execution.RunManager +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.runWriteActionAndWait +import com.intellij.openapi.projectRoots.ProjectJdkTable +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.use +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess +import com.intellij.testFramework.DisposableRule +import com.intellij.util.SystemProperties +import com.jetbrains.python.run.PythonConfigurationType +import com.jetbrains.python.run.PythonRunConfiguration +import com.jetbrains.python.sdk.PyDetectedSdk +import com.jetbrains.python.sdk.detectSystemWideSdks +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerRule +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule +import software.aws.toolkits.jetbrains.utils.executeRunConfigurationAndWait +import software.aws.toolkits.jetbrains.utils.rules.ExperimentRule +import software.aws.toolkits.jetbrains.utils.rules.PythonCodeInsightTestFixtureRule +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.test.assertNotNull + +class PythonAwsConnectionExtensionIntegrationTest { + + @Rule + @JvmField + val projectRule = PythonCodeInsightTestFixtureRule() + + @Rule + @JvmField + val regionProviderRule = MockRegionProviderRule() + + @Rule + @JvmField + val credentialManagerRule = MockCredentialManagerRule() + + @Rule + @JvmField + val experiment = ExperimentRule(PythonAwsConnectionExperiment) + + @Rule + @JvmField + val disposableRule = DisposableRule() + + @Test + fun happyPathPythonConnectionInjection() { + assumeTrue("Needs heavy project on >= 232", ApplicationInfo.getInstance().build.baselineVersion < 232) + val file = projectRule.fixture.addFileToProject( + "hello.py", + """ + import os + print(os.environ["AWS_REGION"]) + """.trimIndent() + ) + + val runManager = RunManager.getInstance(projectRule.project) + val configuration = runManager.createConfiguration("test", PythonConfigurationType::class.java) + val runConfiguration = configuration.configuration as PythonRunConfiguration + + lateinit var pythonExecutable: String + Disposer.newDisposable().use { disposable -> + // Allow us to search system for all pythons + FileSystems.getDefault().rootDirectories.forEach { root -> + Files.list(root).forEach { + VfsRootAccess.allowRootAccess(disposable, it.toString()) + } + } + + pythonExecutable = detectSystemWideSdks(null, emptyList()).firstOrNull()?.homePath + // hack for CI because we use pyenv and 221 changed detection logic + ?: Paths.get(SystemProperties.getUserHome()) + .resolve(".pyenv") + .resolve("versions") + .toFile().listFiles()!!.first() + .resolve("bin") + .resolve("python") + .path + } + + assertThat(pythonExecutable).isNotEmpty + runWriteActionAndWait { + ProjectJdkTable.getInstance().addJdk(PyDetectedSdk(pythonExecutable), disposableRule.disposable) + } + runConfiguration.scriptName = file.virtualFile.path + runConfiguration.sdkHome = pythonExecutable + val mockRegion = regionProviderRule.createAwsRegion() + val mockCredential = credentialManagerRule.createCredentialProvider() + + runConfiguration.putCopyableUserData( + AWS_CONNECTION_RUN_CONFIGURATION_KEY, + AwsCredentialInjectionOptions { + region = mockRegion.id + credential = mockCredential.id + } + ) + + VfsRootAccess.allowRootAccess(projectRule.fixture.testRootDisposable, pythonExecutable) + + val executor = ExecutorRegistry.getInstance().getExecutorById(DefaultRunExecutor.EXECUTOR_ID) + assertNotNull(executor) + + assertThat(executeRunConfigurationAndWait(runConfiguration).stdout).isEqualToIgnoringWhitespace(mockRegion.id) + } +} 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 deleted file mode 100644 index bd78ed8de6..0000000000 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugTestCase.kt +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug - -import com.intellij.openapi.project.Project -import com.intellij.testFramework.RuleChain -import com.nhaarman.mockitokotlin2.mock -import org.junit.After -import org.junit.Before -import org.junit.Rule -import software.amazon.awssdk.http.apache.ApacheHttpClient -import software.amazon.awssdk.regions.Region -import software.amazon.awssdk.services.cloudformation.CloudFormationClient -import software.amazon.awssdk.services.ecs.EcsClient -import software.amazon.awssdk.services.ecs.model.AssignPublicIp -import software.amazon.awssdk.services.ecs.model.LaunchType -import software.amazon.awssdk.services.ecs.model.Service -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.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 -import software.aws.toolkits.jetbrains.services.clouddebug.actions.InstrumentResourceAction -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources -import software.aws.toolkits.jetbrains.services.ecs.waitForServicesInactive -import software.aws.toolkits.jetbrains.services.ecs.waitForServicesStable -import software.aws.toolkits.jetbrains.utils.rules.CloudFormationLazyInitRule -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -abstract class CloudDebugTestCase(private val taskDefName: String) { - protected lateinit var service: Service - private lateinit var instrumentationRole: String - private lateinit var instrumentedService: Service - - private val cfnRule = CloudFormationLazyInitRule( - "CloudDebugTestCluster", - CloudDebugTestCase::class.java.getResource("/cloudDebugTestCluster.yaml").readText(), - emptyList(), - cloudFormationClient - ) - - private val ecsRule = ECSTemporaryServiceRule(ecsClient) - - @Rule - @JvmField - val chain = RuleChain(cfnRule, ecsRule) - - @Before - open fun setUp() { - // does not validate that a SSM session is successfully created - val region = AwsRegion("us-west-2", "US West 2", "aws") - MockRegionProvider.getInstance().addRegion(region) - AwsConnectionManager.getInstance(getProject()).changeRegion(region) - instrumentationRole = cfnRule.outputs["TaskRole"] ?: throw RuntimeException("Could not find instrumentation role in CloudFormation outputs") - service = createService() - runUnderRealCredentials(getProject()) { - println("Instrumenting service") - instrumentService() - val instrumentedServiceName = "cloud-debug-${EcsUtils.serviceArnToName(service.serviceArn())}" - println("Waiting for $instrumentedServiceName to stabilize") - ecsRule.ecsClient.waitForServicesStable(service.clusterArn(), instrumentedServiceName, waitForMissingServices = true) - instrumentedService = ecsRule.ecsClient.describeServices { - it.cluster(service.clusterArn()) - it.services(instrumentedServiceName) - }.services().first() - // TODO: verify that no error toasts were created, or similar mechanism - } - - println("Done with base service setup") - } - - @After - open fun tearDown() { - // TODO: this doesn't wait for the revert command to complete but fulfills our need to cleanup - if (::instrumentedService.isInitialized) { - runUnderRealCredentials(getProject()) { - deinstrumentService() - println("Waiting for ${instrumentedService.serviceArn()} to be deinstrumented") - ecsClient.waitForServicesInactive(instrumentedService.clusterArn(), instrumentedService.serviceArn()) - } - // TODO: verify that no error toasts were created, or similar mechanism - } - } - - private fun createService(): Service { - val cfnOutputs = cfnRule.outputs - val service = ecsRule.createService { - it.desiredCount(0) - it.taskDefinition(taskDefName) - it.cluster("CloudDebugTestECSCluster") - it.launchType(LaunchType.FARGATE) - it.networkConfiguration { networkConfig -> - networkConfig.awsvpcConfiguration { vpcConfig -> - vpcConfig.assignPublicIp(AssignPublicIp.ENABLED) - vpcConfig.subnets(cfnOutputs["SubnetA"]) - vpcConfig.securityGroups(cfnOutputs["SecurityGroup"]) - } - } - } - println("Waiting for ${service.serviceArn()} to be stable") - ecsRule.ecsClient.waitForServicesStable(service.clusterArn(), service.serviceArn(), waitForMissingServices = true) - - return service - } - - // TODO: delete these horrible mocks once we have a sane implementation... - fun setUpMocks() { - runUnderRealCredentials(getProject()) { - MockResourceCache.getInstance(getProject()).let { - val mockInstrumentedResources = mock>> { - on { id }.thenReturn("cdb.list_resources") - } - it.addEntry(EcsResources.describeService(instrumentedService.clusterArn(), instrumentedService.serviceArn()), instrumentedService) - it.addEntry(mockInstrumentedResources, mapOf(service.serviceArn() to instrumentationRole)) - it.addEntry( - EcsResources.describeTaskDefinition(instrumentedService.taskDefinition()), - ecsClient.describeTaskDefinition { builder -> builder.taskDefinition(instrumentedService.taskDefinition()) }.taskDefinition() - ) - } - } - } - - private fun awaitCli(latch: CountDownLatch) = { result: Boolean -> - latch.countDown() - if (!result) { - throw RuntimeException("CLI didn't complete successfully!") - } - } - - private fun instrumentService() { - val latch = CountDownLatch(1) - InstrumentResourceAction.performAction(getProject(), service.clusterArn(), service.serviceArn(), instrumentationRole, null, awaitCli(latch)) - latch.await(5, TimeUnit.MINUTES) - } - - private fun deinstrumentService() { - val latch = CountDownLatch(1) - DeinstrumentResourceFromExplorerAction.performAction( - getProject(), - service.clusterArn(), - EcsUtils.originalServiceName(instrumentedService.serviceName()), - null, - awaitCli(latch) - ) - latch.await(5, TimeUnit.MINUTES) - } - - abstract fun getProject(): Project - - companion object { - private val cloudFormationClient = CloudFormationClient.builder() - .httpClient(ApacheHttpClient.builder().build()) - .region(Region.US_WEST_2) - .build() - - private val ecsClient = EcsClient.builder() - .httpClient(ApacheHttpClient.builder().build()) - .region(Region.US_WEST_2) - .build() - } -} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/java/JavaDebugEndToEndTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/java/JavaDebugEndToEndTest.kt deleted file mode 100644 index 630d5794fd..0000000000 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/java/JavaDebugEndToEndTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.java - -import com.intellij.execution.BeforeRunTask -import com.intellij.execution.executors.DefaultDebugExecutor -import com.intellij.execution.executors.DefaultRunExecutor -import com.intellij.openapi.externalSystem.model.execution.ExternalSystemTaskExecutionSettings -import com.intellij.openapi.externalSystem.service.execution.ProgressExecutionMode -import com.intellij.openapi.externalSystem.task.TaskCallback -import com.intellij.openapi.externalSystem.util.ExternalSystemUtil -import com.intellij.testFramework.runInEdtAndWait -import org.assertj.core.api.Assertions.assertThat -import org.jetbrains.plugins.gradle.util.GradleConstants -import org.junit.Rule -import org.junit.Test -import org.mockito.Mockito -import software.aws.toolkits.jetbrains.core.credentials.activeCredentialProvider -import software.aws.toolkits.jetbrains.core.credentials.activeRegion -import software.aws.toolkits.jetbrains.core.credentials.runUnderRealCredentials -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugTestCase -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.jetbrains.services.ecs.execution.ArtifactMapping -import software.aws.toolkits.jetbrains.services.ecs.execution.ContainerOptions -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsCloudDebugRunConfiguration -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsCloudDebugRunConfigurationProducer -import software.aws.toolkits.jetbrains.utils.addBreakpoint -import software.aws.toolkits.jetbrains.utils.checkBreakPointHit -import software.aws.toolkits.jetbrains.utils.executeRunConfiguration -import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule -import software.aws.toolkits.jetbrains.utils.rules.addClass -import software.aws.toolkits.jetbrains.utils.rules.addModule -import software.aws.toolkits.jetbrains.utils.setUpGradleProject -import java.nio.file.Paths -import java.util.concurrent.CompletableFuture - -class JavaDebugEndToEndTest : CloudDebugTestCase("CloudDebugTestECSClusterTaskDefinitionWithJava") { - @JvmField - @Rule - val projectRule = HeavyJavaCodeInsightTestFixtureRule() - - @Test - fun testEndToEnd() { - // setup project workspace - addJavaFile() - val basePath = Paths.get(projectRule.module.moduleFilePath).parent - val jarFile = basePath.resolve(Paths.get("build", "libs", "main.jar")) - - // TODO: figure out how to turn this into a before task - // ./gradlew jar - val buildSettings = ExternalSystemTaskExecutionSettings().apply { - externalSystemIdString = "GRADLE" - externalProjectPath = basePath.toString() - taskNames = listOf("jar") - } - - val future = CompletableFuture() - ExternalSystemUtil.runTask(buildSettings, DefaultRunExecutor.EXECUTOR_ID, projectRule.project, GradleConstants.SYSTEM_ID, - object : TaskCallback { - override fun onSuccess() { - future.complete(null) - } - - override fun onFailure() { - future.completeExceptionally(RuntimeException("Jar task failed")) - } - }, ProgressExecutionMode.IN_BACKGROUND_ASYNC, false) - future.join() - - // set breakpoint - projectRule.addBreakpoint() - val debuggerIsHit = checkBreakPointHit(projectRule.project) - - setUpMocks() - - // run a run configuration - val configuration = EcsCloudDebugRunConfiguration( - projectRule.project, - EcsCloudDebugRunConfigurationProducer.getFactory() - ).apply { - beforeRunTasks = mutableListOf(Mockito.mock(BeforeRunTask::class.java)) - clusterArn(service.clusterArn()) - // TODO: remove this once we fix the UX around which service is debugged - serviceArn(service.serviceArn().let { - // replace service name with instrumented service name - val instrumentedServiceName = "cloud-debug-${EcsUtils.serviceArnToName(service.serviceArn())}" - it.replace(EcsUtils.serviceArnToName(it), instrumentedServiceName) - }) - containerOptions(mapOf("ContainerName" to ContainerOptions().apply { - platform = CloudDebuggingPlatform.JVM - startCommand = "java -cp /main.jar Main" - artifactMappings = listOf(ArtifactMapping(jarFile.toString(), "/main.jar")) - })) - } - - runUnderRealCredentials(projectRule.project) { - configuration.regionId(projectRule.project.activeRegion().id) - configuration.credentialProviderId(projectRule.project.activeCredentialProvider().id) - configuration.checkConfiguration() - executeRunConfiguration(configuration, DefaultDebugExecutor.EXECUTOR_ID) - } - - // check breakpoint hit - assertThat(debuggerIsHit.get()).isTrue() - } - - private fun addJavaFile() { - val fixture = projectRule.fixture - val module = fixture.addModule("main") - val psiClass = fixture.addClass( - module, - """ - public class Main { - public static void main(String[] args) { - System.out.println("Hello World!"); - } - } - """ - ) - - runInEdtAndWait { - fixture.openFileInEditor(psiClass.containingFile.virtualFile) - } - - projectRule.setUpGradleProject() - } - - override fun getProject() = projectRule.project -} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/python/PythonDebugEndToEndTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/python/PythonDebugEndToEndTest.kt deleted file mode 100644 index 5af53d9f48..0000000000 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/clouddebug/python/PythonDebugEndToEndTest.kt +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.python - -import com.intellij.execution.configurations.RuntimeConfigurationWarning -import com.intellij.execution.executors.DefaultDebugExecutor -import com.intellij.testFramework.runInEdtAndWait -import org.assertj.core.api.Assertions.assertThat -import org.junit.Rule -import org.junit.Test -import software.aws.toolkits.jetbrains.core.credentials.activeCredentialProvider -import software.aws.toolkits.jetbrains.core.credentials.activeRegion -import software.aws.toolkits.jetbrains.core.credentials.runUnderRealCredentials -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugTestCase -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.jetbrains.services.ecs.execution.ArtifactMapping -import software.aws.toolkits.jetbrains.services.ecs.execution.ContainerOptions -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsCloudDebugRunConfiguration -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsCloudDebugRunConfigurationProducer -import software.aws.toolkits.jetbrains.utils.checkBreakPointHit -import software.aws.toolkits.jetbrains.utils.executeRunConfiguration -import software.aws.toolkits.jetbrains.utils.rules.PythonCodeInsightTestFixtureRule -import software.aws.toolkits.jetbrains.utils.rules.addBreakpoint -import java.nio.file.Path -import java.nio.file.Paths -import java.util.concurrent.CountDownLatch - -// We use the corretto image for Python too, that is why we use the Java Task Def -class PythonDebugEndToEndTest : CloudDebugTestCase("CloudDebugTestECSClusterTaskDefinitionWithJava") { - @JvmField - @Rule - val projectRule = PythonCodeInsightTestFixtureRule() - - @Test - fun testEndToEnd() { - // setup project workspace - val testScript = addPythonFile("test.py", - """ - import hello - import folderNoTrailingSlash.hello - import folderTrailingSlash.hello - """.trimIndent()) - val file = addPythonFile("hello.py") - projectRule.addBreakpoint() - - val folderNoTrailingSlash = addPythonFile("folderNoTrailingSlash/hello.py") - projectRule.addBreakpoint() - projectRule.fixture.addFileToProject("folderNoTrailingSlash/__init__.py", "") - - val folderTrailingSlash = addPythonFile("folderTrailingSlash/hello.py") - projectRule.addBreakpoint() - projectRule.fixture.addFileToProject("folderTrailingSlash/__init__.py", "") - - // set breakpoint - val countDown = CountDownLatch(3) - checkBreakPointHit(projectRule.project) { - countDown.countDown() - } - - setUpMocks() - - // run a run configuration - val configuration = EcsCloudDebugRunConfiguration( - projectRule.project, - EcsCloudDebugRunConfigurationProducer.getFactory() - ).apply { - clusterArn(service.clusterArn()) - // TODO: remove this once we fix the UX around which service is debugged - serviceArn(service.serviceArn().let { - // replace service name with instrumented service name - val instrumentedServiceName = "cloud-debug-${EcsUtils.serviceArnToName(service.serviceArn())}" - it.replace(EcsUtils.serviceArnToName(it), instrumentedServiceName) - }) - containerOptions(mapOf("ContainerName" to ContainerOptions().apply { - platform = CloudDebuggingPlatform.PYTHON - startCommand = "python /${testScript.fileName}" - artifactMappings = listOf( - ArtifactMapping(testScript.toString(), "/test.py"), - ArtifactMapping(file.toString(), "/hello.py"), - ArtifactMapping(folderNoTrailingSlash.parent.toString().trimEnd('/'), "/"), - ArtifactMapping(folderTrailingSlash.parent.toString().trimEnd('/') + '/', "/folderTrailingSlash") - ) - })) - } - - runUnderRealCredentials(projectRule.project) { - try { - configuration.regionId(projectRule.project.activeRegion().id) - configuration.credentialProviderId(projectRule.project.activeCredentialProvider().id) - configuration.checkConfiguration() - } catch (_: RuntimeConfigurationWarning) { - // ignore warnings because we know what we're doing - } - executeRunConfiguration(configuration, DefaultDebugExecutor.EXECUTOR_ID) - } - - // check breakpoint hit - assertThat(countDown.count).isEqualTo(0) - } - - private fun addPythonFile(relPath: String, contents: String? = null): Path { - val fixture = projectRule.fixture - val psiClass = fixture.addFileToProject( - relPath, - contents ?: """ - def hello_world(): - print("hello world!") - - hello_world() - """.trimIndent() - ) - - runInEdtAndWait { - fixture.openFileInEditor(psiClass.containingFile.virtualFile) - } - - return Paths.get(psiClass.virtualFile.path) - } - - override fun getProject() = projectRule.project -} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeScanIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeScanIntegrationTest.kt new file mode 100644 index 0000000000..a3ee3783e0 --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeScanIntegrationTest.kt @@ -0,0 +1,77 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer + +import com.intellij.testFramework.runInEdtAndWait +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.cppFileName +import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.cppTestLeftContext +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig.CodeScanSessionConfig +import software.aws.toolkits.jetbrains.utils.rules.RunWithRealCredentials.RequiresRealCredentials +import software.aws.toolkits.resources.message + +@RequiresRealCredentials +class CodeWhispererCodeScanIntegrationTest : CodeWhispererIntegrationTestBase() { + private val filePromptWithSecurityIssues = """ + from flask import app + + @app.route('/') + def execute_input_noncompliant(): + from flask import request + module_version = request.args.get("module_version") + # Noncompliant: executes unsanitized inputs. + exec("import urllib%s as urllib" % module_version) + + @app.route('/') + def execute_input_compliant(): + from flask import request + module_version = request.args.get("module_version") + # Compliant: executes sanitized inputs. + exec("import urllib%d as urllib" % int(module_version)) + """.trimIndent() + + @Test + fun testCodeScanValidWithIssues() { + projectRule.fixture.addFileToProject("test2.py", filePromptWithSecurityIssues) + val response = runCodeScan() + assertThat(response.issues.size).isEqualTo(3) + assertThat(response.responseContext.codeScanTotalIssues).isEqualTo(3) + assertThat(response.responseContext.codeScanJobId).isNotNull + } + + @Test + fun testCodeScanValidWithNoIssues() { + val response = runCodeScan() + assertThat(response.issues.size).isEqualTo(0) + assertThat(response.responseContext.codeScanTotalIssues).isEqualTo(0) + assertThat(response.responseContext.codeScanJobId).isNotNull + } + + @Test + fun testCodeScanFileTooLarge() { + val largePrompt = "a".repeat(1024 * 300) + val file = projectRule.fixture.addFileToProject("test2.py", largePrompt) + runInEdtAndWait { + projectRule.fixture.openFileInEditor(file.virtualFile) + } + testCodeScanWithErrorMessage( + message( + "codewhisperer.codescan.file_too_large", + CodeScanSessionConfig.create(file.virtualFile, projectRule.project).getPresentablePayloadLimit() + ) + ) + } + + @Test + fun testCodeScanUnsupportedLanguage() { + val file = projectRule.fixture.addFileToProject(cppFileName, cppTestLeftContext) + runInEdtAndWait { + projectRule.fixture.openFileInEditor(file.virtualFile) + } + testCodeScanWithErrorMessage( + message("codewhisperer.codescan.file_ext_not_supported", file.virtualFile.extension ?: "") + ) + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeScanJavaIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeScanJavaIntegrationTest.kt new file mode 100644 index 0000000000..467a326a7f --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeScanJavaIntegrationTest.kt @@ -0,0 +1,28 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer + +import com.intellij.testFramework.runInEdtAndWait +import org.junit.Test +import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.javaTestContext +import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.utils.rules.RunWithRealCredentials.RequiresRealCredentials +import software.aws.toolkits.jetbrains.utils.rules.addClass +import software.aws.toolkits.jetbrains.utils.rules.addModule +import software.aws.toolkits.resources.message + +@RequiresRealCredentials +class CodeWhispererCodeScanJavaIntegrationTest : CodeWhispererIntegrationTestBase(HeavyJavaCodeInsightTestFixtureRule()) { + @Test + fun testCodeScanJavaProjectNoBuild() { + projectRule as HeavyJavaCodeInsightTestFixtureRule + val module = projectRule.fixture.addModule("main") + + val psiClass = projectRule.fixture.addClass(module, javaTestContext) + runInEdtAndWait { + projectRule.fixture.openFileInEditor(psiClass.containingFile.virtualFile) + } + testCodeScanWithErrorMessage(message("codewhisperer.codescan.build_artifacts_not_found")) + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCompletionIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCompletionIntegrationTest.kt new file mode 100644 index 0000000000..b946f14ce2 --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCompletionIntegrationTest.kt @@ -0,0 +1,60 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.cppFileName +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererService +import software.aws.toolkits.jetbrains.utils.rules.RunWithRealCredentials.RequiresRealCredentials +import software.aws.toolkits.resources.message + +@RequiresRealCredentials +class CodeWhispererCompletionIntegrationTest : CodeWhispererIntegrationTestBase() { + @Test + fun testInvokeCompletionManualTrigger() { + assertDoesNotThrow { + withCodeWhispererServiceInvokedAndWait { response -> + val requestId = response.responseMetadata().requestId() + assertThat(requestId).isNotNull + val sessionId = response.sdkHttpResponse().headers().getOrDefault( + CodeWhispererService.KET_SESSION_ID, + listOf(requestId) + )[0] + assertThat(sessionId).isNotNull + } + } + } + + @Test + fun testInvokeCompletionAutoTrigger() { + assertDoesNotThrow { + stateManager.setAutoEnabled(true) + withCodeWhispererServiceInvokedAndWait(false) { response -> + val requestId = response.responseMetadata().requestId() + assertThat(requestId).isNotNull + val sessionId = response.sdkHttpResponse().headers().getOrDefault( + CodeWhispererService.KET_SESSION_ID, + listOf(requestId) + )[0] + assertThat(sessionId).isNotNull + } + } + } + + @Test + fun testInvokeCompletionUnsupportedLanguage() { + val file = setFileContext(cppFileName, CodeWhispererTestUtil.cppTestLeftContext, "") + assertDoesNotThrow { + invokeCodeWhispererService() + verify(popupManager, never()).showPopup(any(), any(), any(), any(), any()) + verify(clientAdaptor, never()).generateCompletionsPaginator(any()) + testMessageShown(message("codewhisperer.language.error", file.fileType.name)) + } + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererIntegrationTestBase.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererIntegrationTestBase.kt new file mode 100644 index 0000000000..f6f909818a --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererIntegrationTestBase.kt @@ -0,0 +1,240 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer + +import com.intellij.analysis.problemsView.toolWindow.ProblemsView +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.wm.RegisterToolWindowTask +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.psi.PsiFile +import com.intellij.testFramework.DisposableRule +import com.intellij.testFramework.RuleChain +import com.intellij.testFramework.replaceService +import com.intellij.testFramework.runInEdtAndWait +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.spy +import org.mockito.kotlin.timeout +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.amazon.awssdk.services.codewhispererruntime.model.GenerateCompletionsResponse +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider +import software.aws.toolkits.jetbrains.core.MockClientManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager +import software.aws.toolkits.jetbrains.core.credentials.loginSso +import software.aws.toolkits.jetbrains.core.credentials.sono.CODEWHISPERER_SCOPES +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.codeWhispererRecommendationActionId +import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonFileName +import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonTestLeftContext +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeScanResponse +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanException +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanIssue +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExploreActionState +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExploreStateType +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CodeScanTelemetryEvent +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStatus +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererService +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererConfiguration +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererConfigurationType +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererSettings +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.utils.rules.CodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.utils.rules.PythonCodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.utils.rules.RunWithRealCredentials + +open class CodeWhispererIntegrationTestBase(val projectRule: CodeInsightTestFixtureRule = PythonCodeInsightTestFixtureRule()) { + + private val realCredentials = RunWithRealCredentials(projectRule) + internal val disposableRule = DisposableRule() + + @Rule + @JvmField + val ruleChain = RuleChain(projectRule, disposableRule, realCredentials) + + protected lateinit var popupManager: CodeWhispererPopupManager + protected lateinit var clientAdaptor: CodeWhispererClientAdaptor + protected lateinit var stateManager: CodeWhispererExplorerActionManager + protected lateinit var codewhispererService: CodeWhispererService + protected lateinit var settingsManager: CodeWhispererSettings + private lateinit var originalExplorerActionState: CodeWhispererExploreActionState + private lateinit var originalSettings: CodeWhispererConfiguration + internal lateinit var scanManager: CodeWhispererCodeScanManager + protected lateinit var telemetryServiceSpy: CodeWhispererTelemetryService + + @Suppress("UnreachableCode") + @Before + open fun setUp() { + assumeTrue("CI doesn't have Builder ID credentials", System.getenv("CI").isNullOrBlank()) + MockClientManager.useRealImplementations(disposableRule.disposable) + + loginSso(projectRule.project, SONO_URL, SONO_REGION, CODEWHISPERER_SCOPES) + val connectionId = ToolkitBearerTokenProvider.ssoIdentifier(SONO_URL) + val connection = ToolkitAuthManager.getInstance().getConnection(connectionId) ?: return + val tokenProvider = (connection.getConnectionSettings() as TokenConnectionSettings).tokenProvider.delegate as BearerTokenProvider + tokenProvider.resolveToken() + + ToolWindowManager.getInstance(projectRule.project).registerToolWindow( + RegisterToolWindowTask(id = ProblemsView.ID, canWorkInDumbMode = true) + ) + + scanManager = spy(CodeWhispererCodeScanManager.getInstance(projectRule.project)) + doNothing().whenever(scanManager).addCodeScanUI(any()) + projectRule.project.replaceService(CodeWhispererCodeScanManager::class.java, scanManager, disposableRule.disposable) + + telemetryServiceSpy = spy(CodeWhispererTelemetryService.getInstance()) + ApplicationManager.getApplication().replaceService(CodeWhispererTelemetryService::class.java, telemetryServiceSpy, disposableRule.disposable) + + stateManager = CodeWhispererExplorerActionManager.getInstance() + stateManager.setAutoEnabled(false) + + popupManager = spy(CodeWhispererPopupManager.getInstance()) + popupManager.reset() + doNothing().whenever(popupManager).showPopup(any(), any(), any(), any(), any()) + ApplicationManager.getApplication().replaceService(CodeWhispererPopupManager::class.java, popupManager, disposableRule.disposable) + + codewhispererService = spy(CodeWhispererService.getInstance()) + ApplicationManager.getApplication().replaceService(CodeWhispererService::class.java, codewhispererService, disposableRule.disposable) + + settingsManager = CodeWhispererSettings.getInstance() + + clientAdaptor = spy(CodeWhispererClientAdaptor.getInstance(projectRule.project)) + projectRule.project.replaceService(CodeWhispererClientAdaptor::class.java, clientAdaptor, disposableRule.disposable) + + originalExplorerActionState = stateManager.state + originalSettings = settingsManager.state + stateManager.loadState( + CodeWhispererExploreActionState().apply { + CodeWhispererExploreStateType.values().forEach { + value[it] = true + } + } + ) + settingsManager.loadState( + CodeWhispererConfiguration().apply { + value[CodeWhispererConfigurationType.IsIncludeCodeWithReference] = true + } + ) + + setFileContext(pythonFileName, pythonTestLeftContext, "") + } + + @After + open fun tearDown() { + runInEdtAndWait { + if (::stateManager.isInitialized) { + stateManager.loadState(originalExplorerActionState) + } + + if (::settingsManager.isInitialized) { + settingsManager.loadState(originalSettings) + } + + if (::popupManager.isInitialized) { + popupManager.reset() + } + } + } + + fun withCodeWhispererServiceInvokedAndWait(manual: Boolean = true, runnable: (GenerateCompletionsResponse) -> Unit) { + val responseCaptor = argumentCaptor() + val statesCaptor = argumentCaptor() + invokeCodeWhispererService(manual) + verify(codewhispererService, timeout(5000).atLeastOnce()).validateResponse(responseCaptor.capture()) + val response = responseCaptor.lastValue + verify(popupManager, timeout(5000).atLeastOnce()).showPopup(statesCaptor.capture(), any(), any(), any(), any()) + val states = statesCaptor.lastValue + + runInEdtAndWait { + try { + runnable(response) + } finally { + CodeWhispererPopupManager.getInstance().closePopup(states.popup) + } + } + } + + fun invokeCodeWhispererService(manual: Boolean = true) { + if (manual) { + runInEdtAndWait { + projectRule.fixture.performEditorAction(codeWhispererRecommendationActionId) + } + } else { + runInEdtAndWait { + projectRule.fixture.type('(') + } + } + while (CodeWhispererInvocationStatus.getInstance().hasExistingInvocation()) { + Thread.sleep(10) + } + } + + fun setFileContext(filename: String, leftContext: String, rightContext: String): PsiFile { + val file = projectRule.fixture.configureByText(filename, leftContext + rightContext) + runInEdtAndWait { + projectRule.fixture.editor.caretModel.primaryCaret.moveToOffset(leftContext.length) + } + return file + } + + fun runCodeScan(success: Boolean = true): CodeScanResponse { + runInEdtAndWait { + projectRule.fixture.performEditorAction(CodeWhispererTestUtil.codeWhispererCodeScanActionId) + } + val issuesCaptor = argumentCaptor>() + val codeScanEventCaptor = argumentCaptor() + return runBlocking { + var issues = emptyList() + if (success) { + verify(scanManager, timeout(60000).atLeastOnce()).renderResponseOnUIThread(issuesCaptor.capture(), any(), any()) + issues = issuesCaptor.lastValue + } + verify(telemetryServiceSpy, timeout(60000).atLeastOnce()).sendSecurityScanEvent(codeScanEventCaptor.capture(), anyOrNull()) + val codeScanResponseContext = codeScanEventCaptor.lastValue.codeScanResponseContext + CodeScanResponse.Success(issues, codeScanResponseContext) + } + } + + fun testCodeScanWithErrorMessage(message: String) { + val response = runCodeScan(success = false) + assertThat(response.issues.size).isEqualTo(0) + assertThat(response.responseContext.codeScanTotalIssues).isEqualTo(0) + assertThat(response.responseContext.codeScanJobId).isNull() + val exceptionCaptor = argumentCaptor() + verify(scanManager, atLeastOnce()).handleException(any(), exceptionCaptor.capture()) + val e = exceptionCaptor.lastValue + assertThat(e is CodeWhispererCodeScanException).isTrue + assertThat(e.message).isEqualTo(message) + } + + fun testMessageShown(message: String, info: Boolean = true) { + val messageCaptor = argumentCaptor() + if (info) { + verify(codewhispererService, timeout(5000).times(1)) + .showCodeWhispererInfoHint(any(), messageCaptor.capture()) + } else { + verify(codewhispererService, timeout(5000).times(1)) + .showCodeWhispererErrorHint(any(), messageCaptor.capture()) + } + assertThat(messageCaptor.lastValue).isEqualTo(message) + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererReferenceTrackerIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererReferenceTrackerIntegrationTest.kt new file mode 100644 index 0000000000..67b75619b3 --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererReferenceTrackerIntegrationTest.kt @@ -0,0 +1,70 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.jsFileName +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererService +import software.aws.toolkits.jetbrains.utils.rules.RunWithRealCredentials.RequiresRealCredentials +import software.aws.toolkits.resources.message + +@RequiresRealCredentials +class CodeWhispererReferenceTrackerIntegrationTest : CodeWhispererIntegrationTestBase() { + private val leftContextWithReference = """ +InAuto.GetContent( + InAuto.servers.auto, "vendors.json", function (data) { + let block = ''; + for(let i = 0; i < data.length; i++) { + block += '' + cars[i].title + ''; + } + ${'$'}('#cars').html(block); + } +); + """.trimIndent() + + @Before + override fun setUp() { + super.setUp() + setFileContext(jsFileName, leftContextWithReference, rightContextWithReference) + } + + @Test + fun testInvokeCompletionWithReference() { + assertDoesNotThrow { + settingsManager.toggleIncludeCodeWithReference(true) + withCodeWhispererServiceInvokedAndWait { response -> + val requestId = response.responseMetadata().requestId() + assertThat(requestId).isNotNull + val sessionId = response.sdkHttpResponse().headers().getOrDefault( + CodeWhispererService.KET_SESSION_ID, + listOf(requestId) + )[0] + assertThat(sessionId).isNotNull + assertThat(response.hasCompletions()).isTrue + assertThat(response.completions()).isNotEmpty + assertThat(response.completions()[0].hasReferences()).isTrue + } + } + } + + @Test + fun testInvokeCompletionWithReferenceWithReferenceSettingDisabled() { + assertDoesNotThrow { + settingsManager.toggleIncludeCodeWithReference(false) + invokeCodeWhispererService() + verify(popupManager, never()).showPopup(any(), any(), any(), any(), any()) + testMessageShown(message("codewhisperer.popup.no_recommendations")) + } + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ecr/EcrIntegrationTestUtils.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ecr/EcrIntegrationTestUtils.kt new file mode 100644 index 0000000000..c5871b6423 --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ecr/EcrIntegrationTestUtils.kt @@ -0,0 +1,8 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr + +object EcrIntegrationTestUtils { + fun getImagePrefix(imageId: String): String = imageId +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ecr/EcrPullIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ecr/EcrPullIntegrationTest.kt new file mode 100644 index 0000000000..6688dcb7ee --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ecr/EcrPullIntegrationTest.kt @@ -0,0 +1,89 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr + +import com.intellij.testFramework.ProjectRule +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +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.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.ecr.EcrClient +import software.aws.toolkits.core.rules.EcrTemporaryRepositoryRule +import software.aws.toolkits.jetbrains.core.docker.ToolkitDockerAdapter +import software.aws.toolkits.jetbrains.core.docker.getDockerServerRuntimeFacade +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository +import java.util.UUID + +class EcrPullIntegrationTest { + private val ecrClient = EcrClient.builder() + .region(Region.US_WEST_2) + .build() + + @Rule + @JvmField + val projectRule = ProjectRule() + + @Rule + @JvmField + val folder = TemporaryFolder() + + @Rule + @JvmField + val ecrRule = EcrTemporaryRepositoryRule(ecrClient) + + private lateinit var remoteRepo: Repository + + @Before + fun setUp() { + remoteRepo = ecrRule.createRepository().toToolkitEcrRepository()!! + } + + @Test + fun testPullImage() { + val remoteTag = UUID.randomUUID().toString() + + val dockerfile = folder.newFile() + dockerfile.writeText( + """ + # arbitrary base image with a shell + FROM public.ecr.aws/docker/library/alpine:latest + RUN touch $(date +%s) + """.trimIndent() + ) + + val project = projectRule.project + runBlocking { + val serverRuntime = getDockerServerRuntimeFacade(project) + val ecrLogin = ecrClient.authorizationToken.authorizationData().first().getDockerLogin() + val dockerAdapter = ToolkitDockerAdapter(project, serverRuntime) + val imageId = dockerAdapter.buildLocalImage(dockerfile)!! + + // gross transform because we only have the short SHA right now + val localImage = serverRuntime.agent.getImages(null).first { it.imageId.startsWith(EcrIntegrationTestUtils.getImagePrefix(imageId)) } + val localImageId = localImage.imageId + val config = EcrUtils.buildDockerRepositoryModel(ecrLogin, remoteRepo, remoteTag) + val pushRequest = ImageEcrPushRequest( + serverRuntime, + localImageId, + remoteRepo, + remoteTag + ) + // push up and image and then delete the local tag + EcrUtils.pushImage(projectRule.project, ecrLogin, pushRequest).await() + localImage.deleteImage().await() + assertThat(serverRuntime.agent.getImages(null).firstOrNull { it.imageId == localImageId }).isNull() + + // pull it from the remote + dockerAdapter.pullImage(config).await() + assertThat(serverRuntime.agent.getImages(null).firstOrNull { it.imageId == localImageId }).isNotNull() + } + } + + // FIX_WHEN_MIN_IS_231: deleteImage() is blocking prior to 231 + private fun Unit.await() {} +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ecr/EcrPushIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ecr/EcrPushIntegrationTest.kt new file mode 100644 index 0000000000..448d147e36 --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ecr/EcrPushIntegrationTest.kt @@ -0,0 +1,143 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.testFramework.ProjectRule +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +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.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.ecr.EcrClient +import software.amazon.awssdk.services.ecr.model.Image +import software.amazon.awssdk.services.ecr.model.ImageIdentifier +import software.aws.toolkits.core.rules.EcrTemporaryRepositoryRule +import software.aws.toolkits.jetbrains.core.docker.ToolkitDockerAdapter +import software.aws.toolkits.jetbrains.core.docker.getDockerServerRuntimeFacade +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository +import java.util.UUID + +class EcrPushIntegrationTest { + private val ecrClient = EcrClient.builder() + .region(Region.US_WEST_2) + .build() + + @Rule + @JvmField + val projectRule = ProjectRule() + + @Rule + @JvmField + val folder = TemporaryFolder() + + @Rule + @JvmField + val ecrRule = EcrTemporaryRepositoryRule(ecrClient) + + private lateinit var remoteRepo: Repository + + @Before + fun setUp() { + remoteRepo = ecrRule.createRepository().toToolkitEcrRepository()!! + } + + @Test + fun testPush() { + val remoteTag = UUID.randomUUID().toString() + + val dockerfile = folder.newFile() + dockerfile.writeText( + """ + # arbitrary base image with a shell + FROM public.ecr.aws/docker/library/alpine:latest + RUN touch $(date +%s) + """.trimIndent() + ) + + val project = projectRule.project + runBlocking { + val serverRuntime = getDockerServerRuntimeFacade(project) + val ecrLogin = ecrClient.authorizationToken.authorizationData().first().getDockerLogin() + val dockerAdapter = ToolkitDockerAdapter(project, serverRuntime) + val imageId = dockerAdapter.buildLocalImage(dockerfile)!! + + // gross transform because we only have the short SHA right now + val localImage = serverRuntime.agent.getImages(null).first { it.imageId.startsWith(EcrIntegrationTestUtils.getImagePrefix(imageId)) } + val localImageId = localImage.imageId + val pushRequest = ImageEcrPushRequest( + serverRuntime, + localImageId, + remoteRepo, + remoteTag + ) + EcrUtils.pushImage(projectRule.project, ecrLogin, pushRequest).await() + + assertThat( + ecrClient.batchGetImage { + it.repositoryName(remoteRepo.repositoryName) + it.imageIds(ImageIdentifier.builder().imageTag(remoteTag).build()) + }.images() + ) + .hasSize(1) + .allSatisfy { image -> + assertDigestFromDockerManifest(image, localImageId) + } + } + } + + @Test + fun testPushFromDockerfile() { + val remoteTag = UUID.randomUUID().toString() + + val dockerfile = folder.newFile() + dockerfile.writeText( + """ + # arbitrary base image with a shell + FROM public.ecr.aws/docker/library/alpine:latest + RUN touch $(date +%s) + """.trimIndent() + ) + + val ecrLogin = ecrClient.authorizationToken.authorizationData().first().getDockerLogin() + val config = EcrUtils.dockerRunConfigurationFromPath(projectRule.project, remoteTag, dockerfile.absolutePath) + val pushRequest = DockerfileEcrPushRequest( + config.configuration as DockerRunConfiguration, + remoteRepo, + remoteTag + ) + runBlocking { + EcrUtils.pushImage(projectRule.project, ecrLogin, pushRequest).await() + + // find our local image id + val serverRuntime = getDockerServerRuntimeFacade(projectRule.project) + val localImageId = serverRuntime.agent.getImages(null).first { it.imageRepoTags.contains("${remoteRepo.repositoryUri}:$remoteTag") }.imageId + + assertThat( + ecrClient.batchGetImage { + it.repositoryName(remoteRepo.repositoryName) + it.imageIds(ImageIdentifier.builder().imageTag(remoteTag).build()) + }.images() + ) + .hasSize(1) + .allSatisfy { image -> + assertDigestFromDockerManifest(image, localImageId) + } + } + } + + private fun assertDigestFromDockerManifest(image: Image, imageId: String) { + // inspect the manifest because the registry digest is not the same as the image id + // https://github.com/docker/hub-feedback/issues/1925 + val node = objectMapper.readTree(image.imageManifest()) + assertThat(node.get("config").get("digest").asText()).isEqualTo(imageId) + } + + companion object { + val objectMapper = jacksonObjectMapper() + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/federation/AwsConsoleUrlFactoryIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/federation/AwsConsoleUrlFactoryIntegrationTest.kt new file mode 100644 index 0000000000..c62003b05b --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/federation/AwsConsoleUrlFactoryIntegrationTest.kt @@ -0,0 +1,62 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.federation + +import com.intellij.testFramework.TestApplicationManager +import com.intellij.util.io.HttpRequests +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assume.assumeFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerRule +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider + +@RunWith(Parameterized::class) +class AwsConsoleUrlFactoryIntegrationTest(@Suppress("UNUSED_PARAMETER") regionId: String, private val region: AwsRegion) { + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> { + // hack because parameters are evalutaed before rules https://github.com/junit-team/junit4/issues/671 + TestApplicationManager.getInstance() + + return AwsRegionProvider().allRegions().values.sortedWith(compareBy { it.partitionId }.thenBy { it.id }) + .map { arrayOf(it.id, it) } + } + } + + @Rule + @JvmField + val credRule = MockCredentialManagerRule() + + /** + * There is currently no good way to test this in our integration CI fleet, so this test suite only runs locally + */ + @Test + fun `can sign-in`() { + val profileName = when (region.partitionId) { + // define these environment variables to test signin for the given partition + "aws" -> System.getenv("AWS_CLASSIC_TEST_PROFILE") + "aws-us-gov" -> System.getenv("AWS_GOV_TEST_PROFILE") + "aws-cn" -> System.getenv("AWS_CN_TEST_PROFILE") + else -> throw RuntimeException("Region partition is unknown for $region") + } + assumeFalse("Skipping console sign-in test for $region since a credentials profile was not available", profileName.isNullOrBlank()) + + val credProvider = credRule.createCredentialProvider(profileName, ProfileCredentialsProvider.create(profileName).resolveCredentials()) + val credSettings = ConnectionSettings(credProvider, region) + val signinUrl = AwsConsoleUrlFactory.getSigninUrl(credSettings, destination = "") + val responseCode = HttpRequests.request(signinUrl) + // don't throw because it'll print the signin token as part of the exception + .throwStatusCodeException(false) + .tryConnect() + + assertThat(responseCode).isGreaterThanOrEqualTo(200).isLessThan(400) + } +} 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 c660ac7410..df5f4c2aff 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 @@ -3,53 +3,59 @@ package software.aws.toolkits.jetbrains.services.lambda.deploy -import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.RuleChain import com.intellij.testFramework.runInEdtAndGet import com.intellij.util.ExceptionUtil import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Rule import org.junit.Test -import software.amazon.awssdk.http.apache.ApacheHttpClient -import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.cloudformation.CloudFormationClient import software.amazon.awssdk.services.cloudformation.model.Parameter -import software.amazon.awssdk.services.s3.S3Client -import software.aws.toolkits.core.region.AwsRegion +import software.amazon.awssdk.services.cloudformation.model.Tag +import software.aws.toolkits.core.rules.EcrTemporaryRepositoryRule import software.aws.toolkits.core.rules.S3TemporaryBucketRule -import software.aws.toolkits.jetbrains.core.credentials.MockAwsConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.runUnderRealCredentials +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.services.lambda.steps.DeployLambda +import software.aws.toolkits.jetbrains.services.lambda.steps.createDeployWorkflow +import software.aws.toolkits.jetbrains.utils.assumeImageSupport +import software.aws.toolkits.jetbrains.utils.execution.steps.ConsoleViewWorkflowEmitter +import software.aws.toolkits.jetbrains.utils.execution.steps.StepExecutor +import software.aws.toolkits.jetbrains.utils.readProject import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.utils.rules.RunWithRealCredentials +import software.aws.toolkits.jetbrains.utils.rules.RunWithRealCredentials.RequiresRealCredentials +import software.aws.toolkits.jetbrains.utils.rules.addModule import software.aws.toolkits.jetbrains.utils.setSamExecutableFromEnvironment +import java.io.File +import java.nio.file.Paths import java.util.UUID import java.util.concurrent.TimeUnit +@RequiresRealCredentials class SamDeployTest { - private val s3Client = S3Client.builder() - .httpClient(ApacheHttpClient.builder().build()) - .region(Region.US_WEST_2) - .serviceConfiguration { it.pathStyleAccessEnabled(true) } - .build() - - private val cfnClient = CloudFormationClient.builder() - .httpClient(ApacheHttpClient.builder().build()) - .region(Region.US_WEST_2) - .build() - - @Rule - @JvmField - val projectRule = HeavyJavaCodeInsightTestFixtureRule() + private val largeTemplateLocation = Paths.get(System.getProperty("testDataPath"), "testFiles", "LargeTemplate.yml").toString() + private val projectRule = HeavyJavaCodeInsightTestFixtureRule() + private val bucketRule = S3TemporaryBucketRule { projectRule.project.awsClient() } + private val repositoryRule = EcrTemporaryRepositoryRule { projectRule.project.awsClient() } + private val realCredentials = RunWithRealCredentials(projectRule) @Rule @JvmField - val bucketRule = S3TemporaryBucketRule(s3Client) + val ruleChain = RuleChain( + projectRule, + realCredentials, + repositoryRule, + bucketRule + ) @Before fun setUp() { setSamExecutableFromEnvironment() - MockAwsConnectionManager.getInstance(projectRule.project).changeRegion(AwsRegion(Region.US_WEST_2.id(), "us-west-2", "aws")) + // we need at least one module for deploy image (for read project) + projectRule.fixture.addModule("main") } @Test @@ -57,11 +63,10 @@ class SamDeployTest { val stackName = "SamDeployTest-${UUID.randomUUID()}" val templateFile = setUpProject() runAssertsAndClean(stackName) { - val changeSetArn = createChangeSet(templateFile, stackName) + val changeSetArn = createChangeSet(templateFile, stackName, hasImage = false) - assertThat(changeSetArn).isNotNull() - - val describeChangeSetResponse = cfnClient.describeChangeSet { + assertThat(changeSetArn).isNotNull + val describeChangeSetResponse = projectRule.project.awsClient().describeChangeSet { it.stackName(stackName) it.changeSetName(changeSetArn) } @@ -76,16 +81,39 @@ class SamDeployTest { } } + @Test + // Tests using a stack > the CFN limit of 51200 bytes + fun deployLargeAppUsingSam() { + val stackName = "SamDeployTest-${UUID.randomUUID()}" + val templateFile = setUpProject(largeTemplateLocation) + runAssertsAndClean(stackName) { + val changeSetArn = createChangeSet(templateFile, stackName, hasImage = false, parameters = mapOf("InstanceType" to "t2.small")) + + assertThat(changeSetArn).isNotNull + val describeChangeSetResponse = projectRule.project.awsClient().describeChangeSet { + it.stackName(stackName) + it.changeSetName(changeSetArn) + } + + assertThat(describeChangeSetResponse).isNotNull + assertThat(describeChangeSetResponse.parameters()).contains( + Parameter.builder() + .parameterKey("InstanceType") + .parameterValue("t2.small") + .build() + ) + } + } + @Test fun deployAppUsingSamWithParameters() { val stackName = "SamDeployTest-${UUID.randomUUID()}" val templateFile = setUpProject() runAssertsAndClean(stackName) { - val changeSetArn = createChangeSet(templateFile, stackName, mapOf("TestParameter" to "FooBar")) - - assertThat(changeSetArn).isNotNull() + val changeSetArn = createChangeSet(templateFile, stackName, hasImage = false, parameters = mapOf("TestParameter" to "FooBar")) - val describeChangeSetResponse = cfnClient.describeChangeSet { + assertThat(changeSetArn).isNotNull + val describeChangeSetResponse = projectRule.project.awsClient().describeChangeSet { it.stackName(stackName) it.changeSetName(changeSetArn) } @@ -100,13 +128,83 @@ class SamDeployTest { } } - private fun setUpProject(): VirtualFile { + @Test + fun deployImageBasedSamApp() { + assumeImageSupport() + val stackName = "SamDeployTest-${UUID.randomUUID()}" + val (_, templateFile) = readProject( + projectRule = projectRule, + relativePath = "samProjects/image/java11/maven", + sourceFileName = "App.java" + ) + runAssertsAndClean(stackName) { + val changeSetArn = createChangeSet(templateFile, stackName, hasImage = true) + + assertThat(changeSetArn).isNotNull + val describeChangeSetResponse = projectRule.project.awsClient().describeChangeSet { + it.stackName(stackName) + it.changeSetName(changeSetArn) + } + + assertThat(describeChangeSetResponse).isNotNull + assertThat(describeChangeSetResponse.parameters()).isEmpty() + } + } + + @Test + fun deployAppUsingSamWithTags() { + val stackName = "SamDeployTest-${UUID.randomUUID()}" + val templateFile = setUpProject() + runAssertsAndClean(stackName) { + val changeSetArn = createChangeSet( + templateFile, + stackName, + hasImage = false, + tags = mapOf( + "TestTag" to "FooBar", + "some:gross" to "tag name and value", + // SAM test cases https://github.com/aws/aws-sam-cli/pull/1798/files + "a+-=._:/@" to "b+-=._:/@", + "--c=" to "=d/" + ) + ) + + assertThat(changeSetArn).isNotNull + + val describeChangeSetResponse = projectRule.project.awsClient().describeChangeSet { + it.stackName(stackName) + it.changeSetName(changeSetArn) + } + + assertThat(describeChangeSetResponse).isNotNull + assertThat(describeChangeSetResponse.tags()).containsExactlyInAnyOrder( + Tag.builder() + .key("TestTag") + .value("FooBar") + .build(), + Tag.builder() + .key("some:gross") + .value("tag name and value") + .build(), + Tag.builder() + .key("a+-=._:/@") + .value("b+-=._:/@") + .build(), + Tag.builder() + .key("--c=") + .value("=d/") + .build() + ) + } + } + + private fun setUpProject(templateFilePath: String? = null): VirtualFile { projectRule.fixture.addFileToProject( "hello_world/app.py", """ def lambda_handler(event, context): return "Hello world" - """.trimIndent() + """.trimIndent() ) projectRule.fixture.addFileToProject( @@ -114,9 +212,10 @@ class SamDeployTest { "" ) - return projectRule.fixture.addFileToProject( - "template.yaml", - """ + return if (templateFilePath == null) { + projectRule.fixture.addFileToProject( + "template.yaml", + """ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Parameters: @@ -132,31 +231,56 @@ class SamDeployTest { Properties: Handler: hello_world/app.lambda_handler CodeUri: . - Runtime: python2.7 + Runtime: python3.8 Timeout: 900 """.trimIndent() - ).virtualFile + ).virtualFile + } else { + projectRule.fixture.addFileToProject("template.yaml", File(templateFilePath).readText()).virtualFile + } } - private fun createChangeSet(templateFile: VirtualFile, stackName: String, parameters: Map = emptyMap()): String? { + private fun createChangeSet( + templateFile: VirtualFile, + stackName: String, + hasImage: Boolean, + parameters: Map = emptyMap(), + tags: Map = emptyMap() + ): String? { + val bucket = bucketRule.createBucket(stackName) + val ecrRepo = if (hasImage) repositoryRule.createRepository(stackName).repositoryUri() else null + + var changeSetArn: String? = null val deployDialog = runInEdtAndGet { - runUnderRealCredentials(projectRule.project) { - SamDeployDialog( + val workflow = StepExecutor( + projectRule.project, + createDeployWorkflow( projectRule.project, - stackName, templateFile, - parameters, - bucketRule.createBucket(stackName), - false, - true, - CreateCapabilities.values().toList() - ).also { - Disposer.register(projectRule.fixture.testRootDisposable, it.disposable) - } + DeployServerlessApplicationSettings( + stackName = stackName, + bucket = bucket, + ecrRepo = ecrRepo, + autoExecute = true, + parameters = parameters, + tags = tags, + useContainer = false, + capabilities = CreateCapabilities.values().toList() + ) + ), + ConsoleViewWorkflowEmitter.createEmitter(stackName) + ) + + workflow.onSuccess = { + changeSetArn = it.getRequiredAttribute(DeployLambda.CHANGE_SET_ARN) } + + workflow.startExecution() } - return deployDialog.deployFuture.get(5, TimeUnit.MINUTES) + deployDialog.waitFor(TimeUnit.MINUTES.toMillis(5)) + + return changeSetArn } private fun runAssertsAndClean(stackName: String, asserts: () -> Unit) { @@ -164,7 +288,7 @@ class SamDeployTest { asserts.invoke() } finally { try { - cfnClient.deleteStack { + projectRule.project.awsClient().deleteStack { it.stackName(stackName) } } catch (e: Exception) { 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 deleted file mode 100644 index 5400806dd6..0000000000 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -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.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 { - @Rule - @JvmField - val projectRule = HeavyJavaCodeInsightTestFixtureRule() - - @Rule - @JvmField - val envVarsRule = EnvironmentVariableHelper() - - private val sut = JavaLambdaBuilder() - - @Before - fun setUp() { - setSamExecutableFromEnvironment() - - envVarsRule.remove("JAVA_HOME") - - projectRule.fixture.addModule("main") - projectRule.setUpJdk() - } - - @Test - fun gradleBuiltFromHandler() { - val handlerPsi = projectRule.setUpGradleProject() - - 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" - ) - } - - @Test - fun gradleBuiltFromTemplate() { - projectRule.setUpGradleProject() - - val templateFile = projectRule.fixture.addFileToModule( - projectRule.module, - "template.yaml", - """ - Resources: - SomeFunction: - Type: AWS::Serverless::Function - Properties: - Handler: com.example.SomeClass - CodeUri: . - Runtime: java8 - Timeout: 900 - """.trimIndent() - ) - val templatePath = Paths.get(templateFile.virtualFile.path) - - val builtLambda = sut.buildLambdaFromTemplate(projectRule.module, templatePath, "SomeFunction") - LambdaBuilderTestUtils.verifyEntries( - builtLambda, - "com/example/SomeClass.class", - "lib/aws-lambda-java-core-1.2.0.jar" - ) - } - - @Test - fun gradlePackage() { - val handlerPsi = projectRule.setUpGradleProject() - - 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" - ) - } - - @Test - fun mavenBuiltFromHandler() { - val handlerPsi = projectRule.setUpMavenProject() - - 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" - ) - } - - @Test - fun mavenBuiltFromTemplate() { - projectRule.setUpMavenProject() - - val templateFile = projectRule.fixture.addFileToModule( - projectRule.module, - "template.yaml", - """ - Resources: - SomeFunction: - Type: AWS::Serverless::Function - Properties: - Handler: com.example.SomeClass - CodeUri: . - Runtime: java8 - Timeout: 900 - """.trimIndent() - ) - val templatePath = Paths.get(templateFile.virtualFile.path) - - val builtLambda = sut.buildLambdaFromTemplate(projectRule.module, templatePath, "SomeFunction") - LambdaBuilderTestUtils.verifyEntries( - builtLambda, - "com/example/SomeClass.class", - "lib/aws-lambda-java-core-1.2.0.jar" - ) - } - - @Test - fun mavenPackage() { - val handlerPsi = projectRule.setUpMavenProject() - - 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" - ) - } - - @Test - fun unsupportedSystem() { - val handlerPsi = projectRule.fixture.addClass( - """ - package com.example; - - public class SomeClass { - public static String upperCase(String input) { - return input.toUpperCase(); - } - } - """.trimIndent() - ) - - assertThatThrownBy { - 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 f426e51644..d3e847f0ba 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 @@ -14,28 +14,33 @@ import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized import software.amazon.awssdk.auth.credentials.AwsBasicCredentials -import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.core.utils.RuleUtils import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager import software.aws.toolkits.jetbrains.services.lambda.execution.local.createHandlerBasedRunConfiguration import software.aws.toolkits.jetbrains.services.lambda.execution.local.createTemplateRunConfiguration import software.aws.toolkits.jetbrains.utils.addBreakpoint import software.aws.toolkits.jetbrains.utils.checkBreakPointHit -import software.aws.toolkits.jetbrains.utils.executeRunConfiguration +import software.aws.toolkits.jetbrains.utils.executeRunConfigurationAndWait import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule import software.aws.toolkits.jetbrains.utils.rules.addClass +import software.aws.toolkits.jetbrains.utils.rules.addFileToModule import software.aws.toolkits.jetbrains.utils.rules.addModule +import software.aws.toolkits.jetbrains.utils.samImageRunDebugTest 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) { +class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: LambdaRuntime) { companion object { @JvmStatic @Parameterized.Parameters(name = "{0}") - fun data(): Collection> = listOf( - arrayOf(Runtime.JAVA8), - arrayOf(Runtime.JAVA11) + fun data() = listOf( + arrayOf(LambdaRuntime.JAVA8), + arrayOf(LambdaRuntime.JAVA8_AL2), + arrayOf(LambdaRuntime.JAVA11), + arrayOf(LambdaRuntime.JAVA17) ) } @@ -45,6 +50,7 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim private val mockId = "MockCredsId" private val mockCreds = AwsBasicCredentials.create("Access", "ItsASecret") + private val input = RuleUtils.randomName() @Before fun setUp() { @@ -66,8 +72,9 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim ) val compatibility = when (runtime) { - Runtime.JAVA8 -> "1.8" - Runtime.JAVA11 -> "11" + LambdaRuntime.JAVA8, LambdaRuntime.JAVA8_AL2 -> "1.8" + LambdaRuntime.JAVA11 -> "11" + LambdaRuntime.JAVA17 -> "17" else -> throw NotImplementedError() } @@ -92,13 +99,30 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim fun samIsExecuted() { val runConfiguration = createHandlerBasedRunConfiguration( project = projectRule.project, - runtime = runtime, + runtime = runtime.toSdkRuntime(), input = "\"Hello World\"", credentialsProviderId = mockId ) assertThat(runConfiguration).isNotNull - val executeLambda = executeRunConfiguration(runConfiguration) + val executeLambda = executeRunConfigurationAndWait(runConfiguration) + + assertThat(executeLambda.exitCode).isEqualTo(0) + assertThat(executeLambda.stdout).contains("HELLO WORLD") + } + + @Test + fun samIsExecutedWithFileInput() { + val runConfiguration = createHandlerBasedRunConfiguration( + project = projectRule.project, + runtime = runtime.toSdkRuntime(), + input = projectRule.fixture.tempDirFixture.createFile("tmp", "\"Hello World\"").canonicalPath!!, + inputIsFile = true, + credentialsProviderId = mockId + ) + assertThat(runConfiguration).isNotNull + + val executeLambda = executeRunConfigurationAndWait(runConfiguration) assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("HELLO WORLD") @@ -106,17 +130,19 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim @Test fun samIsExecutedWhenRunWithATemplateServerless() { - val templateFile = projectRule.fixture.addFileToProject( - "template.yaml", """ + val templateFile = projectRule.fixture.addFileToModule( + projectRule.module, + "template.yaml", + """ Resources: SomeFunction: Type: AWS::Serverless::Function Properties: Handler: com.example.LambdaHandler::handleRequest - CodeUri: main + CodeUri: . Runtime: $runtime Timeout: 900 - """.trimIndent() + """.trimIndent() ) val runConfiguration = createTemplateRunConfiguration( @@ -129,7 +155,7 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim assertThat(runConfiguration).isNotNull - val executeLambda = executeRunConfiguration(runConfiguration) + val executeLambda = executeRunConfigurationAndWait(runConfiguration) assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("HELLO WORLD") @@ -137,17 +163,19 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim @Test fun samIsExecutedWhenRunWithATemplateLambda() { - val templateFile = projectRule.fixture.addFileToProject( - "template.yaml", """ + val templateFile = projectRule.fixture.addFileToModule( + projectRule.module, + "template.yaml", + """ Resources: SomeFunction: Type: AWS::Lambda::Function Properties: Handler: com.example.LambdaHandler::handleRequest - Code: main + Code: . Runtime: $runtime Timeout: 900 - """.trimIndent() + """.trimIndent() ) val runConfiguration = createTemplateRunConfiguration( @@ -160,7 +188,7 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim assertThat(runConfiguration).isNotNull - val executeLambda = executeRunConfiguration(runConfiguration) + val executeLambda = executeRunConfigurationAndWait(runConfiguration) assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("HELLO WORLD") @@ -172,7 +200,7 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim val runConfiguration = createHandlerBasedRunConfiguration( project = projectRule.project, - runtime = runtime, + runtime = runtime.toSdkRuntime(), input = "\"Hello World\"", credentialsProviderId = mockId ) @@ -180,11 +208,34 @@ class JavaLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtim val debuggerIsHit = checkBreakPointHit(projectRule.project) - val executeLambda = executeRunConfiguration(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) + val executeLambda = executeRunConfigurationAndWait(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("HELLO WORLD") assertThat(debuggerIsHit.get()).isTrue() } + + @Test + fun samIsExecutedWhenRunWithATemplateImage(): Unit = samImageRunDebugTest( + projectRule = projectRule, + relativePath = "samProjects/image/$runtime/maven", + sourceFileName = "App.java", + runtime = runtime, + mockCredentialsId = mockId, + input = input, + expectedOutput = input.uppercase() + ) + + @Test + fun samIsExecutedWithDebuggerImage(): Unit = samImageRunDebugTest( + projectRule = projectRule, + relativePath = "samProjects/image/$runtime/maven", + sourceFileName = "App.java", + runtime = runtime, + mockCredentialsId = mockId, + input = input, + expectedOutput = input.uppercase(), + addBreakpoint = { projectRule.addBreakpoint() } + ) } 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 deleted file mode 100644 index 1cdda3bfa9..0000000000 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaBuilderTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.python - -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.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 { - @Rule - @JvmField - val projectRule = PythonCodeInsightTestFixtureRule() - - private val sut = PythonLambdaBuilder() - - @Before - fun setUp() { - setSamExecutableFromEnvironment() - } - - @Test - fun contentRootIsAdded() { - val module = projectRule.module - val handler = addPythonHandler("hello_world") - addRequirementsFile("") - val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "hello_world/app.handle") - LambdaBuilderTestUtils.verifyEntries( - builtLambda, - "hello_world/app.py", - "requirements.txt" - ) - LambdaBuilderTestUtils.verifyPathMappings( - module, - builtLambda, - "%PROJECT_ROOT%" to "/", - "%BUILD_ROOT%" to "/" - ) - } - - @Test - fun sourceRootTakesPrecedenceOverContentRoot() { - val module = projectRule.module - val handler = addPythonHandler("src") - addRequirementsFile("src") - - PsiTestUtil.addSourceRoot(projectRule.module, handler.containingFile.virtualFile.parent) - - val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "app.handle") - LambdaBuilderTestUtils.verifyEntries( - builtLambda, - "app.py", - "requirements.txt" - ) - LambdaBuilderTestUtils.verifyPathMappings( - module, - builtLambda, - "%PROJECT_ROOT%/src" to "/", - "%BUILD_ROOT%" to "/" - ) - } - - @Test - fun baseDirBasedOnRequirementsFileAtRootOfHandler() { - val module = projectRule.module - val handler = addPythonHandler("src") - addRequirementsFile("src") - - val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "app.handle") - LambdaBuilderTestUtils.verifyEntries( - builtLambda, - "app.py", - "requirements.txt" - ) - LambdaBuilderTestUtils.verifyPathMappings( - module, - builtLambda, - "%PROJECT_ROOT%/src" to "/", - "%BUILD_ROOT%" to "/" - ) - } - - @Test - fun dependenciesAreAdded() { - val module = projectRule.module - val handler = addPythonHandler("hello_world") - addRequirementsFile("", "requests==2.20.0") - - val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "hello_world/app.handle") - LambdaBuilderTestUtils.verifyEntries( - builtLambda, - "hello_world/app.py", - "requests/__init__.py" - ) - LambdaBuilderTestUtils.verifyPathMappings( - module, - builtLambda, - "%PROJECT_ROOT%" to "/", - "%BUILD_ROOT%" to "/" - ) - } - - @Test - fun builtFromTemplate() { - val module = projectRule.module - addPythonHandler("hello_world") - addRequirementsFile("hello_world", "requests==2.20.0") - val templateFile = projectRule.fixture.addFileToProject( - "template.yaml", - """ - Resources: - SomeFunction: - Type: AWS::Serverless::Function - Properties: - Handler: app.handle - CodeUri: hello_world - Runtime: python3.6 - Timeout: 900 - """.trimIndent() - ) - val templatePath = Paths.get(templateFile.virtualFile.path) - - val builtLambda = sut.buildLambdaFromTemplate(module, templatePath, "SomeFunction") - LambdaBuilderTestUtils.verifyEntries( - builtLambda, - "app.py", - "requests/__init__.py" - ) - LambdaBuilderTestUtils.verifyPathMappings( - module, - builtLambda, - "%PROJECT_ROOT%/hello_world" to "/", - "%BUILD_ROOT%" to "/" - ) - } - - @Test - fun packageLambdaIntoZip() { - val handler = addPythonHandler("hello_world") - addRequirementsFile("", "requests==2.20.0") - - val lambdaPackage = sut.packageLambda(projectRule.module, handler, Runtime.PYTHON3_6, "hello_world/app.handle") - LambdaBuilderTestUtils.verifyZipEntries( - lambdaPackage, - "hello_world/app.py", - "requests/__init__.py" - ) - } - - @Test - fun buildInContainer() { - val module = projectRule.module - val handler = addPythonHandler("hello_world") - addRequirementsFile("") - val builtLambda = sut.buildLambda(module, handler, Runtime.PYTHON3_6, "hello_world/app.handle", true) - LambdaBuilderTestUtils.verifyEntries( - builtLambda, - "hello_world/app.py", - "requirements.txt" - ) - LambdaBuilderTestUtils.verifyPathMappings( - module, - builtLambda, - "%PROJECT_ROOT%" to "/", - "%BUILD_ROOT%" to "/" - ) - } - - @Test - fun packageInContainer() { - val handler = addPythonHandler("hello_world") - addRequirementsFile("", "requests==2.20.0") - - 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" - ) - } - - private fun addPythonHandler(subPath: String): PyFunction { - val psiFile = projectRule.fixture.addFileToProject( - "$subPath/app.py", - """ - def handle(event, context): - return "HelloWorld" - """.trimIndent() - ) as PyFile - - return runInEdtAndGet { - psiFile.findTopLevelFunction("handle")!! - } - } - - private fun addRequirementsFile(subPath: String, content: String = "") { - projectRule.fixture.addFileToProject("$subPath/requirements.txt", content) - } -} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/python/PythonLocalLambdaRunConfigurationIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/python/PythonLocalLambdaRunConfigurationIntegrationTest.kt index ec26ff6c21..d3d20a79d8 100644 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/python/PythonLocalLambdaRunConfigurationIntegrationTest.kt +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/python/PythonLocalLambdaRunConfigurationIntegrationTest.kt @@ -3,9 +3,11 @@ package software.aws.toolkits.jetbrains.services.lambda.python -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile import com.intellij.testFramework.PsiTestUtil import com.intellij.testFramework.runInEdtAndWait import org.assertj.core.api.Assertions.assertThat @@ -16,17 +18,24 @@ import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.core.utils.test.aString import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager -import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.core.region.getDefaultRegion import software.aws.toolkits.jetbrains.services.lambda.execution.local.createHandlerBasedRunConfiguration import software.aws.toolkits.jetbrains.services.lambda.execution.local.createTemplateRunConfiguration import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions import software.aws.toolkits.jetbrains.utils.checkBreakPointHit -import software.aws.toolkits.jetbrains.utils.executeRunConfiguration +import software.aws.toolkits.jetbrains.utils.executeRunConfigurationAndWait +import software.aws.toolkits.jetbrains.utils.jsonToMap import software.aws.toolkits.jetbrains.utils.rules.PythonCodeInsightTestFixtureRule import software.aws.toolkits.jetbrains.utils.rules.addBreakpoint +import software.aws.toolkits.jetbrains.utils.samImageRunDebugTest import software.aws.toolkits.jetbrains.utils.setSamExecutableFromEnvironment +import software.aws.toolkits.jetbrains.utils.stopOnPause @RunWith(Parameterized::class) class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runtime) { @@ -34,10 +43,11 @@ class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt @JvmStatic @Parameterized.Parameters(name = "{0}") fun data(): Collection> = listOf( - arrayOf(Runtime.PYTHON2_7), - arrayOf(Runtime.PYTHON3_6), arrayOf(Runtime.PYTHON3_7), - arrayOf(Runtime.PYTHON3_8) + arrayOf(Runtime.PYTHON3_8), + arrayOf(Runtime.PYTHON3_9), + arrayOf(Runtime.PYTHON3_10), + arrayOf(Runtime.PYTHON3_11) ) } @@ -47,6 +57,8 @@ class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt private val mockId = "MockCredsId" private val mockCreds = AwsBasicCredentials.create("Access", "ItsASecret") + private val input = RuleUtils.randomName() + private lateinit var lambdaClass: PsiFile @Before fun setUp() { @@ -58,10 +70,11 @@ class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt "" ) - val psiClass = fixture.addFileToProject( + lambdaClass = fixture.addFileToProject( "src/hello_world/app.py", """ import os + import time def lambda_handler(event, context): print(os.environ) @@ -69,11 +82,16 @@ class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt def env_print(event, context): return dict(**os.environ) + + def run_forever(event, context): + print(os.environ) + while true: + time.sleep(1) """.trimIndent() ) runInEdtAndWait { - fixture.openFileInEditor(psiClass.containingFile.virtualFile) + fixture.openFileInEditor(lambdaClass.virtualFile) } MockCredentialsManager.getInstance().addCredentials(mockId, mockCreds) @@ -85,110 +103,147 @@ class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt } @Test - fun samIsExecuted() { + fun samIsExecutedWithContainer() { projectRule.fixture.addFileToProject("requirements.txt", "") + val samOptions = SamOptions().apply { + this.buildInContainer = true + } + val runConfiguration = createHandlerBasedRunConfiguration( project = projectRule.project, runtime = runtime, handler = "src/hello_world.app.lambda_handler", input = "\"Hello World\"", - credentialsProviderId = mockId + credentialsProviderId = mockId, + samOptions = samOptions ) assertThat(runConfiguration).isNotNull - val executeLambda = executeRunConfiguration(runConfiguration) + val executeLambda = executeRunConfigurationAndWait(runConfiguration) + assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("Hello world") } @Test - fun samIsExecutedWithContainer() { + fun samIsExecuted() { projectRule.fixture.addFileToProject("requirements.txt", "") - val samOptions = SamOptions().apply { - this.buildInContainer = true - } + val envVars = mutableMapOf("Foo" to "Bar", "Bat" to "Baz") val runConfiguration = createHandlerBasedRunConfiguration( project = projectRule.project, runtime = runtime, - handler = "src/hello_world.app.lambda_handler", + handler = "src/hello_world.app.env_print", input = "\"Hello World\"", credentialsProviderId = mockId, - samOptions = samOptions + environmentVariables = envVars ) assertThat(runConfiguration).isNotNull - val executeLambda = executeRunConfiguration(runConfiguration) + val executeLambda = executeRunConfigurationAndWait(runConfiguration) assertThat(executeLambda.exitCode).isEqualTo(0) - assertThat(executeLambda.stdout).contains("Hello world") + assertThat(jsonToMap(executeLambda.stdout)) + .describedAs("Environment variables are passed") + .containsEntry("Foo", "Bar") + .containsEntry("Bat", "Baz") + assertThat(jsonToMap(executeLambda.stdout)) + .describedAs("Region is set") + .containsEntry("AWS_REGION", getDefaultRegion().id) + assertThat(jsonToMap(executeLambda.stdout)) + .describedAs("Credentials are passed") + .containsEntry("AWS_ACCESS_KEY_ID", mockCreds.accessKeyId()) + .containsEntry("AWS_SECRET_ACCESS_KEY", mockCreds.secretAccessKey()) + // An empty AWS_SESSION_TOKEN is inserted by Samcli/the Lambda runtime as of 1.13.1 + .containsEntry("AWS_SESSION_TOKEN", "") } @Test - fun envVarsArePassed() { + fun fileContentsAreSavedBeforeRunning() { projectRule.fixture.addFileToProject("requirements.txt", "") - val envVars = mutableMapOf("Foo" to "Bar", "Bat" to "Baz") + val randomString = aString() + runInEdtAndWait { + WriteCommandAction.runWriteCommandAction(projectRule.project) { + val document = FileDocumentManager.getInstance().getDocument(lambdaClass.virtualFile)!! + document.replaceString( + 0, + document.textLength, + """ + def print_string(event, context): + return "$randomString" + """.trimIndent() + ) + PsiDocumentManager.getInstance(projectRule.project).commitDocument(document) + } + } val runConfiguration = createHandlerBasedRunConfiguration( project = projectRule.project, runtime = runtime, - handler = "src/hello_world.app.env_print", + handler = "src/hello_world.app.print_string", input = "\"Hello World\"", credentialsProviderId = mockId, - environmentVariables = envVars ) assertThat(runConfiguration).isNotNull - val executeLambda = executeRunConfiguration(runConfiguration) + val executeLambda = executeRunConfigurationAndWait(runConfiguration) assertThat(executeLambda.exitCode).isEqualTo(0) - assertThat(jsonToMap(executeLambda.stdout)) - .containsEntry("Foo", "Bar") - .containsEntry("Bat", "Baz") + assertThat(executeLambda.stdout) + .describedAs("Random string is printed") + .contains(randomString) } @Test - fun regionIsPassed() { + fun samIsExecutedWithFileInput() { projectRule.fixture.addFileToProject("requirements.txt", "") + val envVars = mutableMapOf("Foo" to "Bar", "Bat" to "Baz") + val runConfiguration = createHandlerBasedRunConfiguration( project = projectRule.project, runtime = runtime, handler = "src/hello_world.app.env_print", - input = "\"Hello World\"", - credentialsProviderId = mockId + input = projectRule.fixture.tempDirFixture.createFile("tmp", "Hello World").canonicalPath!!, + inputIsFile = true, + credentialsProviderId = mockId, + environmentVariables = envVars ) assertThat(runConfiguration).isNotNull - val executeLambda = executeRunConfiguration(runConfiguration) + val executeLambda = executeRunConfigurationAndWait(runConfiguration) assertThat(executeLambda.exitCode).isEqualTo(0) - assertThat(jsonToMap(executeLambda.stdout)) - .containsEntry("AWS_REGION", MockRegionProvider.getInstance().defaultRegion().id) } @Test - fun credentialsArePassed() { + fun sessionCredentialsArePassed() { projectRule.fixture.addFileToProject("requirements.txt", "") + val mockSessionId = "mockSessionId" + val mockSessionCreds = AwsSessionCredentials.create("access", "secret", "session") + + MockCredentialsManager.getInstance().addCredentials(mockSessionId, mockSessionCreds) + val runConfiguration = createHandlerBasedRunConfiguration( project = projectRule.project, runtime = runtime, handler = "src/hello_world.app.env_print", input = "\"Hello World\"", - credentialsProviderId = mockId + credentialsProviderId = mockSessionId ) assertThat(runConfiguration).isNotNull - val executeLambda = executeRunConfiguration(runConfiguration) + val executeLambda = executeRunConfigurationAndWait(runConfiguration) assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(jsonToMap(executeLambda.stdout)) - .containsEntry("AWS_ACCESS_KEY_ID", mockCreds.accessKeyId()) - .containsEntry("AWS_SECRET_ACCESS_KEY", mockCreds.secretAccessKey()) + .containsEntry("AWS_ACCESS_KEY_ID", mockSessionCreds.accessKeyId()) + .containsEntry("AWS_SECRET_ACCESS_KEY", mockSessionCreds.secretAccessKey()) + .containsEntry("AWS_SESSION_TOKEN", mockSessionCreds.sessionToken()) } @Test @@ -207,9 +262,9 @@ class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt projectRule.addBreakpoint() val debuggerIsHit = checkBreakPointHit(projectRule.project) - val executeLambda = executeRunConfiguration(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) + val executeLambda = executeRunConfigurationAndWait(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) - // assertThat(executeLambda.exitCode).isEqualTo(0) TODO: When debugging, always exits with 137 + assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("Hello world") assertThat(debuggerIsHit.get()).isTrue() @@ -234,9 +289,9 @@ class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt projectRule.addBreakpoint() val debuggerIsHit = checkBreakPointHit(projectRule.project) - val executeLambda = executeRunConfiguration(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) + val executeLambda = executeRunConfigurationAndWait(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) - // assertThat(executeLambda.exitCode).isEqualTo(0) TODO: When debugging, always exits with 137 + assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("Hello world") assertThat(debuggerIsHit.get()).isTrue() @@ -275,14 +330,37 @@ class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt projectRule.addBreakpoint() val debuggerIsHit = checkBreakPointHit(projectRule.project) - val executeLambda = executeRunConfiguration(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) + val executeLambda = executeRunConfigurationAndWait(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) - // assertThat(executeLambda.exitCode).isEqualTo(0) TODO: When debugging, always exits with 137 + assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("Hello world") assertThat(debuggerIsHit.get()).isTrue() } + @Test + fun samIsExecutedImage(): Unit = samImageRunDebugTest( + projectRule = projectRule, + relativePath = "samProjects/image/$runtime", + sourceFileName = "app.py", + runtime = LambdaRuntime.fromValue(runtime)!!, + mockCredentialsId = mockId, + input = input, + expectedOutput = input.uppercase() + ) + + @Test + fun samIsExecutedWithDebuggerImage(): Unit = samImageRunDebugTest( + projectRule = projectRule, + relativePath = "samProjects/image/$runtime", + sourceFileName = "app.py", + runtime = LambdaRuntime.fromValue(runtime)!!, + mockCredentialsId = mockId, + input = input, + expectedOutput = input.uppercase(), + addBreakpoint = { projectRule.addBreakpoint() } + ) + @Test fun samIsExecutedWithTemplateWithLocalCodeUri() { val templateFile = projectRule.fixture.addFileToProject( @@ -314,13 +392,32 @@ class PythonLocalLambdaRunConfigurationIntegrationTest(private val runtime: Runt projectRule.addBreakpoint() val debuggerIsHit = checkBreakPointHit(projectRule.project) - val executeLambda = executeRunConfiguration(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) + val executeLambda = executeRunConfigurationAndWait(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) - // assertThat(executeLambda.exitCode).isEqualTo(0) TODO: When debugging, always exits with 137 + assertThat(executeLambda.exitCode).isEqualTo(0) assertThat(executeLambda.stdout).contains("Hello world") assertThat(debuggerIsHit.get()).isTrue() } - private fun jsonToMap(data: String) = jacksonObjectMapper().readValue>(data) + @Test + fun stopDebuggerStopsSamCli() { + projectRule.fixture.addFileToProject("requirements.txt", "") + + val runConfiguration = createHandlerBasedRunConfiguration( + project = projectRule.project, + runtime = runtime, + handler = "src/hello_world.app.run_forever", + input = "\"Hello World\"", + credentialsProviderId = mockId, + ) + assertThat(runConfiguration).isNotNull + + projectRule.addBreakpoint() + stopOnPause(projectRule.project) + + val executeLambda = executeRunConfigurationAndWait(runConfiguration, DefaultDebugExecutor.EXECUTOR_ID) + + assertThat(executeLambda.exitCode).isEqualTo(0) + } } diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionIntegrationTest.kt new file mode 100644 index 0000000000..09d1488061 --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionIntegrationTest.kt @@ -0,0 +1,218 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.testFramework.DisposableRule +import com.intellij.testFramework.RuleChain +import com.intellij.testFramework.RunAll +import com.intellij.testFramework.runInEdtAndGet +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.ecr.EcrClient +import software.amazon.awssdk.services.iam.IamClient +import software.amazon.awssdk.services.iam.model.Role +import software.amazon.awssdk.services.lambda.LambdaClient +import software.amazon.awssdk.services.lambda.model.ResourceNotFoundException +import software.amazon.awssdk.services.lambda.model.Runtime +import software.amazon.awssdk.services.s3.model.Bucket +import software.aws.toolkits.core.rules.S3TemporaryBucketRule +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.core.utils.createIntegrationTestCredentialProvider +import software.aws.toolkits.jetbrains.core.MockClientManager +import software.aws.toolkits.jetbrains.core.MockResourceCacheRule +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.MockAwsConnectionManager.ProjectAccountSettingsManagerRule +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerRule +import software.aws.toolkits.jetbrains.core.credentials.activeRegion +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository +import software.aws.toolkits.jetbrains.services.iam.Iam.createRoleWithPolicy +import software.aws.toolkits.jetbrains.services.iam.IamResources +import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources +import software.aws.toolkits.jetbrains.utils.assumeImageSupport +import software.aws.toolkits.jetbrains.utils.execution.steps.StepExecutor +import software.aws.toolkits.jetbrains.utils.readProject +import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule +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.waitToLoad +import java.time.Duration + +class CreateFunctionIntegrationTest { + private val projectRule = HeavyJavaCodeInsightTestFixtureRule() + private val resourceCache = MockResourceCacheRule() + private val disposableRule = DisposableRule() + private val credentialManager = MockCredentialManagerRule() + private val settingsManager = ProjectAccountSettingsManagerRule(projectRule) + private val temporaryBucket = S3TemporaryBucketRule { projectRule.project.awsClient() } + + @Rule + @JvmField + // we need to control the life cycle of what gets started and shut down in a very specific way. + // i.e. disposable needs to be near front of chain so it is first and last so that the ability to make real AWS calls lives for the life of full test + val ruleChain = RuleChain( + projectRule, + disposableRule, + credentialManager, + settingsManager, + temporaryBucket + ) + + private lateinit var lambdaClient: LambdaClient + private lateinit var iamClient: IamClient + private lateinit var ecrClient: EcrClient + + private lateinit var lambdaName: String + private lateinit var iamRole: Role + + @Before + fun setUp() { + setSamExecutableFromEnvironment() + projectRule.fixture.addModule("main") + + // Make sure this is called before we use real credentials since if we assume a role, we will trigger SDK client creation and that will show up as a + // leaked thread in the idle connection reaper + // TODO: To defend against this we should make a AwsSdkClient that throws telling people to use this method + MockClientManager.useRealImplementations(disposableRule.disposable) + + val region = AwsRegionProvider.getInstance()[Region.US_WEST_2.id()]!! + val credentials = credentialManager.addCredentials("ReadCreds", createIntegrationTestCredentialProvider(), region.id) + + settingsManager.settingsManager.changeRegion(region) + settingsManager.settingsManager.changeCredentialProviderAndWait(credentials) + + lambdaClient = projectRule.project.awsClient() + iamClient = projectRule.project.awsClient() + ecrClient = projectRule.project.awsClient() + + lambdaName = RuleUtils.randomName() + iamRole = iamClient.createRoleWithPolicy(RuleUtils.randomName(), DEFAULT_LAMBDA_ASSUME_ROLE_POLICY) + + // Sleep for a while to allow the new IAM role to propagate to other regions + Thread.sleep(Duration.ofSeconds(30).toMillis()) + + resourceCache.addEntry( + projectRule.project, + IamResources.LIST_RAW_ROLES, + listOf(iamRole) + ) + } + + @After + fun tearDown() { + // static method import incompatible when jb converted framework to KT: FIX_WHEN_MIN_IS_212 + RunAll( + { + try { + lambdaClient.deleteFunction { it.functionName(lambdaName) } + } catch (e: Exception) { + if (e !is ResourceNotFoundException) { + throw e + } + } + }, + { + iamClient.deleteRole { it.roleName(iamRole.roleName()) } + } + ).run() + } + + @Test + fun `zip based lambda can be created`() { + val s3Bucket = temporaryBucket.createBucket() + resourceCache.addEntry( + projectRule.project, + "s3.list_buckets", + listOf(S3Resources.RegionalizedBucket(Bucket.builder().name(s3Bucket).build(), projectRule.project.activeRegion())) + ) + + projectRule.setUpGradleProject() + + executeCreateFunction { + val dialog = runInEdtAndGet { + CreateFunctionDialog(projectRule.project, Runtime.JAVA8, "com.example.SomeClass").apply { + val view = getViewForTestAssertions() + view.name.text = lambdaName + view.configSettings.iamRole.selectedItem { iamRole.arn() == it.arn } + view.codeStorage.sourceBucket.selectedItem = s3Bucket + } + } + + val view = dialog.getViewForTestAssertions() + view.codeStorage.sourceBucket.waitToLoad() + view.configSettings.iamRole.waitToLoad() + + runInEdtAndGet { + assertThat(view.validatePanel()?.message).isNull() // Validate we set everything up + dialog.createWorkflow() + } + } + } + + @Test + fun `image based lambda can be created`() { + assumeImageSupport() + val (dockerfile, _) = readProject("samProjects/image/java8/maven", "Dockerfile", projectRule) + val ecrRepo = ecrClient.createRepository { + it.repositoryName(RuleUtils.randomName().lowercase()) + }.repository() + + val repository = Repository(ecrRepo.repositoryName(), ecrRepo.repositoryArn(), ecrRepo.repositoryUri()) + + try { + resourceCache.addEntry( + projectRule.project, + EcrResources.LIST_REPOS, + listOf(repository) + ) + + executeCreateFunction { + val dialog = runInEdtAndGet { + CreateFunctionDialog(projectRule.project, null, null).apply { + val view = getViewForTestAssertions() + view.name.text = lambdaName + view.configSettings.packageImage.isSelected = true + view.configSettings.dockerFile.textField.text = dockerfile.path + view.configSettings.iamRole.selectedItem { iamRole.arn() == it.arn } + view.codeStorage.ecrRepo.selectedItem = repository + } + } + + val view = dialog.getViewForTestAssertions() + view.codeStorage.ecrRepo.waitToLoad() + view.configSettings.iamRole.waitToLoad() + + runInEdtAndGet { + assertThat(view.validatePanel()?.message).isNull() // Validate we set everything up + dialog.createWorkflow() + } + } + } finally { + ecrClient.deleteRepository { + it.repositoryName(repository.repositoryName) + it.force(true) + } + } + } + + private fun executeCreateFunction(workflowBuilder: () -> StepExecutor) { + val workflow = workflowBuilder() + + var passed = false + workflow.onSuccess = { + passed = true + } + + workflow.startExecution().waitFor(Duration.ofMinutes(15).toMillis()) + + assertThat(passed).isTrue() + assertThat(lambdaClient.getFunction { it.functionName(lambdaName) }).isNotNull + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/s3/TransferUtilsIntegrationTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/s3/TransferUtilsIntegrationTest.kt index 1b68979b27..a383a87c79 100644 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/s3/TransferUtilsIntegrationTest.kt +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/s3/TransferUtilsIntegrationTest.kt @@ -44,7 +44,7 @@ class TransferUtilsIntegrationTest { s3Client.upload(projectRule.project, sourceFile.toPath(), bucket, "file", message = "uploading").value val destinationFile = folder.newFile() - s3Client.download(projectRule.project, bucket, "file", destinationFile.toPath(), message = "downloading").value + s3Client.download(projectRule.project, bucket, "file", null, destinationFile.toPath(), message = "downloading").value assertThat(destinationFile).hasSameTextualContentAs(sourceFile) } diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ssm/SsmPluginTest.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ssm/SsmPluginTest.kt new file mode 100644 index 0000000000..be3caf42b0 --- /dev/null +++ b/jetbrains-core/it/software/aws/toolkits/jetbrains/services/ssm/SsmPluginTest.kt @@ -0,0 +1,65 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ssm + +import com.intellij.openapi.util.SystemInfo +import com.intellij.testFramework.ApplicationRule +import com.intellij.util.io.HttpRequests +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.SoftAssertions +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.util.UUID + +class SsmPluginTest { + @Rule + @JvmField + val application = ApplicationRule() + + @Rule + @JvmField + val tempFolder = TemporaryFolder() + + @Test + fun `download URLs all work`() { + val latest = SsmPlugin.determineLatestVersion() + SoftAssertions.assertSoftly { softly -> + listOf( + SsmPlugin.windowsUrl(latest), + SsmPlugin.linuxArm64Url(latest), + SsmPlugin.linuxI64Url(latest), + SsmPlugin.ubuntuArm64Url(latest), + SsmPlugin.ubuntuI64Url(latest), + SsmPlugin.macUrl(latest) + ).forEach { url -> + softly.assertThatCode { HttpRequests.head(url).tryConnect() }.doesNotThrowAnyException() + } + } + } + + @Test + fun `end to end install works`() { + val executableName = if (SystemInfo.isWindows) { + "session-manager-plugin.exe" + } else { + "session-manager-plugin" + } + + val latest = SsmPlugin.determineLatestVersion() + val downloadDir = tempFolder.newFolder().toPath() + val installDir = tempFolder.newFolder() + .resolve("nested1-${UUID.randomUUID()}") + .resolve("nested2-${UUID.randomUUID()}") + .toPath() + + val downloadedFile = SsmPlugin.downloadVersion(latest, downloadDir, null) + SsmPlugin.installVersion(downloadedFile, installDir, null) + val tool = SsmPlugin.toTool(installDir) + assertThat(tool.path.fileName.toString()).isEqualTo(executableName) + + val reportedLatest = SsmPlugin.determineVersion(tool.path) + assertThat(reportedLatest).isEqualTo(latest) + } +} diff --git a/jetbrains-core/it/software/aws/toolkits/jetbrains/utils/TestUtils.kt b/jetbrains-core/it/software/aws/toolkits/jetbrains/utils/TestUtils.kt deleted file mode 100644 index cb84220d45..0000000000 --- a/jetbrains-core/it/software/aws/toolkits/jetbrains/utils/TestUtils.kt +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.utils - -import com.intellij.execution.ExecutorRegistry -import com.intellij.execution.Output -import com.intellij.execution.OutputListener -import com.intellij.execution.configurations.RunConfiguration -import com.intellij.execution.executors.DefaultRunExecutor -import com.intellij.execution.process.ProcessEvent -import com.intellij.execution.process.ProcessOutputType -import com.intellij.execution.process.ProcessOutputTypes -import com.intellij.execution.runners.ExecutionEnvironmentBuilder -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Key -import com.intellij.openapi.util.Ref -import com.intellij.xdebugger.XDebugProcess -import com.intellij.xdebugger.XDebugSessionListener -import com.intellij.xdebugger.XDebuggerManager -import com.intellij.xdebugger.XDebuggerManagerListener -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit -import kotlin.test.assertNotNull - -fun executeRunConfiguration( - runConfiguration: RunConfiguration, - executorId: String = DefaultRunExecutor.EXECUTOR_ID -): Output { - val executor = ExecutorRegistry.getInstance().getExecutorById(executorId) - assertNotNull(executor) - val executionFuture = CompletableFuture() - runInEdt { - val executionEnvironment = ExecutionEnvironmentBuilder.create(executor, runConfiguration).build() - try { - executionEnvironment.runner.execute(executionEnvironment) { - it.processHandler?.addProcessListener(object : OutputListener() { - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - // Ansi codes throw off the default logic, so remap it to check the base type - val processOutputType = outputType as? ProcessOutputType - val baseType = processOutputType?.baseOutputType ?: outputType - - super.onTextAvailable(event, baseType) - - println("[${if (baseType == ProcessOutputTypes.STDOUT) "stdout" else "stderr"}]: ${event.text}") - } - - override fun processTerminated(event: ProcessEvent) { - super.processTerminated(event) - executionFuture.complete(this.output) - } - }) - } - } catch (e: Exception) { - executionFuture.completeExceptionally(e) - } - } - - return executionFuture.get(3, TimeUnit.MINUTES) -} - -fun checkBreakPointHit(project: Project, callback: () -> Unit = {}): Ref { - val debuggerIsHit = Ref(false) - - val messageBusConnection = project.messageBus.connect() - messageBusConnection.subscribe(XDebuggerManager.TOPIC, object : XDebuggerManagerListener { - override fun processStarted(debugProcess: XDebugProcess) { - println("Debugger attached: $debugProcess") - - debugProcess.session.addSessionListener(object : XDebugSessionListener { - override fun sessionPaused() { - runInEdt { - val suspendContext = debugProcess.session.suspendContext - println("Resuming: $suspendContext") - callback() - debuggerIsHit.set(true) - debugProcess.resume(suspendContext) - } - } - - override fun sessionStopped() { - debuggerIsHit.setIfNull(false) // Used to prevent having to wait for max timeout - } - }) - } - }) - - return debuggerIsHit -} diff --git a/jetbrains-core/resources-201/META-INF/plugin-extra.xml b/jetbrains-core/resources-201/META-INF/plugin-extra.xml deleted file mode 100644 index f71673580b..0000000000 --- a/jetbrains-core/resources-201/META-INF/plugin-extra.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/jetbrains-core/resources/META-INF/ext-docker.xml b/jetbrains-core/resources/META-INF/ext-docker.xml new file mode 100644 index 0000000000..2d090a574e --- /dev/null +++ b/jetbrains-core/resources/META-INF/ext-docker.xml @@ -0,0 +1,16 @@ + + + + + software.aws.toolkits.resources.MessagesBundle + + + + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/ext-java.xml b/jetbrains-core/resources/META-INF/ext-java.xml index 02716190c4..1f5bc3a913 100644 --- a/jetbrains-core/resources/META-INF/ext-java.xml +++ b/jetbrains-core/resources/META-INF/ext-java.xml @@ -2,18 +2,73 @@ + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + + - - + + + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/ext-json.xml b/jetbrains-core/resources/META-INF/ext-json.xml new file mode 100644 index 0000000000..8a0b751a35 --- /dev/null +++ b/jetbrains-core/resources/META-INF/ext-json.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/ext-kotlin.xml b/jetbrains-core/resources/META-INF/ext-kotlin.xml new file mode 100644 index 0000000000..1e730401ab --- /dev/null +++ b/jetbrains-core/resources/META-INF/ext-kotlin.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/ext-python.xml b/jetbrains-core/resources/META-INF/ext-python.xml index 6bd2f1407d..cc99b31804 100644 --- a/jetbrains-core/resources/META-INF/ext-python.xml +++ b/jetbrains-core/resources/META-INF/ext-python.xml @@ -5,15 +5,30 @@ + + + + + + + - - - - - + + + + + + + + + + - - + + + + diff --git a/jetbrains-core/resources/META-INF/ext-rust-deprecated.xml b/jetbrains-core/resources/META-INF/ext-rust-deprecated.xml new file mode 100644 index 0000000000..a4cd6ffe01 --- /dev/null +++ b/jetbrains-core/resources/META-INF/ext-rust-deprecated.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/ext-rust.xml b/jetbrains-core/resources/META-INF/ext-rust.xml new file mode 100644 index 0000000000..a4cd6ffe01 --- /dev/null +++ b/jetbrains-core/resources/META-INF/ext-rust.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/ext-scala.xml b/jetbrains-core/resources/META-INF/ext-scala.xml new file mode 100644 index 0000000000..a0a603c624 --- /dev/null +++ b/jetbrains-core/resources/META-INF/ext-scala.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/ext-shell.xml b/jetbrains-core/resources/META-INF/ext-shell.xml new file mode 100644 index 0000000000..963bb7cf1c --- /dev/null +++ b/jetbrains-core/resources/META-INF/ext-shell.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/ext-terminal.xml b/jetbrains-core/resources/META-INF/ext-terminal.xml new file mode 100644 index 0000000000..a04b6d5125 --- /dev/null +++ b/jetbrains-core/resources/META-INF/ext-terminal.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/ext-yaml.xml b/jetbrains-core/resources/META-INF/ext-yaml.xml new file mode 100644 index 0000000000..20e6869da5 --- /dev/null +++ b/jetbrains-core/resources/META-INF/ext-yaml.xml @@ -0,0 +1,10 @@ + + + + + software.aws.toolkits.resources.MessagesBundle + + + + + diff --git a/jetbrains-core/resources/META-INF/inactive/plugin-gateway.xml b/jetbrains-core/resources/META-INF/inactive/plugin-gateway.xml new file mode 100644 index 0000000000..45ba786dc5 --- /dev/null +++ b/jetbrains-core/resources/META-INF/inactive/plugin-gateway.xml @@ -0,0 +1,17 @@ + + + + + com.intellij.modules.platform + + + + com.intellij.modules.androidstudio + com.intellij.modules.idea + com.intellij.modules.java + com.intellij.modules.mps + com.intellij.modules.python + com.intellij.modules.ultimate + com.intellij.java + diff --git a/jetbrains-core/resources/META-INF/plugin-intellij.xml b/jetbrains-core/resources/META-INF/plugin-intellij.xml new file mode 100644 index 0000000000..e0151c443b --- /dev/null +++ b/jetbrains-core/resources/META-INF/plugin-intellij.xml @@ -0,0 +1,7 @@ + + + + + com.intellij.modules.lang + org.jetbrains.plugins.yaml + diff --git a/jetbrains-core/resources/META-INF/plugin.xml b/jetbrains-core/resources/META-INF/plugin.xml index f55a290570..3606cd8910 100644 --- a/jetbrains-core/resources/META-INF/plugin.xml +++ b/jetbrains-core/resources/META-INF/plugin.xml @@ -1,147 +1,248 @@ - + aws.toolkit - AWS Toolkit + AWS Toolkit - Amazon Q, CodeWhisperer, and more 1.0 - The AWS Toolkit is an open-source plug-in for JetBrains IDEs that makes it easier to develop applications built on - AWS. The toolkit helps you create, test, and debug serverless applications built using the AWS Serverless - Application Model (SAM).

-
-

- See Installing the AWS Toolkit for - JetBrains in the AWS Toolkit for JetBrains User Guide. -

-
-

To use this AWS Toolkit, you will first need an AWS account, a user within that account, and an access key for that - user. -

-
-

To use the AWS Toolkit to do AWS serverless application development and to run/debug AWS Lambda functions locally, - you will also need to install the AWS CLI, Docker, and the AWS SAM CLI. The preceding link covers setting up all of - these prerequisites. -

-
-

- If you come across bugs with the toolkit or have feature requests, please raise an issue. -

+

Amazon Q (preview)

+

Amazon Q is an interactive, generative AI-powered assistant that gives you expert guidance when building, troubleshooting, and transforming applications on AWS.

-

Features

+

Amazon Q can help you with the following:

+
    +
  • + Answer questions about AWS +
  • +
  • + Answer questions about general programming concepts +
  • +
  • + Explain what a line of code or code function does +
  • +
  • + Write unit tests and code +
  • +
  • + Debug and fix code +
  • +
  • + Refactor code +
  • +
+ -

SAM features support Java, Python, Node.js, and .NET Core

+

Amazon CodeWhisperer

+

An AI powered productivity tool for the IDE.

  • - New Project Wizard - Get started quickly by using one of the quickstart serverless application - templates Learn More + Real-time code suggestions - automatic code recommendations in 15+ languages, now including infrastructure as code (CloudFormation, AWS CDK, and Terraform)
  • -
  • - Run/Debug Local Lambda Functions - Locally test and step-through debug functions in a - Lambda-like execution environment provided by the AWS SAM CLI - Learn More + Optimized for use with AWS services - code suggestions are optimized for AWS APIs including Amazon Elastic Compute Cloud (Amazon EC2), AWS Lambda, and Amazon Simple Storage Service (Amazon S3)
  • -
  • - Resource Explorer - View your AWS Lambda remote functions & related CloudFormation stacks - Learn More + Built-in security scans - Scan your code to detect hard-to-find vulnerabilities and get code suggestions to remediate them immediately
  • +
+ +

Amazon CodeCatalyst

+

Unified software development service to quickly build and deliver applications on AWS.

+
  • - Invoke Remote Lambda Functions - Invoke remote functions using a sharable run-configuration - Learn More + Dev Environments - launch JetBrains IDEs in a cloud development environment, available on-demand in the cloud and automatically created with branch code and consistent project settings, providing faster setup, development, and testing
  • +
+

+

View, modify, and deploy AWS resources

+ +
    +
  • + Authentication - Connect to AWS using static credentials, credential process, or AWS identity center +
  • +
  • + Resource Explorer - View and manage AWS resources +
  • +
  • + Run/Debug Local Lambda Functions - Locally test and step-through debug functions in a Lambda-like execution environment provided by the AWS SAM CLI. Supports Java, Python, Node.js, and .NET. +
  • +
  • + Deploy SAM-based Applications - Package, deploy track SAM-based applications +
  • +
  • + CloudWatch Logs - View and search CloudWatch log streams +
  • - Deploy SAM-based Applications - Package, deploy & track SAM-based applications - Learn More + S3 Explorer - Manage S3 buckets, and upload to/download from S3 buckets +
  • +
  • + See the user guide for a full list of services and features supported
+ +

About

+

+ The AWS Toolkit for JetBrains makes it easier to write applications built on Amazon Web Services. +

+

+ If you come across bugs with the toolkit or have feature requests, please raise an issue on our GitHub repository. +

+ +

+ See the user guide for how to get started +

]]>
- AWS - + AWS + + + + software.aws.toolkits.resources.MessagesBundle - - com.intellij.modules.lang - org.jetbrains.plugins.yaml + + com.intellij.modules.platform + + + + + + + + + + + + + + + org.jetbrains.idea.maven org.jetbrains.plugins.gradle - org.jetbrains.plugins.terminal + org.jetbrains.plugins.terminal com.intellij.modules.externalSystem - Docker + org.jetbrains.plugins.yaml + Docker com.intellij.modules.java com.intellij.modules.python - JavaScriptDebugger - com.intellij.modules.rider - - + com.intellij.modules.json + com.jetbrains.gateway + org.intellij.scala + org.jetbrains.kotlin + com.jetbrains.sh + org.rust.lang + com.jetbrains.rust + + + + - + + + + + - + + + + + + + + + + + + + + + + - + + - - - - + - - - + + + + - - - + + - - - + + + + - - - + + - - - + + - - - - + + + + + + + + + + + + + + + + @@ -155,14 +256,26 @@ serviceImplementation="software.aws.toolkits.jetbrains.core.credentials.profiles.DefaultProfileWatcher"/> - + testServiceImplementation="software.aws.toolkits.jetbrains.settings.MockAwsSettings"/> + + + + - + + + + + + + - + + + + + + + + + + - - - + + + testServiceImplementation="software.aws.toolkits.jetbrains.services.telemetry.NoOpTelemetryService"/> - - + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - - - - + + + + + + + + - - - + + - + - - - - - - - + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -278,13 +615,31 @@ - - + + + + + + + + + + + + + + + + + + + + - + @@ -302,7 +657,7 @@ - + @@ -311,22 +666,63 @@ class="software.aws.toolkits.jetbrains.services.cloudformation.actions.DeleteStackAction"/> + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - + @@ -353,21 +749,192 @@ class="software.aws.toolkits.jetbrains.services.schemas.code.DownloadCodeForSchemaAction"/> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/resources/META-INF/pluginIcon.svg b/jetbrains-core/resources/META-INF/pluginIcon.svg index 5be381b953..19453f558b 100644 --- a/jetbrains-core/resources/META-INF/pluginIcon.svg +++ b/jetbrains-core/resources/META-INF/pluginIcon.svg @@ -1,17 +1,41 @@ - - - - Artboard 2 - Created with Sketch. - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/pluginIcon_dark.svg b/jetbrains-core/resources/META-INF/pluginIcon_dark.svg new file mode 100644 index 0000000000..cbc43434bd --- /dev/null +++ b/jetbrains-core/resources/META-INF/pluginIcon_dark.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/services/caws-ext-git.xml b/jetbrains-core/resources/META-INF/services/caws-ext-git.xml new file mode 100644 index 0000000000..ff523edf42 --- /dev/null +++ b/jetbrains-core/resources/META-INF/services/caws-ext-git.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/META-INF/services/caws.xml b/jetbrains-core/resources/META-INF/services/caws.xml new file mode 100644 index 0000000000..5dcac4fed7 --- /dev/null +++ b/jetbrains-core/resources/META-INF/services/caws.xml @@ -0,0 +1,28 @@ + + + + + Git4Idea + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/cloudapi/dynamic_resources.json b/jetbrains-core/resources/cloudapi/dynamic_resources.json new file mode 100644 index 0000000000..804c86eacd --- /dev/null +++ b/jetbrains-core/resources/cloudapi/dynamic_resources.json @@ -0,0 +1,2744 @@ +{ + "AWS::ACMPCA::Certificate" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-acmpca-certificate.html" + }, + "AWS::ACMPCA::CertificateAuthority" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):acm-pca:(?[a-z0-9-]+):(?[0-9]{12}):certificate-authority/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-acmpca-certificateauthority.html" + }, + "AWS::ACMPCA::CertificateAuthorityActivation" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-acmpca-certificateauthorityactivation.html" + }, + "AWS::ACMPCA::Permission" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-acmpca-permission.html" + }, + "AWS::APS::RuleGroupsNamespace" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-aps-rulegroupsnamespace.html" + }, + "AWS::APS::Workspace" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-aps-workspace.html" + }, + "AWS::AccessAnalyzer::Analyzer" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):access-analyzer:(?[a-z0-9-]+):(?[0-9]{12}):analyzer/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-accessanalyzer-analyzer.html" + }, + "AWS::Amplify::App" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-amplify-app.html" + }, + "AWS::Amplify::Branch" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-amplify-branch.html" + }, + "AWS::Amplify::Domain" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-amplify-domain.html" + }, + "AWS::AmplifyUIBuilder::Component" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-amplifyuibuilder-component.html" + }, + "AWS::AmplifyUIBuilder::Theme" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-amplifyuibuilder-theme.html" + }, + "AWS::ApiGateway::Account" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/account/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-account.html" + }, + "AWS::ApiGateway::ApiKey" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/apikeys/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-apikey.html" + }, + "AWS::ApiGateway::Authorizer" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/restapis/(?[^/:]+)/authorizers/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-authorizer.html" + }, + "AWS::ApiGateway::BasePathMapping" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/domainnames/(?[^/:]+)/basepathmappings/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-basepathmapping.html" + }, + "AWS::ApiGateway::ClientCertificate" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/clientcertificates/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-clientcertificate.html" + }, + "AWS::ApiGateway::Deployment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/restapis/(?[^/:]+)/deployments/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-deployment.html" + }, + "AWS::ApiGateway::DocumentationPart" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/restapis/(?[^/:]+)/documentation/parts/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-documentationpart.html" + }, + "AWS::ApiGateway::DocumentationVersion" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/restapis/(?[^/:]+)/documentation/versions/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-documentationversion.html" + }, + "AWS::ApiGateway::DomainName" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/domainnames/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-domainname.html" + }, + "AWS::ApiGateway::Method" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/restapis/(?[^/:]+)/resources/(?[^/:]+)/methods/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-method.html" + }, + "AWS::ApiGateway::Model" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/restapis/(?[^/:]+)/models/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-model.html" + }, + "AWS::ApiGateway::RequestValidator" : { + "operations" : [ "CREATE", "UPDATE", "DELETE", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/restapis/(?[^/:]+)/requestvalidators/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-requestvalidator.html" + }, + "AWS::ApiGateway::Resource" : { + "operations" : [ "READ", "CREATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/restapis/(?[^/:]+)/resources/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-resource.html" + }, + "AWS::ApiGateway::Stage" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/restapis/(?[^/:]+)/stages/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-stage.html" + }, + "AWS::ApiGateway::UsagePlan" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/usageplans/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-usageplan.html" + }, + "AWS::ApiGateway::UsagePlanKey" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):apigateway:(?[a-z0-9-]+)::/usageplans/(?[^/:]+)/keys/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-usageplankey.html" + }, + "AWS::ApiGatewayV2::VpcLink" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigatewayv2-vpclink.html" + }, + "AWS::AppFlow::ConnectorProfile" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):appflow:(?[a-z0-9-]+):(?[0-9]{12}):connectorprofile/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appflow-connectorprofile.html" + }, + "AWS::AppFlow::Flow" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):appflow:(?[a-z0-9-]+):(?[0-9]{12}):flow/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appflow-flow.html" + }, + "AWS::AppIntegrations::DataIntegration" : { + "operations" : [ "CREATE", "READ", "LIST", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):app-integrations:(?[a-z0-9-]+):(?[0-9]{12}):data-integration/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appintegrations-dataintegration.html" + }, + "AWS::AppIntegrations::EventIntegration" : { + "operations" : [ "CREATE", "READ", "LIST", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):app-integrations:(?[a-z0-9-]+):(?[0-9]{12}):event-integration/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appintegrations-eventintegration.html" + }, + "AWS::AppRunner::ObservabilityConfiguration" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):apprunner:(?[a-z0-9-]+):(?[0-9]{12}):observabilityconfiguration/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apprunner-observabilityconfiguration.html" + }, + "AWS::AppRunner::Service" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):apprunner:(?[a-z0-9-]+):(?[0-9]{12}):service/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apprunner-service.html" + }, + "AWS::AppRunner::VpcConnector" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):apprunner:(?[a-z0-9-]+):(?[0-9]{12}):vpcconnector/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apprunner-vpcconnector.html" + }, + "AWS::AppStream::AppBlock" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):appstream:(?[a-z0-9-]+):(?[0-9]{12}):app-block/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appstream-appblock.html" + }, + "AWS::AppStream::Application" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):appstream:(?[a-z0-9-]+):(?[0-9]{12}):application/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appstream-application.html" + }, + "AWS::AppStream::ApplicationEntitlementAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appstream-applicationentitlementassociation.html" + }, + "AWS::AppStream::ApplicationFleetAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appstream-applicationfleetassociation.html" + }, + "AWS::AppStream::DirectoryConfig" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appstream-directoryconfig.html" + }, + "AWS::AppStream::Entitlement" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appstream-entitlement.html" + }, + "AWS::AppStream::ImageBuilder" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):appstream:(?[a-z0-9-]+):(?[0-9]{12}):image-builder/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appstream-imagebuilder.html" + }, + "AWS::AppSync::DomainName" : { + "operations" : [ "CREATE", "DELETE", "UPDATE", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appsync-domainname.html" + }, + "AWS::AppSync::DomainNameApiAssociation" : { + "operations" : [ "CREATE", "DELETE", "UPDATE", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appsync-domainnameapiassociation.html" + }, + "AWS::ApplicationInsights::Application" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-applicationinsights-application.html" + }, + "AWS::Athena::DataCatalog" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):athena:(?[a-z0-9-]+):(?[0-9]{12}):datacatalog/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-athena-datacatalog.html" + }, + "AWS::Athena::NamedQuery" : { + "operations" : [ "CREATE", "READ", "LIST", "DELETE", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-athena-namedquery.html" + }, + "AWS::Athena::PreparedStatement" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-athena-preparedstatement.html" + }, + "AWS::Athena::WorkGroup" : { + "operations" : [ "CREATE", "READ", "LIST", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):athena:(?[a-z0-9-]+):(?[0-9]{12}):workgroup/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-athena-workgroup.html" + }, + "AWS::AuditManager::Assessment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):auditmanager:(?[a-z0-9-]+):(?[0-9]{12}):assessment/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-auditmanager-assessment.html" + }, + "AWS::AutoScaling::LaunchConfiguration" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):autoscaling:(?[a-z0-9-]+):(?[0-9]{12}):launchConfiguration:(?[^/:]+):launchConfigurationName/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-autoscaling-launchconfiguration.html" + }, + "AWS::AutoScaling::LifecycleHook" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-autoscaling-lifecyclehook.html" + }, + "AWS::AutoScaling::ScalingPolicy" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-autoscaling-scalingpolicy.html" + }, + "AWS::AutoScaling::WarmPool" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-autoscaling-warmpool.html" + }, + "AWS::Backup::BackupPlan" : { + "operations" : [ "READ", "CREATE", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):backup:(?[a-z0-9-]+):(?[0-9]{12}):backup-plan:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-backup-backupplan.html" + }, + "AWS::Backup::BackupSelection" : { + "operations" : [ "DELETE", "READ", "CREATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-backup-backupselection.html" + }, + "AWS::Backup::BackupVault" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):backup:(?[a-z0-9-]+):(?[0-9]{12}):backup-vault:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-backup-backupvault.html" + }, + "AWS::Backup::Framework" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):backup:(?[a-z0-9-]+):(?[0-9]{12}):framework:(?[^/:]+)-(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-backup-framework.html" + }, + "AWS::Backup::ReportPlan" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):backup:(?[a-z0-9-]+):(?[0-9]{12}):report-plan:(?[^/:]+)-(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-backup-reportplan.html" + }, + "AWS::Batch::ComputeEnvironment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):batch:(?[a-z0-9-]+):(?[0-9]{12}):compute-environment/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-batch-computeenvironment.html" + }, + "AWS::Batch::JobQueue" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):batch:(?[a-z0-9-]+):(?[0-9]{12}):job-queue/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-batch-jobqueue.html" + }, + "AWS::Batch::SchedulingPolicy" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):batch:(?[a-z0-9-]+):(?[0-9]{12}):scheduling-policy/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-batch-schedulingpolicy.html" + }, + "AWS::Budgets::BudgetsAction" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-budgets-budgetsaction.html" + }, + "AWS::CE::CostCategory" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ce::(?[0-9]{12}):costcategory/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ce-costcategory.html" + }, + "AWS::Cassandra::Keyspace" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):cassandra:(?[a-z0-9-]+):(?[0-9]{12}):/keyspace/(?[^/:]+)/", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cassandra-keyspace.html" + }, + "AWS::Cassandra::Table" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):cassandra:(?[a-z0-9-]+):(?[0-9]{12}):/keyspace/(?[^/:]+)/table/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cassandra-table.html" + }, + "AWS::CertificateManager::Account" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-certificatemanager-account.html" + }, + "AWS::Chatbot::SlackChannelConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-chatbot-slackchannelconfiguration.html" + }, + "AWS::CloudFormation::HookDefaultVersion" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-hookdefaultversion.html" + }, + "AWS::CloudFormation::HookTypeConfig" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-hooktypeconfig.html" + }, + "AWS::CloudFormation::HookVersion" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-hookversion.html" + }, + "AWS::CloudFormation::ModuleDefaultVersion" : { + "operations" : [ "CREATE", "DELETE", "READ", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-moduledefaultversion.html" + }, + "AWS::CloudFormation::ModuleVersion" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-moduleversion.html" + }, + "AWS::CloudFormation::PublicTypeVersion" : { + "operations" : [ "CREATE", "DELETE", "READ", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-publictypeversion.html" + }, + "AWS::CloudFormation::Publisher" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-publisher.html" + }, + "AWS::CloudFormation::ResourceDefaultVersion" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-resourcedefaultversion.html" + }, + "AWS::CloudFormation::ResourceVersion" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-resourceversion.html" + }, + "AWS::CloudFormation::StackSet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):cloudformation:(?[a-z0-9-]+):(?[0-9]{12}):stackset/(?[^/:]+):(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-stackset.html" + }, + "AWS::CloudFormation::TypeActivation" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudformation-typeactivation.html" + }, + "AWS::CloudFront::CachePolicy" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):cloudfront::(?[0-9]{12}):cache-policy/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudfront-cachepolicy.html" + }, + "AWS::CloudFront::CloudFrontOriginAccessIdentity" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudfront-cloudfrontoriginaccessidentity.html" + }, + "AWS::CloudFront::Distribution" : { + "operations" : [ "READ", "CREATE", "UPDATE", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):cloudfront::(?[0-9]{12}):distribution/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudfront-distribution.html" + }, + "AWS::CloudFront::Function" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):cloudfront::(?[0-9]{12}):function/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudfront-function.html" + }, + "AWS::CloudFront::KeyGroup" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudfront-keygroup.html" + }, + "AWS::CloudFront::OriginAccessControl" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ] + }, + "AWS::CloudFront::OriginRequestPolicy" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):cloudfront::(?[0-9]{12}):origin-request-policy/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudfront-originrequestpolicy.html" + }, + "AWS::CloudFront::PublicKey" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudfront-publickey.html" + }, + "AWS::CloudFront::RealtimeLogConfig" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):cloudfront::(?[0-9]{12}):realtime-log-config/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudfront-realtimelogconfig.html" + }, + "AWS::CloudFront::ResponseHeadersPolicy" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):cloudfront::(?[0-9]{12}):response-headers-policy/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudfront-responseheaderspolicy.html" + }, + "AWS::CloudTrail::EventDataStore" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):cloudtrail:(?[a-z0-9-]+):(?[0-9]{12}):eventdatastore/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudtrail-eventdatastore.html" + }, + "AWS::CloudTrail::Trail" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):cloudtrail:(?[a-z0-9-]+):(?[0-9]{12}):trail/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudtrail-trail.html" + }, + "AWS::CloudWatch::CompositeAlarm" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-compositealarm.html" + }, + "AWS::CloudWatch::MetricStream" : { + "operations" : [ "CREATE", "UPDATE", "DELETE", "LIST", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):cloudwatch:(?[a-z0-9-]+):(?[0-9]{12}):metric-stream/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-metricstream.html" + }, + "AWS::CodeArtifact::Domain" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):codeartifact:(?[a-z0-9-]+):(?[0-9]{12}):domain/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-codeartifact-domain.html" + }, + "AWS::CodeArtifact::Repository" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):codeartifact:(?[a-z0-9-]+):(?[0-9]{12}):repository/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-codeartifact-repository.html" + }, + "AWS::CodeGuruProfiler::ProfilingGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):codeguru-profiler:(?[a-z0-9-]+):(?[0-9]{12}):profilingGroup/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-codeguruprofiler-profilinggroup.html" + }, + "AWS::CodeGuruReviewer::RepositoryAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):codeguru-reviewer:(?[a-z0-9-]+):(?[0-9]{12}):association:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-codegurureviewer-repositoryassociation.html" + }, + "AWS::CodeStarConnections::Connection" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):codestar-connections:(?[a-z0-9-]+):(?[0-9]{12}):connection/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-codestarconnections-connection.html" + }, + "AWS::CodeStarNotifications::NotificationRule" : { + "operations" : [ "CREATE", "LIST", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):codestar-notifications:(?[a-z0-9-]+):(?[0-9]{12}):notificationrule/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-codestarnotifications-notificationrule.html" + }, + "AWS::Config::AggregationAuthorization" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):config:(?[a-z0-9-]+):(?[0-9]{12}):aggregation-authorization/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-config-aggregationauthorization.html" + }, + "AWS::Config::ConfigurationAggregator" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):config:(?[a-z0-9-]+):(?[0-9]{12}):config-aggregator/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-config-configurationaggregator.html" + }, + "AWS::Config::ConformancePack" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):config:(?[a-z0-9-]+):(?[0-9]{12}):conformance-pack/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-config-conformancepack.html" + }, + "AWS::Config::OrganizationConformancePack" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):config:(?[a-z0-9-]+):(?[0-9]{12}):organization-conformance-pack/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-config-organizationconformancepack.html" + }, + "AWS::Config::StoredQuery" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):config:(?[a-z0-9-]+):(?[0-9]{12}):stored-query/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-config-storedquery.html" + }, + "AWS::Connect::ContactFlow" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):connect:(?[a-z0-9-]+):(?[0-9]{12}):instance/(?[^/:]+)/contact-flow/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-contactflow.html" + }, + "AWS::Connect::ContactFlowModule" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):connect:(?[a-z0-9-]+):(?[0-9]{12}):instance/(?[^/:]+)/flow-module/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-contactflowmodule.html" + }, + "AWS::Connect::HoursOfOperation" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):connect:(?[a-z0-9-]+):(?[0-9]{12}):instance/(?[^/:]+)/operating-hours/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-hoursofoperation.html" + }, + "AWS::Connect::Instance" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):connect:(?[a-z0-9-]+):(?[0-9]{12}):instance/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-instance.html" + }, + "AWS::Connect::InstanceStorageConfig" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-instancestorageconfig.html" + }, + "AWS::Connect::PhoneNumber" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):connect:(?[a-z0-9-]+):(?[0-9]{12}):phone-number/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-phonenumber.html" + }, + "AWS::Connect::QuickConnect" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):connect:(?[a-z0-9-]+):(?[0-9]{12}):instance/(?[^/:]+)/transfer-destination/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-quickconnect.html" + }, + "AWS::Connect::TaskTemplate" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):connect:(?[a-z0-9-]+):(?[0-9]{12}):instance/(?[^/:]+)/task-template/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-tasktemplate.html" + }, + "AWS::Connect::User" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):connect:(?[a-z0-9-]+):(?[0-9]{12}):instance/(?[^/:]+)/agent/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-user.html" + }, + "AWS::Connect::UserHierarchyGroup" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connect-userhierarchygroup.html" + }, + "AWS::ConnectCampaigns::Campaign" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):connect-campaigns:(?[a-z0-9-]+):(?[0-9]{12}):campaign/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-connectcampaigns-campaign.html" + }, + "AWS::ControlTower::EnabledControl" : { + "operations" : [ "CREATE", "DELETE", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-controltower-enabledcontrol.html" + }, + "AWS::CustomerProfiles::Domain" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-customerprofiles-domain.html" + }, + "AWS::CustomerProfiles::Integration" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-customerprofiles-integration.html" + }, + "AWS::CustomerProfiles::ObjectType" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-customerprofiles-objecttype.html" + }, + "AWS::DataBrew::Dataset" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):databrew:(?[a-z0-9-]+):(?[0-9]{12}):dataset/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-databrew-dataset.html" + }, + "AWS::DataBrew::Job" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):databrew:(?[a-z0-9-]+):(?[0-9]{12}):job/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-databrew-job.html" + }, + "AWS::DataBrew::Project" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):databrew:(?[a-z0-9-]+):(?[0-9]{12}):project/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-databrew-project.html" + }, + "AWS::DataBrew::Recipe" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):databrew:(?[a-z0-9-]+):(?[0-9]{12}):recipe/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-databrew-recipe.html" + }, + "AWS::DataBrew::Ruleset" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):databrew:(?[a-z0-9-]+):(?[0-9]{12}):ruleset/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-databrew-ruleset.html" + }, + "AWS::DataBrew::Schedule" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):databrew:(?[a-z0-9-]+):(?[0-9]{12}):schedule/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-databrew-schedule.html" + }, + "AWS::DataSync::Agent" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):datasync:(?[a-z0-9-]+):(?[^/:]+):agent/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-agent.html" + }, + "AWS::DataSync::LocationEFS" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locationefs.html" + }, + "AWS::DataSync::LocationFSxLustre" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locationfsxlustre.html" + }, + "AWS::DataSync::LocationFSxONTAP" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locationfsxontap.html" + }, + "AWS::DataSync::LocationFSxOpenZFS" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locationfsxopenzfs.html" + }, + "AWS::DataSync::LocationFSxWindows" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locationfsxwindows.html" + }, + "AWS::DataSync::LocationHDFS" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locationhdfs.html" + }, + "AWS::DataSync::LocationNFS" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locationnfs.html" + }, + "AWS::DataSync::LocationObjectStorage" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locationobjectstorage.html" + }, + "AWS::DataSync::LocationS3" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locations3.html" + }, + "AWS::DataSync::LocationSMB" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-locationsmb.html" + }, + "AWS::DataSync::Task" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):datasync:(?[a-z0-9-]+):(?[^/:]+):task/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-datasync-task.html" + }, + "AWS::Detective::Graph" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):detective:(?[a-z0-9-]+):(?[0-9]{12}):graph:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-detective-graph.html" + }, + "AWS::Detective::MemberInvitation" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-detective-memberinvitation.html" + }, + "AWS::DevOpsGuru::NotificationChannel" : { + "operations" : [ "CREATE", "LIST", "DELETE", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-devopsguru-notificationchannel.html" + }, + "AWS::DevOpsGuru::ResourceCollection" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-devopsguru-resourcecollection.html" + }, + "AWS::DeviceFarm::DevicePool" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):devicefarm:(?[a-z0-9-]+):(?[0-9]{12}):devicepool:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-devicefarm-devicepool.html" + }, + "AWS::DeviceFarm::InstanceProfile" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):devicefarm:(?[a-z0-9-]+):(?[0-9]{12}):instanceprofile:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-devicefarm-instanceprofile.html" + }, + "AWS::DeviceFarm::NetworkProfile" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):devicefarm:(?[a-z0-9-]+):(?[0-9]{12}):networkprofile:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-devicefarm-networkprofile.html" + }, + "AWS::DeviceFarm::Project" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):devicefarm:(?[a-z0-9-]+):(?[0-9]{12}):project:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-devicefarm-project.html" + }, + "AWS::DeviceFarm::TestGridProject" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):devicefarm:(?[a-z0-9-]+):(?[0-9]{12}):testgrid-project:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-devicefarm-testgridproject.html" + }, + "AWS::DeviceFarm::VPCEConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):devicefarm:(?[a-z0-9-]+):(?[0-9]{12}):vpceconfiguration:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-devicefarm-vpceconfiguration.html" + }, + "AWS::DynamoDB::GlobalTable" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):dynamodb::(?[0-9]{12}):global-table/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-globaltable.html" + }, + "AWS::DynamoDB::Table" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):dynamodb:(?[a-z0-9-]+):(?[0-9]{12}):table/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html" + }, + "AWS::EC2::CapacityReservation" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):capacity-reservation/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-capacityreservation.html" + }, + "AWS::EC2::CapacityReservationFleet" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):capacity-reservation-fleet/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-capacityreservationfleet.html" + }, + "AWS::EC2::CarrierGateway" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):carrier-gateway/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-carriergateway.html" + }, + "AWS::EC2::CustomerGateway" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):customer-gateway/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-customergateway.html" + }, + "AWS::EC2::DHCPOptions" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):dhcp-options/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-dhcpoptions.html" + }, + "AWS::EC2::EC2Fleet" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-ec2fleet.html" + }, + "AWS::EC2::EgressOnlyInternetGateway" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):egress-only-internet-gateway/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-egressonlyinternetgateway.html" + }, + "AWS::EC2::EnclaveCertificateIamRoleAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-enclavecertificateiamroleassociation.html" + }, + "AWS::EC2::FlowLog" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-flowlog.html" + }, + "AWS::EC2::GatewayRouteTableAssociation" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-gatewayroutetableassociation.html" + }, + "AWS::EC2::Host" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):dedicated-host/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-host.html" + }, + "AWS::EC2::IPAM" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2::(?[0-9]{12}):ipam/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-ipam.html" + }, + "AWS::EC2::IPAMAllocation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-ipamallocation.html" + }, + "AWS::EC2::IPAMPool" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2::(?[0-9]{12}):ipam-pool/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-ipampool.html" + }, + "AWS::EC2::IPAMScope" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2::(?[0-9]{12}):ipam-scope/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-ipamscope.html" + }, + "AWS::EC2::InternetGateway" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):internet-gateway/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-internetgateway.html" + }, + "AWS::EC2::KeyPair" : { + "operations" : [ "CREATE", "READ", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):key-pair/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-keypair.html" + }, + "AWS::EC2::LocalGatewayRoute" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-localgatewayroute.html" + }, + "AWS::EC2::LocalGatewayRouteTableVPCAssociation" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):local-gateway-route-table-vpc-association/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-localgatewayroutetablevpcassociation.html" + }, + "AWS::EC2::NatGateway" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):natgateway/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-natgateway.html" + }, + "AWS::EC2::NetworkAcl" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):network-acl/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-networkacl.html" + }, + "AWS::EC2::NetworkInsightsAccessScope" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):network-insights-access-scope/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-networkinsightsaccessscope.html" + }, + "AWS::EC2::NetworkInsightsAccessScopeAnalysis" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):network-insights-access-scope-analysis/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-networkinsightsaccessscopeanalysis.html" + }, + "AWS::EC2::NetworkInsightsAnalysis" : { + "operations" : [ "CREATE", "DELETE", "READ", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):network-insights-analysis/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-networkinsightsanalysis.html" + }, + "AWS::EC2::NetworkInsightsPath" : { + "operations" : [ "CREATE", "DELETE", "READ", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):network-insights-path/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-networkinsightspath.html" + }, + "AWS::EC2::NetworkInterface" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):network-interface/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-networkinterface.html" + }, + "AWS::EC2::PlacementGroup" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):placement-group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-placementgroup.html" + }, + "AWS::EC2::PrefixList" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):prefix-list/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-prefixlist.html" + }, + "AWS::EC2::RouteTable" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):route-table/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-routetable.html" + }, + "AWS::EC2::SpotFleet" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-spotfleet.html" + }, + "AWS::EC2::Subnet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):subnet/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-subnet.html" + }, + "AWS::EC2::SubnetNetworkAclAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-subnet-network-acl-assoc.html" + }, + "AWS::EC2::SubnetRouteTableAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-subnetroutetableassociation.html" + }, + "AWS::EC2::TransitGateway" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):transit-gateway/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-transitgateway.html" + }, + "AWS::EC2::TransitGatewayAttachment" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):transit-gateway-attachment/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-transitgatewayattachment.html" + }, + "AWS::EC2::TransitGatewayConnect" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-transitgatewayconnect.html" + }, + "AWS::EC2::TransitGatewayMulticastDomain" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):transit-gateway-multicast-domain/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-transitgatewaymulticastdomain.html" + }, + "AWS::EC2::TransitGatewayMulticastDomainAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-transitgatewaymulticastdomainassociation.html" + }, + "AWS::EC2::TransitGatewayMulticastGroupMember" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-transitgatewaymulticastgroupmember.html" + }, + "AWS::EC2::TransitGatewayMulticastGroupSource" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-transitgatewaymulticastgroupsource.html" + }, + "AWS::EC2::TransitGatewayPeeringAttachment" : { + "operations" : [ "READ", "CREATE", "UPDATE", "LIST", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-transitgatewaypeeringattachment.html" + }, + "AWS::EC2::TransitGatewayVpcAttachment" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-transitgatewayvpcattachment.html" + }, + "AWS::EC2::VPC" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):vpc/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-vpc.html" + }, + "AWS::EC2::VPCDHCPOptionsAssociation" : { + "operations" : [ "CREATE", "UPDATE", "DELETE", "READ", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-vpcdhcpoptionsassociation.html" + }, + "AWS::EC2::VPCPeeringConnection" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):vpc-peering-connection/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-vpcpeeringconnection.html" + }, + "AWS::EC2::VPNConnection" : { + "operations" : [ "CREATE", "DELETE", "UPDATE", "READ", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):vpn-connection/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-vpn-connection.html" + }, + "AWS::EC2::VPNGateway" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ec2:(?[a-z0-9-]+):(?[0-9]{12}):vpn-gateway/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-vpngateway.html" + }, + "AWS::ECR::PullThroughCacheRule" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecr-pullthroughcacherule.html" + }, + "AWS::ECR::RegistryPolicy" : { + "operations" : [ "CREATE", "READ", "LIST", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecr-registrypolicy.html" + }, + "AWS::ECR::ReplicationConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecr-replicationconfiguration.html" + }, + "AWS::ECR::Repository" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ecr:(?[a-z0-9-]+):(?[0-9]{12}):repository/(?[^:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecr-repository.html" + }, + "AWS::ECS::CapacityProvider" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ecs:(?[a-z0-9-]+):(?[0-9]{12}):capacity-provider/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-capacityprovider.html" + }, + "AWS::ECS::Cluster" : { + "operations" : [ "READ", "CREATE", "UPDATE", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):ecs:(?[a-z0-9-]+):(?[0-9]{12}):cluster/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-cluster.html" + }, + "AWS::ECS::ClusterCapacityProviderAssociations" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-clustercapacityproviderassociations.html" + }, + "AWS::ECS::PrimaryTaskSet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-primarytaskset.html" + }, + "AWS::ECS::Service" : { + "operations" : [ "READ", "CREATE", "UPDATE", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):ecs:(?[a-z0-9-]+):(?[0-9]{12}):service/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-service.html" + }, + "AWS::ECS::TaskDefinition" : { + "operations" : [ "READ", "CREATE", "UPDATE", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):ecs:(?[a-z0-9-]+):(?[0-9]{12}):task-definition/(?[^/:]+):(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-taskdefinition.html" + }, + "AWS::ECS::TaskSet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):ecs:(?[a-z0-9-]+):(?[0-9]{12}):task-set/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-taskset.html" + }, + "AWS::EFS::AccessPoint" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):elasticfilesystem:(?[a-z0-9-]+):(?[0-9]{12}):access-point/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-efs-accesspoint.html" + }, + "AWS::EFS::FileSystem" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):elasticfilesystem:(?[a-z0-9-]+):(?[0-9]{12}):file-system/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-efs-filesystem.html" + }, + "AWS::EFS::MountTarget" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-efs-mounttarget.html" + }, + "AWS::EKS::Addon" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):eks:(?[a-z0-9-]+):(?[0-9]{12}):addon/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-eks-addon.html" + }, + "AWS::EKS::Cluster" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):eks:(?[a-z0-9-]+):(?[0-9]{12}):cluster/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-eks-cluster.html" + }, + "AWS::EKS::FargateProfile" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):eks:(?[a-z0-9-]+):(?[0-9]{12}):fargateprofile/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-eks-fargateprofile.html" + }, + "AWS::EKS::IdentityProviderConfig" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):eks:(?[a-z0-9-]+):(?[0-9]{12}):identityproviderconfig/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-eks-identityproviderconfig.html" + }, + "AWS::EKS::Nodegroup" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):eks:(?[a-z0-9-]+):(?[0-9]{12}):nodegroup/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-eks-nodegroup.html" + }, + "AWS::EMR::SecurityConfiguration" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-emr-securityconfiguration.html" + }, + "AWS::EMR::Studio" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):elasticmapreduce:(?[a-z0-9-]+):(?[0-9]{12}):studio/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-emr-studio.html" + }, + "AWS::EMR::StudioSessionMapping" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-emr-studiosessionmapping.html" + }, + "AWS::EMRContainers::VirtualCluster" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):emr-containers:(?[a-z0-9-]+):(?[0-9]{12}):/virtualclusters/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-emrcontainers-virtualcluster.html" + }, + "AWS::EMRServerless::Application" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):emr-serverless:(?[a-z0-9-]+):(?[0-9]{12}):/applications/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-emrserverless-application.html" + }, + "AWS::ElastiCache::GlobalReplicationGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):elasticache::(?[0-9]{12}):globalreplicationgroup:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-elasticache-globalreplicationgroup.html" + }, + "AWS::ElastiCache::User" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):elasticache:(?[a-z0-9-]+):(?[0-9]{12}):user:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-elasticache-user.html" + }, + "AWS::ElastiCache::UserGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):elasticache:(?[a-z0-9-]+):(?[0-9]{12}):usergroup:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-elasticache-usergroup.html" + }, + "AWS::ElasticBeanstalk::Application" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):elasticbeanstalk:(?[a-z0-9-]+):(?[0-9]{12}):application/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-elasticbeanstalk-application.html" + }, + "AWS::ElasticLoadBalancingV2::Listener" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-elasticloadbalancingv2-listener.html" + }, + "AWS::ElasticLoadBalancingV2::ListenerRule" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-elasticloadbalancingv2-listenerrule.html" + }, + "AWS::EventSchemas::RegistryPolicy" : { + "operations" : [ "CREATE", "DELETE", "UPDATE", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-eventschemas-registrypolicy.html" + }, + "AWS::Events::ApiDestination" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):events:(?[a-z0-9-]+):(?[0-9]{12}):api-destination/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-events-apidestination.html" + }, + "AWS::Events::Archive" : { + "operations" : [ "CREATE", "DELETE", "LIST", "UPDATE", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):events:(?[a-z0-9-]+):(?[0-9]{12}):archive/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-events-archive.html" + }, + "AWS::Events::Connection" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):events:(?[a-z0-9-]+):(?[0-9]{12}):connection/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-events-connection.html" + }, + "AWS::Events::Endpoint" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):events:(?[a-z0-9-]+):(?[0-9]{12}):endpoint/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-events-endpoint.html" + }, + "AWS::Evidently::Experiment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):evidently:(?[a-z0-9-]+):(?[^/:]+):project/(?[^/:]+)/experiment/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-evidently-experiment.html" + }, + "AWS::Evidently::Feature" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):evidently:(?[a-z0-9-]+):(?[^/:]+):project/(?[^/:]+)/feature/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-evidently-feature.html" + }, + "AWS::Evidently::Launch" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):evidently:(?[a-z0-9-]+):(?[^/:]+):project/(?[^/:]+)/launch/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-evidently-launch.html" + }, + "AWS::Evidently::Project" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):evidently:(?[a-z0-9-]+):(?[^/:]+):project/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-evidently-project.html" + }, + "AWS::Evidently::Segment" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-evidently-segment.html" + }, + "AWS::FIS::ExperimentTemplate" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):fis:(?[a-z0-9-]+):(?[0-9]{12}):experiment-template/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-fis-experimenttemplate.html" + }, + "AWS::FMS::NotificationChannel" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-fms-notificationchannel.html" + }, + "AWS::FMS::Policy" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):fms:(?[a-z0-9-]+):(?[0-9]{12}):policy/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-fms-policy.html" + }, + "AWS::FinSpace::Environment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):finspace:(?[a-z0-9-]+):(?[0-9]{12}):environment/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-finspace-environment.html" + }, + "AWS::Forecast::Dataset" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):forecast:(?[a-z0-9-]+):(?[0-9]{12}):dataset/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-forecast-dataset.html" + }, + "AWS::Forecast::DatasetGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):forecast:(?[a-z0-9-]+):(?[0-9]{12}):dataset-group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-forecast-datasetgroup.html" + }, + "AWS::FraudDetector::Detector" : { + "operations" : [ "CREATE", "UPDATE", "DELETE", "READ", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):frauddetector:(?[a-z0-9-]+):(?[0-9]{12}):detector/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-frauddetector-detector.html" + }, + "AWS::FraudDetector::EntityType" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):frauddetector:(?[a-z0-9-]+):(?[0-9]{12}):entity-type/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-frauddetector-entitytype.html" + }, + "AWS::FraudDetector::EventType" : { + "operations" : [ "CREATE", "UPDATE", "DELETE", "READ", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):frauddetector:(?[a-z0-9-]+):(?[0-9]{12}):event-type/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-frauddetector-eventtype.html" + }, + "AWS::FraudDetector::Label" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):frauddetector:(?[a-z0-9-]+):(?[0-9]{12}):label/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-frauddetector-label.html" + }, + "AWS::FraudDetector::Outcome" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):frauddetector:(?[a-z0-9-]+):(?[0-9]{12}):outcome/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-frauddetector-outcome.html" + }, + "AWS::FraudDetector::Variable" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):frauddetector:(?[a-z0-9-]+):(?[0-9]{12}):variable/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-frauddetector-variable.html" + }, + "AWS::GameLift::Alias" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):gamelift:(?[a-z0-9-]+)::alias/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-gamelift-alias.html" + }, + "AWS::GameLift::Fleet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):gamelift:(?[a-z0-9-]+):(?[0-9]{12}):fleet/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-gamelift-fleet.html" + }, + "AWS::GameLift::GameServerGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):gamelift:(?[a-z0-9-]+):(?[0-9]{12}):gameservergroup/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-gamelift-gameservergroup.html" + }, + "AWS::GlobalAccelerator::Accelerator" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):globalaccelerator::(?[0-9]{12}):accelerator/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-globalaccelerator-accelerator.html" + }, + "AWS::GlobalAccelerator::EndpointGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):globalaccelerator::(?[0-9]{12}):accelerator/(?[^/:]+)/listener/(?[^/:]+)/endpoint-group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-globalaccelerator-endpointgroup.html" + }, + "AWS::GlobalAccelerator::Listener" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):globalaccelerator::(?[0-9]{12}):accelerator/(?[^/:]+)/listener/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-globalaccelerator-listener.html" + }, + "AWS::Glue::Registry" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):glue:(?[a-z0-9-]+):(?[0-9]{12}):registry/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-glue-registry.html" + }, + "AWS::Glue::Schema" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):glue:(?[a-z0-9-]+):(?[0-9]{12}):schema/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-glue-schema.html" + }, + "AWS::Glue::SchemaVersion" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-glue-schemaversion.html" + }, + "AWS::Glue::SchemaVersionMetadata" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-glue-schemaversionmetadata.html" + }, + "AWS::GreengrassV2::ComponentVersion" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):greengrass:(?[a-z0-9-]+):(?[0-9]{12}):components:(?[^/:]+):versions:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-greengrassv2-componentversion.html" + }, + "AWS::GroundStation::Config" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):groundstation:(?[a-z0-9-]+):(?[0-9]{12}):config/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-groundstation-config.html" + }, + "AWS::GroundStation::DataflowEndpointGroup" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):groundstation:(?[a-z0-9-]+):(?[0-9]{12}):dataflow-endpoint-group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-groundstation-dataflowendpointgroup.html" + }, + "AWS::GroundStation::MissionProfile" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):groundstation:(?[a-z0-9-]+):(?[0-9]{12}):mission-profile/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-groundstation-missionprofile.html" + }, + "AWS::HealthLake::FHIRDatastore" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-healthlake-fhirdatastore.html" + }, + "AWS::IAM::InstanceProfile" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iam::(?[0-9]{12}):instance-profile/(?[^:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-instanceprofile.html" + }, + "AWS::IAM::OIDCProvider" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-oidcprovider.html" + }, + "AWS::IAM::Role" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iam::(?[0-9]{12}):role/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html" + }, + "AWS::IAM::SAMLProvider" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iam::(?[0-9]{12}):saml-provider/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-samlprovider.html" + }, + "AWS::IAM::ServerCertificate" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iam::(?[0-9]{12}):server-certificate/(?[^:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-servercertificate.html" + }, + "AWS::IAM::VirtualMFADevice" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iam::(?[0-9]{12}):mfa/(?[^:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-virtualmfadevice.html" + }, + "AWS::IVS::Channel" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ivs:(?[a-z0-9-]+):(?[0-9]{12}):channel/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ivs-channel.html" + }, + "AWS::IVS::PlaybackKeyPair" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ivs:(?[a-z0-9-]+):(?[0-9]{12}):playback-key/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ivs-playbackkeypair.html" + }, + "AWS::IVS::RecordingConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ivs:(?[a-z0-9-]+):(?[0-9]{12}):recording-configuration/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ivs-recordingconfiguration.html" + }, + "AWS::IVS::StreamKey" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ivs:(?[a-z0-9-]+):(?[0-9]{12}):stream-key/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ivs-streamkey.html" + }, + "AWS::ImageBuilder::Component" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):imagebuilder:(?[a-z0-9-]+):(?[0-9]{12}):component/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-imagebuilder-component.html" + }, + "AWS::ImageBuilder::ContainerRecipe" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):imagebuilder:(?[a-z0-9-]+):(?[0-9]{12}):container-recipe/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-imagebuilder-containerrecipe.html" + }, + "AWS::ImageBuilder::DistributionConfiguration" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):imagebuilder:(?[a-z0-9-]+):(?[0-9]{12}):distribution-configuration/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-imagebuilder-distributionconfiguration.html" + }, + "AWS::ImageBuilder::Image" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):imagebuilder:(?[a-z0-9-]+):(?[0-9]{12}):image/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-imagebuilder-image.html" + }, + "AWS::ImageBuilder::ImagePipeline" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):imagebuilder:(?[a-z0-9-]+):(?[0-9]{12}):image-pipeline/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-imagebuilder-imagepipeline.html" + }, + "AWS::ImageBuilder::ImageRecipe" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):imagebuilder:(?[a-z0-9-]+):(?[0-9]{12}):image-recipe/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-imagebuilder-imagerecipe.html" + }, + "AWS::ImageBuilder::InfrastructureConfiguration" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):imagebuilder:(?[a-z0-9-]+):(?[0-9]{12}):infrastructure-configuration/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-imagebuilder-infrastructureconfiguration.html" + }, + "AWS::Inspector::AssessmentTarget" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-inspector-assessmenttarget.html" + }, + "AWS::Inspector::AssessmentTemplate" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):inspector:(?[a-z0-9-]+):(?[0-9]{12}):target/(?[^/:]+)/template/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-inspector-assessmenttemplate.html" + }, + "AWS::Inspector::ResourceGroup" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-inspector-resourcegroup.html" + }, + "AWS::InspectorV2::Filter" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-inspectorv2-filter.html" + }, + "AWS::IoT::AccountAuditConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-accountauditconfiguration.html" + }, + "AWS::IoT::Authorizer" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):authorizer/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-authorizer.html" + }, + "AWS::IoT::CACertificate" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-cacertificate.html" + }, + "AWS::IoT::Certificate" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-certificate.html" + }, + "AWS::IoT::CustomMetric" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):custommetric/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-custommetric.html" + }, + "AWS::IoT::Dimension" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):dimension/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-dimension.html" + }, + "AWS::IoT::DomainConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):domainconfiguration/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-domainconfiguration.html" + }, + "AWS::IoT::FleetMetric" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):fleetmetric/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-fleetmetric.html" + }, + "AWS::IoT::JobTemplate" : { + "operations" : [ "READ", "CREATE", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):jobtemplate/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-jobtemplate.html" + }, + "AWS::IoT::Logging" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-logging.html" + }, + "AWS::IoT::MitigationAction" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):mitigationaction/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-mitigationaction.html" + }, + "AWS::IoT::Policy" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):policy/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-policy.html" + }, + "AWS::IoT::ProvisioningTemplate" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):provisioningtemplate/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-provisioningtemplate.html" + }, + "AWS::IoT::ResourceSpecificLogging" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-resourcespecificlogging.html" + }, + "AWS::IoT::RoleAlias" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):rolealias/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-rolealias.html" + }, + "AWS::IoT::ScheduledAudit" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):scheduledaudit/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-scheduledaudit.html" + }, + "AWS::IoT::SecurityProfile" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):securityprofile/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-securityprofile.html" + }, + "AWS::IoT::TopicRule" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iot:(?[a-z0-9-]+):(?[0-9]{12}):rule/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-topicrule.html" + }, + "AWS::IoT::TopicRuleDestination" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-topicruledestination.html" + }, + "AWS::IoTAnalytics::Channel" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotanalytics:(?[a-z0-9-]+):(?[0-9]{12}):channel/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotanalytics-channel.html" + }, + "AWS::IoTAnalytics::Dataset" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotanalytics:(?[a-z0-9-]+):(?[0-9]{12}):dataset/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotanalytics-dataset.html" + }, + "AWS::IoTAnalytics::Datastore" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotanalytics:(?[a-z0-9-]+):(?[0-9]{12}):datastore/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotanalytics-datastore.html" + }, + "AWS::IoTAnalytics::Pipeline" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotanalytics:(?[a-z0-9-]+):(?[0-9]{12}):pipeline/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotanalytics-pipeline.html" + }, + "AWS::IoTCoreDeviceAdvisor::SuiteDefinition" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotcoredeviceadvisor-suitedefinition.html" + }, + "AWS::IoTEvents::AlarmModel" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotevents:(?[a-z0-9-]+):(?[0-9]{12}):alarmModel/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotevents-alarmmodel.html" + }, + "AWS::IoTEvents::DetectorModel" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotevents:(?[a-z0-9-]+):(?[0-9]{12}):detectorModel/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotevents-detectormodel.html" + }, + "AWS::IoTEvents::Input" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotevents:(?[a-z0-9-]+):(?[0-9]{12}):input/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotevents-input.html" + }, + "AWS::IoTFleetHub::Application" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotfleethub:(?[a-z0-9-]+):(?[0-9]{12}):application/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotfleethub-application.html" + }, + "AWS::IoTSiteWise::AccessPolicy" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):iotsitewise:(?[a-z0-9-]+):(?[0-9]{12}):access-policy/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotsitewise-accesspolicy.html" + }, + "AWS::IoTSiteWise::Asset" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotsitewise:(?[a-z0-9-]+):(?[0-9]{12}):asset/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotsitewise-asset.html" + }, + "AWS::IoTSiteWise::AssetModel" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotsitewise:(?[a-z0-9-]+):(?[0-9]{12}):asset-model/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotsitewise-assetmodel.html" + }, + "AWS::IoTSiteWise::Dashboard" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):iotsitewise:(?[a-z0-9-]+):(?[0-9]{12}):dashboard/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotsitewise-dashboard.html" + }, + "AWS::IoTSiteWise::Gateway" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotsitewise:(?[a-z0-9-]+):(?[0-9]{12}):gateway/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotsitewise-gateway.html" + }, + "AWS::IoTSiteWise::Portal" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotsitewise:(?[a-z0-9-]+):(?[0-9]{12}):portal/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotsitewise-portal.html" + }, + "AWS::IoTSiteWise::Project" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):iotsitewise:(?[a-z0-9-]+):(?[0-9]{12}):project/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotsitewise-project.html" + }, + "AWS::IoTTwinMaker::ComponentType" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):iottwinmaker:(?[a-z0-9-]+):(?[0-9]{12}):workspace/(?[^/:]+)/component-type/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iottwinmaker-componenttype.html" + }, + "AWS::IoTTwinMaker::Entity" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):iottwinmaker:(?[a-z0-9-]+):(?[0-9]{12}):workspace/(?[^/:]+)/entity/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iottwinmaker-entity.html" + }, + "AWS::IoTTwinMaker::Scene" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):iottwinmaker:(?[a-z0-9-]+):(?[0-9]{12}):workspace/(?[^/:]+)/scene/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iottwinmaker-scene.html" + }, + "AWS::IoTTwinMaker::Workspace" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iottwinmaker:(?[a-z0-9-]+):(?[0-9]{12}):workspace/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iottwinmaker-workspace.html" + }, + "AWS::IoTWireless::Destination" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotwireless:(?[a-z0-9-]+):(?[0-9]{12}):Destination/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotwireless-destination.html" + }, + "AWS::IoTWireless::DeviceProfile" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotwireless:(?[a-z0-9-]+):(?[0-9]{12}):DeviceProfile/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotwireless-deviceprofile.html" + }, + "AWS::IoTWireless::FuotaTask" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotwireless:(?[a-z0-9-]+):(?[0-9]{12}):FuotaTask/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotwireless-fuotatask.html" + }, + "AWS::IoTWireless::MulticastGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotwireless:(?[a-z0-9-]+):(?[0-9]{12}):MulticastGroup/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotwireless-multicastgroup.html" + }, + "AWS::IoTWireless::NetworkAnalyzerConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotwireless:(?[a-z0-9-]+):(?[0-9]{12}):NetworkAnalyzerConfiguration/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotwireless-networkanalyzerconfiguration.html" + }, + "AWS::IoTWireless::ServiceProfile" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotwireless:(?[a-z0-9-]+):(?[0-9]{12}):ServiceProfile/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotwireless-serviceprofile.html" + }, + "AWS::IoTWireless::TaskDefinition" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotwireless-taskdefinition.html" + }, + "AWS::IoTWireless::WirelessDevice" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotwireless:(?[a-z0-9-]+):(?[0-9]{12}):WirelessDevice/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotwireless-wirelessdevice.html" + }, + "AWS::IoTWireless::WirelessGateway" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):iotwireless:(?[a-z0-9-]+):(?[0-9]{12}):WirelessGateway/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iotwireless-wirelessgateway.html" + }, + "AWS::KMS::Alias" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):kms:(?[a-z0-9-]+):(?[0-9]{12}):alias/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kms-alias.html" + }, + "AWS::KMS::Key" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):kms:(?[a-z0-9-]+):(?[0-9]{12}):key/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kms-key.html" + }, + "AWS::KMS::ReplicaKey" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kms-replicakey.html" + }, + "AWS::KafkaConnect::Connector" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):kafkaconnect:(?[a-z0-9-]+):(?[0-9]{12}):connector/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kafkaconnect-connector.html" + }, + "AWS::Kendra::DataSource" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):kendra:(?[a-z0-9-]+):(?[0-9]{12}):index/(?[^/:]+)/data-source/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kendra-datasource.html" + }, + "AWS::Kendra::Faq" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):kendra:(?[a-z0-9-]+):(?[0-9]{12}):index/(?[^/:]+)/faq/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kendra-faq.html" + }, + "AWS::Kendra::Index" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):kendra:(?[a-z0-9-]+):(?[0-9]{12}):index/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kendra-index.html" + }, + "AWS::Kinesis::Stream" : { + "operations" : [ "READ", "CREATE", "UPDATE", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):kinesis:(?[a-z0-9-]+):(?[0-9]{12}):stream/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kinesis-stream.html" + }, + "AWS::KinesisAnalyticsV2::Application" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):kinesisanalytics:(?[a-z0-9-]+):(?[0-9]{12}):application/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kinesisanalyticsv2-application.html" + }, + "AWS::KinesisFirehose::DeliveryStream" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):firehose:(?[a-z0-9-]+):(?[0-9]{12}):deliverystream/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kinesisfirehose-deliverystream.html" + }, + "AWS::KinesisVideo::SignalingChannel" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kinesisvideo-signalingchannel.html" + }, + "AWS::KinesisVideo::Stream" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):kinesisvideo:(?[a-z0-9-]+):(?[0-9]{12}):stream/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-kinesisvideo-stream.html" + }, + "AWS::LakeFormation::DataCellsFilter" : { + "operations" : [ "CREATE", "DELETE", "READ", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lakeformation-datacellsfilter.html" + }, + "AWS::LakeFormation::PrincipalPermissions" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lakeformation-principalpermissions.html" + }, + "AWS::LakeFormation::Tag" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lakeformation-tag.html" + }, + "AWS::LakeFormation::TagAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lakeformation-tagassociation.html" + }, + "AWS::Lambda::CodeSigningConfig" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-codesigningconfig.html" + }, + "AWS::Lambda::EventSourceMapping" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):lambda:(?[a-z0-9-]+):(?[0-9]{12}):event-source-mapping:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html" + }, + "AWS::Lambda::Function" : { + "operations" : [ "READ", "CREATE", "UPDATE", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):lambda:(?[a-z0-9-]+):(?[0-9]{12}):function:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-function.html" + }, + "AWS::Lambda::Url" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-url.html" + }, + "AWS::Lex::Bot" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):lex:(?[a-z0-9-]+):(?[0-9]{12}):bot:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lex-bot.html" + }, + "AWS::Lex::BotAlias" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lex-botalias.html" + }, + "AWS::Lex::BotVersion" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lex-botversion.html" + }, + "AWS::Lex::ResourcePolicy" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lex-resourcepolicy.html" + }, + "AWS::LicenseManager::Grant" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):license-manager::(?[0-9]{12}):grant:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-licensemanager-grant.html" + }, + "AWS::LicenseManager::License" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):license-manager::(?[0-9]{12}):license:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-licensemanager-license.html" + }, + "AWS::Lightsail::Alarm" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):lightsail:(?[a-z0-9-]+):(?[0-9]{12}):Alarm/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-alarm.html" + }, + "AWS::Lightsail::Bucket" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):lightsail:(?[a-z0-9-]+):(?[0-9]{12}):Bucket/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-bucket.html" + }, + "AWS::Lightsail::Certificate" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):lightsail:(?[a-z0-9-]+):(?[0-9]{12}):Certificate/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-certificate.html" + }, + "AWS::Lightsail::Container" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-container.html" + }, + "AWS::Lightsail::Database" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-database.html" + }, + "AWS::Lightsail::Disk" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):lightsail:(?[a-z0-9-]+):(?[0-9]{12}):Disk/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-disk.html" + }, + "AWS::Lightsail::Instance" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):lightsail:(?[a-z0-9-]+):(?[0-9]{12}):Instance/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-instance.html" + }, + "AWS::Lightsail::LoadBalancer" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):lightsail:(?[a-z0-9-]+):(?[0-9]{12}):LoadBalancer/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-loadbalancer.html" + }, + "AWS::Lightsail::LoadBalancerTlsCertificate" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):lightsail:(?[a-z0-9-]+):(?[0-9]{12}):LoadBalancerTlsCertificate/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-loadbalancertlscertificate.html" + }, + "AWS::Lightsail::StaticIp" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):lightsail:(?[a-z0-9-]+):(?[0-9]{12}):StaticIp/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lightsail-staticip.html" + }, + "AWS::Location::GeofenceCollection" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-location-geofencecollection.html" + }, + "AWS::Location::Map" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-location-map.html" + }, + "AWS::Location::PlaceIndex" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-location-placeindex.html" + }, + "AWS::Location::RouteCalculator" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-location-routecalculator.html" + }, + "AWS::Location::Tracker" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-location-tracker.html" + }, + "AWS::Location::TrackerConsumer" : { + "operations" : [ "CREATE", "DELETE", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-location-trackerconsumer.html" + }, + "AWS::Logs::Destination" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):logs:(?[a-z0-9-]+):(?[0-9]{12}):destination:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-destination.html" + }, + "AWS::Logs::LogGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):logs:(?[a-z0-9-]+):(?[0-9]{12}):log-group:(?[^:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-loggroup.html" + }, + "AWS::Logs::LogStream" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):logs:(?[a-z0-9-]+):(?[0-9]{12}):log-group:(?[^/:]+):log-stream:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-logstream.html" + }, + "AWS::Logs::MetricFilter" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-metricfilter.html" + }, + "AWS::Logs::QueryDefinition" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-querydefinition.html" + }, + "AWS::Logs::ResourcePolicy" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-resourcepolicy.html" + }, + "AWS::Logs::SubscriptionFilter" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-logs-subscriptionfilter.html" + }, + "AWS::LookoutMetrics::Alert" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):lookoutmetrics:(?[a-z0-9-]+):(?[0-9]{12}):Alert:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lookoutmetrics-alert.html" + }, + "AWS::LookoutMetrics::AnomalyDetector" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):lookoutmetrics:(?[a-z0-9-]+):(?[0-9]{12}):AnomalyDetector:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lookoutmetrics-anomalydetector.html" + }, + "AWS::LookoutVision::Project" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):lookoutvision:(?[a-z0-9-]+):(?[0-9]{12}):project/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lookoutvision-project.html" + }, + "AWS::M2::Application" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ] + }, + "AWS::M2::Environment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ] + }, + "AWS::MSK::BatchScramSecret" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-msk-batchscramsecret.html" + }, + "AWS::MSK::Cluster" : { + "operations" : [ "CREATE", "UPDATE", "DELETE", "LIST", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-msk-cluster.html" + }, + "AWS::MSK::Configuration" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-msk-configuration.html" + }, + "AWS::MSK::ServerlessCluster" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-msk-serverlesscluster.html" + }, + "AWS::MWAA::Environment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mwaa-environment.html" + }, + "AWS::Macie::AllowList" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-macie-allowlist.html" + }, + "AWS::Macie::CustomDataIdentifier" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):macie2:(?[a-z0-9-]+):(?[0-9]{12}):custom-data-identifier/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-macie-customdataidentifier.html" + }, + "AWS::Macie::FindingsFilter" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):macie2:(?[a-z0-9-]+):(?[0-9]{12}):findings-filter/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-macie-findingsfilter.html" + }, + "AWS::Macie::Session" : { + "operations" : [ "CREATE", "READ", "LIST", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-macie-session.html" + }, + "AWS::MediaConnect::Flow" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):mediaconnect:(?[a-z0-9-]+):(?[0-9]{12}):flow:(?[^/:]+):(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediaconnect-flow.html" + }, + "AWS::MediaConnect::FlowEntitlement" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediaconnect-flowentitlement.html" + }, + "AWS::MediaConnect::FlowOutput" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediaconnect-flowoutput.html" + }, + "AWS::MediaConnect::FlowSource" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediaconnect-flowsource.html" + }, + "AWS::MediaConnect::FlowVpcInterface" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediaconnect-flowvpcinterface.html" + }, + "AWS::MediaPackage::Asset" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediapackage-asset.html" + }, + "AWS::MediaPackage::Channel" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediapackage-channel.html" + }, + "AWS::MediaPackage::OriginEndpoint" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediapackage-originendpoint.html" + }, + "AWS::MediaPackage::PackagingConfiguration" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediapackage-packagingconfiguration.html" + }, + "AWS::MediaPackage::PackagingGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "LIST", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediapackage-packaginggroup.html" + }, + "AWS::MediaTailor::PlaybackConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):mediatailor:(?[a-z0-9-]+):(?[0-9]{12}):playbackConfiguration/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-mediatailor-playbackconfiguration.html" + }, + "AWS::MemoryDB::ACL" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):memorydb:(?[a-z0-9-]+):(?[0-9]{12}):acl/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-memorydb-acl.html" + }, + "AWS::MemoryDB::Cluster" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):memorydb:(?[a-z0-9-]+):(?[0-9]{12}):cluster/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-memorydb-cluster.html" + }, + "AWS::MemoryDB::ParameterGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):memorydb:(?[a-z0-9-]+):(?[0-9]{12}):parametergroup/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-memorydb-parametergroup.html" + }, + "AWS::MemoryDB::SubnetGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):memorydb:(?[a-z0-9-]+):(?[0-9]{12}):subnetgroup/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-memorydb-subnetgroup.html" + }, + "AWS::MemoryDB::User" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):memorydb:(?[a-z0-9-]+):(?[0-9]{12}):user/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-memorydb-user.html" + }, + "AWS::NetworkFirewall::Firewall" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):network-firewall:(?[a-z0-9-]+):(?[0-9]{12}):firewall/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkfirewall-firewall.html" + }, + "AWS::NetworkFirewall::FirewallPolicy" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):network-firewall:(?[a-z0-9-]+):(?[0-9]{12}):firewall-policy/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkfirewall-firewallpolicy.html" + }, + "AWS::NetworkFirewall::LoggingConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkfirewall-loggingconfiguration.html" + }, + "AWS::NetworkFirewall::RuleGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkfirewall-rulegroup.html" + }, + "AWS::NetworkManager::ConnectAttachment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-connectattachment.html" + }, + "AWS::NetworkManager::ConnectPeer" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):networkmanager::(?[0-9]{12}):connect-peer/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-connectpeer.html" + }, + "AWS::NetworkManager::CoreNetwork" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):networkmanager::(?[0-9]{12}):core-network/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-corenetwork.html" + }, + "AWS::NetworkManager::CustomerGatewayAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-customergatewayassociation.html" + }, + "AWS::NetworkManager::Device" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):networkmanager::(?[0-9]{12}):device/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-device.html" + }, + "AWS::NetworkManager::GlobalNetwork" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):networkmanager::(?[0-9]{12}):global-network/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-globalnetwork.html" + }, + "AWS::NetworkManager::Link" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):networkmanager::(?[0-9]{12}):link/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-link.html" + }, + "AWS::NetworkManager::LinkAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-linkassociation.html" + }, + "AWS::NetworkManager::Site" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):networkmanager::(?[0-9]{12}):site/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-site.html" + }, + "AWS::NetworkManager::SiteToSiteVpnAttachment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-sitetositevpnattachment.html" + }, + "AWS::NetworkManager::TransitGatewayRegistration" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-transitgatewayregistration.html" + }, + "AWS::NetworkManager::VpcAttachment" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-networkmanager-vpcattachment.html" + }, + "AWS::NimbleStudio::LaunchProfile" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-nimblestudio-launchprofile.html" + }, + "AWS::NimbleStudio::StreamingImage" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-nimblestudio-streamingimage.html" + }, + "AWS::NimbleStudio::Studio" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-nimblestudio-studio.html" + }, + "AWS::NimbleStudio::StudioComponent" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-nimblestudio-studiocomponent.html" + }, + "AWS::OpenSearchService::Domain" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-opensearchservice-domain.html" + }, + "AWS::OpsWorksCM::Server" : { + "operations" : [ "CREATE", "DELETE", "UPDATE", "LIST", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):opsworks-cm::(?[0-9]{12}):server/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-opsworkscm-server.html" + }, + "AWS::Panorama::ApplicationInstance" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):panorama:(?[a-z0-9-]+):(?[0-9]{12}):applicationInstance/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-panorama-applicationinstance.html" + }, + "AWS::Panorama::Package" : { + "operations" : [ "CREATE", "READ", "UPDATE", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):panorama:(?[a-z0-9-]+):(?[0-9]{12}):package/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-panorama-package.html" + }, + "AWS::Panorama::PackageVersion" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-panorama-packageversion.html" + }, + "AWS::Personalize::Dataset" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):personalize:(?[a-z0-9-]+):(?[0-9]{12}):dataset/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-personalize-dataset.html" + }, + "AWS::Personalize::DatasetGroup" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):personalize:(?[a-z0-9-]+):(?[0-9]{12}):dataset-group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-personalize-datasetgroup.html" + }, + "AWS::Personalize::Schema" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):personalize:(?[a-z0-9-]+):(?[0-9]{12}):schema/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-personalize-schema.html" + }, + "AWS::Personalize::Solution" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):personalize:(?[a-z0-9-]+):(?[0-9]{12}):solution/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-personalize-solution.html" + }, + "AWS::Pinpoint::InAppTemplate" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-pinpoint-inapptemplate.html" + }, + "AWS::QLDB::Stream" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):qldb:(?[a-z0-9-]+):(?[0-9]{12}):stream/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-qldb-stream.html" + }, + "AWS::QuickSight::Analysis" : { + "operations" : [ "READ", "CREATE", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):quicksight:(?[a-z0-9-]+):(?[0-9]{12}):analysis/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-quicksight-analysis.html" + }, + "AWS::QuickSight::Dashboard" : { + "operations" : [ "READ", "CREATE", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):quicksight:(?[a-z0-9-]+):(?[0-9]{12}):dashboard/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-quicksight-dashboard.html" + }, + "AWS::QuickSight::DataSet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):quicksight:(?[a-z0-9-]+):(?[0-9]{12}):dataset/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-quicksight-dataset.html" + }, + "AWS::QuickSight::DataSource" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):quicksight:(?[a-z0-9-]+):(?[0-9]{12}):datasource/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-quicksight-datasource.html" + }, + "AWS::QuickSight::Template" : { + "operations" : [ "READ", "CREATE", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):quicksight:(?[a-z0-9-]+):(?[0-9]{12}):template/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-quicksight-template.html" + }, + "AWS::QuickSight::Theme" : { + "operations" : [ "READ", "CREATE", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):quicksight:(?[a-z0-9-]+):(?[0-9]{12}):theme/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-quicksight-theme.html" + }, + "AWS::RDS::DBCluster" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rds:(?[a-z0-9-]+):(?[0-9]{12}):cluster:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbcluster.html" + }, + "AWS::RDS::DBClusterParameterGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rds:(?[a-z0-9-]+):(?[0-9]{12}):cluster-pg:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbclusterparametergroup.html" + }, + "AWS::RDS::DBInstance" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rds:(?[a-z0-9-]+):(?[0-9]{12}):db:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbinstance.html" + }, + "AWS::RDS::DBParameterGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rds:(?[a-z0-9-]+):(?[0-9]{12}):pg:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbparametergroup.html" + }, + "AWS::RDS::DBProxy" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbproxy.html" + }, + "AWS::RDS::DBProxyEndpoint" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbproxyendpoint.html" + }, + "AWS::RDS::DBProxyTargetGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbproxytargetgroup.html" + }, + "AWS::RDS::DBSubnetGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rds:(?[a-z0-9-]+):(?[0-9]{12}):subgrp:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbsubnetgroup.html" + }, + "AWS::RDS::EventSubscription" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rds:(?[a-z0-9-]+):(?[0-9]{12}):es:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-eventsubscription.html" + }, + "AWS::RDS::GlobalCluster" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rds::(?[0-9]{12}):global-cluster:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-globalcluster.html" + }, + "AWS::RDS::OptionGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rds:(?[a-z0-9-]+):(?[0-9]{12}):og:(?[^/]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-optiongroup.html" + }, + "AWS::RUM::AppMonitor" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rum-appmonitor.html" + }, + "AWS::Redshift::Cluster" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):redshift:(?[a-z0-9-]+):(?[0-9]{12}):cluster:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-redshift-cluster.html" + }, + "AWS::Redshift::ClusterParameterGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):redshift:(?[a-z0-9-]+):(?[0-9]{12}):parametergroup:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-redshift-clusterparametergroup.html" + }, + "AWS::Redshift::ClusterSubnetGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):redshift:(?[a-z0-9-]+):(?[0-9]{12}):subnetgroup:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-redshift-clustersubnetgroup.html" + }, + "AWS::Redshift::EndpointAccess" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-redshift-endpointaccess.html" + }, + "AWS::Redshift::EndpointAuthorization" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-redshift-endpointauthorization.html" + }, + "AWS::Redshift::EventSubscription" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):redshift:(?[a-z0-9-]+):(?[0-9]{12}):eventsubscription:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-redshift-eventsubscription.html" + }, + "AWS::Redshift::ScheduledAction" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-redshift-scheduledaction.html" + }, + "AWS::RedshiftServerless::Namespace" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):redshift-serverless:(?[a-z0-9-]+):(?[0-9]{12}):namespace/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-redshiftserverless-namespace.html" + }, + "AWS::RedshiftServerless::Workgroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):redshift-serverless:(?[a-z0-9-]+):(?[0-9]{12}):workgroup/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-redshiftserverless-workgroup.html" + }, + "AWS::RefactorSpaces::Application" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):refactor-spaces:(?[a-z0-9-]+):(?[0-9]{12}):environment/(?[^/:]+)/application/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-refactorspaces-application.html" + }, + "AWS::RefactorSpaces::Environment" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):refactor-spaces:(?[a-z0-9-]+):(?[0-9]{12}):environment/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-refactorspaces-environment.html" + }, + "AWS::RefactorSpaces::Route" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):refactor-spaces:(?[a-z0-9-]+):(?[0-9]{12}):environment/(?[^/:]+)/application/(?[^/:]+)/route/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-refactorspaces-route.html" + }, + "AWS::RefactorSpaces::Service" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):refactor-spaces:(?[a-z0-9-]+):(?[0-9]{12}):environment/(?[^/:]+)/application/(?[^/:]+)/service/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-refactorspaces-service.html" + }, + "AWS::Rekognition::Collection" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rekognition:(?[a-z0-9-]+):(?[0-9]{12}):collection/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rekognition-collection.html" + }, + "AWS::Rekognition::Project" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rekognition:(?[a-z0-9-]+):(?[0-9]{12}):project/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rekognition-project.html" + }, + "AWS::Rekognition::StreamProcessor" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):rekognition:(?[a-z0-9-]+):(?[0-9]{12}):streamprocessor/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rekognition-streamprocessor.html" + }, + "AWS::ResilienceHub::App" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-resiliencehub-app.html" + }, + "AWS::ResilienceHub::ResiliencyPolicy" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-resiliencehub-resiliencypolicy.html" + }, + "AWS::ResourceGroups::Group" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):resource-groups(?:-(?test|beta|gamma))?:(?[a-z0-9-]+):(?[0-9]{12}):group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-resourcegroups-group.html" + }, + "AWS::RoboMaker::Fleet" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):robomaker:(?[a-z0-9-]+):(?[0-9]{12}):deployment-fleet/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-robomaker-fleet.html" + }, + "AWS::RoboMaker::Robot" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):robomaker:(?[a-z0-9-]+):(?[0-9]{12}):robot/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-robomaker-robot.html" + }, + "AWS::RoboMaker::RobotApplication" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):robomaker:(?[a-z0-9-]+):(?[0-9]{12}):robot-application/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-robomaker-robotapplication.html" + }, + "AWS::RoboMaker::RobotApplicationVersion" : { + "operations" : [ "CREATE", "DELETE", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-robomaker-robotapplicationversion.html" + }, + "AWS::RoboMaker::SimulationApplication" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):robomaker:(?[a-z0-9-]+):(?[0-9]{12}):simulation-application/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-robomaker-simulationapplication.html" + }, + "AWS::RoboMaker::SimulationApplicationVersion" : { + "operations" : [ "CREATE", "DELETE", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-robomaker-simulationapplicationversion.html" + }, + "AWS::RolesAnywhere::CRL" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rolesanywhere-crl.html" + }, + "AWS::RolesAnywhere::Profile" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rolesanywhere-profile.html" + }, + "AWS::RolesAnywhere::TrustAnchor" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rolesanywhere-trustanchor.html" + }, + "AWS::Route53::CidrCollection" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53:::cidrcollection/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53-cidrcollection.html" + }, + "AWS::Route53::DNSSEC" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53-dnssec.html" + }, + "AWS::Route53::HealthCheck" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53:::healthcheck/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53-healthcheck.html" + }, + "AWS::Route53::HostedZone" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53:::hostedzone/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53-hostedzone.html" + }, + "AWS::Route53::KeySigningKey" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53-keysigningkey.html" + }, + "AWS::Route53RecoveryControl::Cluster" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53recoverycontrol-cluster.html" + }, + "AWS::Route53RecoveryControl::ControlPanel" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53recoverycontrol-controlpanel.html" + }, + "AWS::Route53RecoveryControl::RoutingControl" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53recoverycontrol-routingcontrol.html" + }, + "AWS::Route53RecoveryControl::SafetyRule" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53recoverycontrol-safetyrule.html" + }, + "AWS::Route53RecoveryReadiness::Cell" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53-recovery-readiness::(?[0-9]{12}):cell/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53recoveryreadiness-cell.html" + }, + "AWS::Route53RecoveryReadiness::ReadinessCheck" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53-recovery-readiness::(?[0-9]{12}):readiness-check/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53recoveryreadiness-readinesscheck.html" + }, + "AWS::Route53RecoveryReadiness::RecoveryGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53-recovery-readiness::(?[0-9]{12}):recovery-group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53recoveryreadiness-recoverygroup.html" + }, + "AWS::Route53RecoveryReadiness::ResourceSet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53-recovery-readiness::(?[0-9]{12}):resource-set/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53recoveryreadiness-resourceset.html" + }, + "AWS::Route53Resolver::FirewallDomainList" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):route53resolver:(?[a-z0-9-]+):(?[0-9]{12}):firewall-domain-list/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53resolver-firewalldomainlist.html" + }, + "AWS::Route53Resolver::FirewallRuleGroup" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):route53resolver:(?[a-z0-9-]+):(?[0-9]{12}):firewall-rule-group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53resolver-firewallrulegroup.html" + }, + "AWS::Route53Resolver::FirewallRuleGroupAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):route53resolver:(?[a-z0-9-]+):(?[0-9]{12}):firewall-rule-group-association/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53resolver-firewallrulegroupassociation.html" + }, + "AWS::Route53Resolver::ResolverConfig" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53resolver:(?[a-z0-9-]+):(?[0-9]{12}):resolver-config/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53resolver-resolverconfig.html" + }, + "AWS::Route53Resolver::ResolverDNSSECConfig" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53resolver:(?[a-z0-9-]+):(?[0-9]{12}):resolver-dnssec-config/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53resolver-resolverdnssecconfig.html" + }, + "AWS::Route53Resolver::ResolverQueryLoggingConfig" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53resolver-resolverqueryloggingconfig.html" + }, + "AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53resolver-resolverqueryloggingconfigassociation.html" + }, + "AWS::Route53Resolver::ResolverRule" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):route53resolver:(?[a-z0-9-]+):(?[0-9]{12}):resolver-rule/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53resolver-resolverrule.html" + }, + "AWS::Route53Resolver::ResolverRuleAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-route53resolver-resolverruleassociation.html" + }, + "AWS::S3::AccessPoint" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):s3:(?[a-z0-9-]+):(?[0-9]{12}):accesspoint/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3-accesspoint.html" + }, + "AWS::S3::Bucket" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):s3:::(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3-bucket.html" + }, + "AWS::S3::MultiRegionAccessPoint" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):s3::(?[0-9]{12}):accesspoint/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3-multiregionaccesspoint.html" + }, + "AWS::S3::MultiRegionAccessPointPolicy" : { + "operations" : [ "UPDATE", "READ", "DELETE", "CREATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3-multiregionaccesspointpolicy.html" + }, + "AWS::S3::StorageLens" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3-storagelens.html" + }, + "AWS::S3ObjectLambda::AccessPoint" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3objectlambda-accesspoint.html" + }, + "AWS::S3ObjectLambda::AccessPointPolicy" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3objectlambda-accesspointpolicy.html" + }, + "AWS::S3Outposts::AccessPoint" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):s3-outposts:(?[a-z0-9-]+):(?[0-9]{12}):outpost/(?[^/:]+)/accesspoint/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3outposts-accesspoint.html" + }, + "AWS::S3Outposts::Bucket" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):s3-outposts:(?[a-z0-9-]+):(?[0-9]{12}):outpost/(?[^/:]+)/bucket/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3outposts-bucket.html" + }, + "AWS::S3Outposts::BucketPolicy" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3outposts-bucketpolicy.html" + }, + "AWS::S3Outposts::Endpoint" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):s3-outposts:(?[a-z0-9-]+):(?[0-9]{12}):outpost/(?[^/:]+)/endpoint/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-s3outposts-endpoint.html" + }, + "AWS::SES::ConfigurationSet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ses:(?[a-z0-9-]+):(?[0-9]{12}):configuration-set/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ses-configurationset.html" + }, + "AWS::SES::ConfigurationSetEventDestination" : { + "operations" : [ "CREATE", "UPDATE", "DELETE", "READ" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ses-configurationseteventdestination.html" + }, + "AWS::SES::ContactList" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ses:(?[a-z0-9-]+):(?[0-9]{12}):contact-list/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ses-contactlist.html" + }, + "AWS::SES::DedicatedIpPool" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ses:(?[a-z0-9-]+):(?[0-9]{12}):dedicated-ip-pool/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ses-dedicatedippool.html" + }, + "AWS::SES::EmailIdentity" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ses-emailidentity.html" + }, + "AWS::SES::Template" : { + "operations" : [ "CREATE", "READ", "DELETE", "UPDATE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ses:(?[a-z0-9-]+):(?[0-9]{12}):template/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ses-template.html" + }, + "AWS::SNS::Topic" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sns:(?[a-z0-9-]+):(?[0-9]{12}):(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sns-topic.html" + }, + "AWS::SQS::Queue" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sqs:(?[a-z0-9-]+):(?[0-9]{12}):(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queue.html" + }, + "AWS::SSM::Association" : { + "operations" : [ "CREATE", "DELETE", "UPDATE", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):ssm:(?[a-z0-9-]+):(?[0-9]{12}):association/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ssm-association.html" + }, + "AWS::SSM::Document" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ssm:(?[a-z0-9-]+):(?[0-9]{12})?:document/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ssm-document.html" + }, + "AWS::SSM::ResourceDataSync" : { + "operations" : [ "CREATE", "DELETE", "UPDATE", "LIST", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):ssm:(?[a-z0-9-]+):(?[0-9]{12}):resource-data-sync/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ssm-resourcedatasync.html" + }, + "AWS::SSMContacts::Contact" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):ssm-contacts:(?[a-z0-9-]+):(?[0-9]{12}):contact/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ssmcontacts-contact.html" + }, + "AWS::SSMContacts::ContactChannel" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):ssm-contacts:(?[a-z0-9-]+):(?[0-9]{12}):contactchannel/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ssmcontacts-contactchannel.html" + }, + "AWS::SSMIncidents::ReplicationSet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ssm-incidents::(?[0-9]{12}):replication-set/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ssmincidents-replicationset.html" + }, + "AWS::SSMIncidents::ResponsePlan" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):ssm-incidents::(?[0-9]{12}):response-plan/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ssmincidents-responseplan.html" + }, + "AWS::SSO::Assignment" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sso-assignment.html" + }, + "AWS::SSO::InstanceAccessControlAttributeConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sso-instanceaccesscontrolattributeconfiguration.html" + }, + "AWS::SSO::PermissionSet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):sso:::permissionSet/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sso-permissionset.html" + }, + "AWS::SageMaker::App" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):app/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-app.html" + }, + "AWS::SageMaker::AppImageConfig" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):app-image-config/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-appimageconfig.html" + }, + "AWS::SageMaker::DataQualityJobDefinition" : { + "operations" : [ "CREATE", "DELETE", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):data-quality-job-definition/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-dataqualityjobdefinition.html" + }, + "AWS::SageMaker::Device" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):device-fleet/(?[^/:]+)/device/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-device.html" + }, + "AWS::SageMaker::DeviceFleet" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):device-fleet/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-devicefleet.html" + }, + "AWS::SageMaker::Domain" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):domain/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-domain.html" + }, + "AWS::SageMaker::FeatureGroup" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):feature-group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-featuregroup.html" + }, + "AWS::SageMaker::Image" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):image/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-image.html" + }, + "AWS::SageMaker::ImageVersion" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):image-version/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-imageversion.html" + }, + "AWS::SageMaker::ModelBiasJobDefinition" : { + "operations" : [ "CREATE", "DELETE", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):model-bias-job-definition/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-modelbiasjobdefinition.html" + }, + "AWS::SageMaker::ModelExplainabilityJobDefinition" : { + "operations" : [ "CREATE", "DELETE", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):model-explainability-job-definition/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-modelexplainabilityjobdefinition.html" + }, + "AWS::SageMaker::ModelPackage" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):model-package/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-modelpackage.html" + }, + "AWS::SageMaker::ModelPackageGroup" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):model-package-group/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-modelpackagegroup.html" + }, + "AWS::SageMaker::ModelQualityJobDefinition" : { + "operations" : [ "CREATE", "DELETE", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):model-quality-job-definition/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-modelqualityjobdefinition.html" + }, + "AWS::SageMaker::MonitoringSchedule" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):monitoring-schedule/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-monitoringschedule.html" + }, + "AWS::SageMaker::Pipeline" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):pipeline/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-pipeline.html" + }, + "AWS::SageMaker::Project" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):project/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-project.html" + }, + "AWS::SageMaker::UserProfile" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):sagemaker:(?[a-z0-9-]+):(?[0-9]{12}):user-profile/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sagemaker-userprofile.html" + }, + "AWS::ServiceCatalog::CloudFormationProvisionedProduct" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-servicecatalog-cloudformationprovisionedproduct.html" + }, + "AWS::ServiceCatalog::ServiceAction" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-servicecatalog-serviceaction.html" + }, + "AWS::ServiceCatalog::ServiceActionAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-servicecatalog-serviceactionassociation.html" + }, + "AWS::ServiceCatalogAppRegistry::Application" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-servicecatalogappregistry-application.html" + }, + "AWS::ServiceCatalogAppRegistry::AttributeGroup" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-servicecatalogappregistry-attributegroup.html" + }, + "AWS::ServiceCatalogAppRegistry::AttributeGroupAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-servicecatalogappregistry-attributegroupassociation.html" + }, + "AWS::ServiceCatalogAppRegistry::ResourceAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-servicecatalogappregistry-resourceassociation.html" + }, + "AWS::Signer::ProfilePermission" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-signer-profilepermission.html" + }, + "AWS::Signer::SigningProfile" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):signer:(?[a-z0-9-]+):(?[0-9]{12}):/signing-profiles/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-signer-signingprofile.html" + }, + "AWS::StepFunctions::Activity" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):states:(?[a-z0-9-]+):(?[0-9]{12}):activity:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-stepfunctions-activity.html" + }, + "AWS::StepFunctions::StateMachine" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):states:(?[a-z0-9-]+):(?[0-9]{12}):stateMachine:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-stepfunctions-statemachine.html" + }, + "AWS::SupportApp::AccountAlias" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-supportapp-accountalias.html" + }, + "AWS::SupportApp::SlackChannelConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-supportapp-slackchannelconfiguration.html" + }, + "AWS::Synthetics::Canary" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):synthetics:(?[a-z0-9-]+):(?[0-9]{12}):canary:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-synthetics-canary.html" + }, + "AWS::Synthetics::Group" : { + "operations" : [ "CREATE", "UPDATE", "READ", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):synthetics:(?[a-z0-9-]+):(?[0-9]{12}):group:(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-synthetics-group.html" + }, + "AWS::Timestream::Database" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):timestream:(?[a-z0-9-]+):(?[0-9]{12}):database/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-timestream-database.html" + }, + "AWS::Timestream::ScheduledQuery" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):timestream:(?[a-z0-9-]+):(?[0-9]{12}):scheduled-query/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-timestream-scheduledquery.html" + }, + "AWS::Timestream::Table" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):timestream:(?[a-z0-9-]+):(?[0-9]{12}):database/(?[^/:]+)/table/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-timestream-table.html" + }, + "AWS::Transfer::Workflow" : { + "operations" : [ "CREATE", "READ", "DELETE", "LIST", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):transfer:(?[a-z0-9-]+):(?[0-9]{12}):workflow/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-transfer-workflow.html" + }, + "AWS::VoiceID::Domain" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "arnRegex" : "arn:(?[a-z-]+):voiceid:(?[a-z0-9-]+):(?[0-9]{12}):domain/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-voiceid-domain.html" + }, + "AWS::WAFv2::IPSet" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):wafv2:(?[a-z0-9-]+):(?[0-9]{12}):(?[^/:]+)/ipset/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wafv2-ipset.html" + }, + "AWS::WAFv2::LoggingConfiguration" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE", "LIST" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wafv2-loggingconfiguration.html" + }, + "AWS::WAFv2::RegexPatternSet" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):wafv2:(?[a-z0-9-]+):(?[0-9]{12}):(?[^/:]+)/regexpatternset/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wafv2-regexpatternset.html" + }, + "AWS::WAFv2::RuleGroup" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):wafv2:(?[a-z0-9-]+):(?[0-9]{12}):(?[^/:]+)/rulegroup/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wafv2-rulegroup.html" + }, + "AWS::WAFv2::WebACL" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "arnRegex" : "arn:(?[a-z-]+):wafv2:(?[a-z0-9-]+):(?[0-9]{12}):(?[^/:]+)/webacl/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wafv2-webacl.html" + }, + "AWS::WAFv2::WebACLAssociation" : { + "operations" : [ "CREATE", "DELETE", "READ", "UPDATE" ], + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wafv2-webaclassociation.html" + }, + "AWS::Wisdom::Assistant" : { + "operations" : [ "CREATE", "READ", "LIST", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):wisdom:(?[a-z0-9-]+):(?[0-9]{12}):assistant/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wisdom-assistant.html" + }, + "AWS::Wisdom::AssistantAssociation" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):wisdom:(?[a-z0-9-]+):(?[0-9]{12}):association/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wisdom-assistantassociation.html" + }, + "AWS::Wisdom::KnowledgeBase" : { + "operations" : [ "CREATE", "DELETE", "LIST", "READ" ], + "arnRegex" : "arn:(?[a-z-]+):wisdom:(?[a-z0-9-]+):(?[0-9]{12}):knowledge-base/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-wisdom-knowledgebase.html" + }, + "AWS::WorkSpaces::ConnectionAlias" : { + "operations" : [ "CREATE", "READ", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):workspaces:(?[a-z0-9-]+):(?[0-9]{12}):connectionalias/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-workspaces-connectionalias.html" + }, + "AWS::XRay::Group" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):xray:(?[a-z0-9-]+):(?[0-9]{12}):group/(?[^/:]+)/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-xray-group.html" + }, + "AWS::XRay::SamplingRule" : { + "operations" : [ "CREATE", "READ", "UPDATE", "DELETE" ], + "arnRegex" : "arn:(?[a-z-]+):xray:(?[a-z0-9-]+):(?[0-9]{12}):sampling-rule/(?[^/:]+)", + "documentation" : "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-xray-samplingrule.html" + } +} \ No newline at end of file diff --git a/jetbrains-core/resources/codewhisperer/codescan.png b/jetbrains-core/resources/codewhisperer/codescan.png new file mode 100644 index 0000000000..848f28cec0 Binary files /dev/null and b/jetbrains-core/resources/codewhisperer/codescan.png differ diff --git a/jetbrains-core/resources/codewhisperer/licenses.json b/jetbrains-core/resources/codewhisperer/licenses.json new file mode 100644 index 0000000000..58ba5971b7 --- /dev/null +++ b/jetbrains-core/resources/codewhisperer/licenses.json @@ -0,0 +1,455 @@ +{ + "0BSD": "https://spdx.org/licenses/0BSD.html", + "AAL": "https://spdx.org/licenses/AAL.html", + "ADSL": "https://spdx.org/licenses/ADSL.html", + "AFL-1.1": "https://spdx.org/licenses/AFL-1.1.html", + "AFL-1.2": "https://spdx.org/licenses/AFL-1.2.html", + "AFL-2.0": "https://spdx.org/licenses/AFL-2.0.html", + "AFL-2.1": "https://spdx.org/licenses/AFL-2.1.html", + "AFL-3.0": "https://spdx.org/licenses/AFL-3.0.html", + "AGPL-1.0-only": "https://spdx.org/licenses/AGPL-1.0-only.html", + "AGPL-1.0-or-later": "https://spdx.org/licenses/AGPL-1.0-or-later.html", + "AGPL-3.0-only": "https://spdx.org/licenses/AGPL-3.0-only.html", + "AGPL-3.0-or-later": "https://spdx.org/licenses/AGPL-3.0-or-later.html", + "AMDPLPA": "https://spdx.org/licenses/AMDPLPA.html", + "AML": "https://spdx.org/licenses/AML.html", + "AMPAS": "https://spdx.org/licenses/AMPAS.html", + "ANTLR-PD": "https://spdx.org/licenses/ANTLR-PD.html", + "ANTLR-PD-fallback": "https://spdx.org/licenses/ANTLR-PD-fallback.html", + "APAFML": "https://spdx.org/licenses/APAFML.html", + "APL-1.0": "https://spdx.org/licenses/APL-1.0.html", + "APSL-1.0": "https://spdx.org/licenses/APSL-1.0.html", + "APSL-1.1": "https://spdx.org/licenses/APSL-1.1.html", + "APSL-1.2": "https://spdx.org/licenses/APSL-1.2.html", + "APSL-2.0": "https://spdx.org/licenses/APSL-2.0.html", + "Abstyles": "https://spdx.org/licenses/Abstyles.html", + "Adobe-2006": "https://spdx.org/licenses/Adobe-2006.html", + "Adobe-Glyph": "https://spdx.org/licenses/Adobe-Glyph.html", + "Afmparse": "https://spdx.org/licenses/Afmparse.html", + "Aladdin": "https://spdx.org/licenses/Aladdin.html", + "Apache-1.0": "https://spdx.org/licenses/Apache-1.0.html", + "Apache-1.1": "https://spdx.org/licenses/Apache-1.1.html", + "Apache-2.0": "https://spdx.org/licenses/Apache-2.0.html", + "App-s2p": "https://spdx.org/licenses/App-s2p.html", + "Artistic-1.0": "https://spdx.org/licenses/Artistic-1.0.html", + "Artistic-1.0-Perl": "https://spdx.org/licenses/Artistic-1.0-Perl.html", + "Artistic-1.0-cl8": "https://spdx.org/licenses/Artistic-1.0-cl8.html", + "Artistic-2.0": "https://spdx.org/licenses/Artistic-2.0.html", + "BSD-1-Clause": "https://spdx.org/licenses/BSD-1-Clause.html", + "BSD-2-Clause": "https://spdx.org/licenses/BSD-2-Clause.html", + "BSD-2-Clause-Patent": "https://spdx.org/licenses/BSD-2-Clause-Patent.html", + "BSD-2-Clause-Views": "https://spdx.org/licenses/BSD-2-Clause-Views.html", + "BSD-3-Clause": "https://spdx.org/licenses/BSD-3-Clause.html", + "BSD-3-Clause-Attribution": "https://spdx.org/licenses/BSD-3-Clause-Attribution.html", + "BSD-3-Clause-Clear": "https://spdx.org/licenses/BSD-3-Clause-Clear.html", + "BSD-3-Clause-LBNL": "https://spdx.org/licenses/BSD-3-Clause-LBNL.html", + "BSD-3-Clause-Modification": "https://spdx.org/licenses/BSD-3-Clause-Modification.html", + "BSD-3-Clause-No-Military-License": "https://spdx.org/licenses/BSD-3-Clause-No-Military-License.html", + "BSD-3-Clause-No-Nuclear-License": "https://spdx.org/licenses/BSD-3-Clause-No-Nuclear-License.html", + "BSD-3-Clause-No-Nuclear-License-2014": "https://spdx.org/licenses/BSD-3-Clause-No-Nuclear-License-2014.html", + "BSD-3-Clause-No-Nuclear-Warranty": "https://spdx.org/licenses/BSD-3-Clause-No-Nuclear-Warranty.html", + "BSD-3-Clause-Open-MPI": "https://spdx.org/licenses/BSD-3-Clause-Open-MPI.html", + "BSD-4-Clause": "https://spdx.org/licenses/BSD-4-Clause.html", + "BSD-4-Clause-Shortened": "https://spdx.org/licenses/BSD-4-Clause-Shortened.html", + "BSD-4-Clause-UC": "https://spdx.org/licenses/BSD-4-Clause-UC.html", + "BSD-Protection": "https://spdx.org/licenses/BSD-Protection.html", + "BSD-Source-Code": "https://spdx.org/licenses/BSD-Source-Code.html", + "BSL-1.0": "https://spdx.org/licenses/BSL-1.0.html", + "BUSL-1.1": "https://spdx.org/licenses/BUSL-1.1.html", + "Bahyph": "https://spdx.org/licenses/Bahyph.html", + "Barr": "https://spdx.org/licenses/Barr.html", + "Beerware": "https://spdx.org/licenses/Beerware.html", + "BitTorrent-1.0": "https://spdx.org/licenses/BitTorrent-1.0.html", + "BitTorrent-1.1": "https://spdx.org/licenses/BitTorrent-1.1.html", + "BlueOak-1.0.0": "https://spdx.org/licenses/BlueOak-1.0.0.html", + "Borceux": "https://spdx.org/licenses/Borceux.html", + "C-UDA-1.0": "https://spdx.org/licenses/C-UDA-1.0.html", + "CAL-1.0": "https://spdx.org/licenses/CAL-1.0.html", + "CAL-1.0-Combined-Work-Exception": "https://spdx.org/licenses/CAL-1.0-Combined-Work-Exception.html", + "CATOSL-1.1": "https://spdx.org/licenses/CATOSL-1.1.html", + "CC-BY-1.0": "https://spdx.org/licenses/CC-BY-1.0.html", + "CC-BY-2.0": "https://spdx.org/licenses/CC-BY-2.0.html", + "CC-BY-2.5": "https://spdx.org/licenses/CC-BY-2.5.html", + "CC-BY-2.5-AU": "https://spdx.org/licenses/CC-BY-2.5-AU.html", + "CC-BY-3.0": "https://spdx.org/licenses/CC-BY-3.0.html", + "CC-BY-3.0-AT": "https://spdx.org/licenses/CC-BY-3.0-AT.html", + "CC-BY-3.0-DE": "https://spdx.org/licenses/CC-BY-3.0-DE.html", + "CC-BY-3.0-NL": "https://spdx.org/licenses/CC-BY-3.0-NL.html", + "CC-BY-3.0-US": "https://spdx.org/licenses/CC-BY-3.0-US.html", + "CC-BY-4.0": "https://spdx.org/licenses/CC-BY-4.0.html", + "CC-BY-NC-1.0": "https://spdx.org/licenses/CC-BY-NC-1.0.html", + "CC-BY-NC-2.0": "https://spdx.org/licenses/CC-BY-NC-2.0.html", + "CC-BY-NC-2.5": "https://spdx.org/licenses/CC-BY-NC-2.5.html", + "CC-BY-NC-3.0": "https://spdx.org/licenses/CC-BY-NC-3.0.html", + "CC-BY-NC-3.0-DE": "https://spdx.org/licenses/CC-BY-NC-3.0-DE.html", + "CC-BY-NC-4.0": "https://spdx.org/licenses/CC-BY-NC-4.0.html", + "CC-BY-NC-ND-1.0": "https://spdx.org/licenses/CC-BY-NC-ND-1.0.html", + "CC-BY-NC-ND-2.0": "https://spdx.org/licenses/CC-BY-NC-ND-2.0.html", + "CC-BY-NC-ND-2.5": "https://spdx.org/licenses/CC-BY-NC-ND-2.5.html", + "CC-BY-NC-ND-3.0": "https://spdx.org/licenses/CC-BY-NC-ND-3.0.html", + "CC-BY-NC-ND-3.0-DE": "https://spdx.org/licenses/CC-BY-NC-ND-3.0-DE.html", + "CC-BY-NC-ND-3.0-IGO": "https://spdx.org/licenses/CC-BY-NC-ND-3.0-IGO.html", + "CC-BY-NC-ND-4.0": "https://spdx.org/licenses/CC-BY-NC-ND-4.0.html", + "CC-BY-NC-SA-1.0": "https://spdx.org/licenses/CC-BY-NC-SA-1.0.html", + "CC-BY-NC-SA-2.0": "https://spdx.org/licenses/CC-BY-NC-SA-2.0.html", + "CC-BY-NC-SA-2.0-FR": "https://spdx.org/licenses/CC-BY-NC-SA-2.0-FR.html", + "CC-BY-NC-SA-2.0-UK": "https://spdx.org/licenses/CC-BY-NC-SA-2.0-UK.html", + "CC-BY-NC-SA-2.5": "https://spdx.org/licenses/CC-BY-NC-SA-2.5.html", + "CC-BY-NC-SA-3.0": "https://spdx.org/licenses/CC-BY-NC-SA-3.0.html", + "CC-BY-NC-SA-3.0-DE": "https://spdx.org/licenses/CC-BY-NC-SA-3.0-DE.html", + "CC-BY-NC-SA-3.0-IGO": "https://spdx.org/licenses/CC-BY-NC-SA-3.0-IGO.html", + "CC-BY-NC-SA-4.0": "https://spdx.org/licenses/CC-BY-NC-SA-4.0.html", + "CC-BY-ND-1.0": "https://spdx.org/licenses/CC-BY-ND-1.0.html", + "CC-BY-ND-2.0": "https://spdx.org/licenses/CC-BY-ND-2.0.html", + "CC-BY-ND-2.5": "https://spdx.org/licenses/CC-BY-ND-2.5.html", + "CC-BY-ND-3.0": "https://spdx.org/licenses/CC-BY-ND-3.0.html", + "CC-BY-ND-3.0-DE": "https://spdx.org/licenses/CC-BY-ND-3.0-DE.html", + "CC-BY-ND-4.0": "https://spdx.org/licenses/CC-BY-ND-4.0.html", + "CC-BY-SA-1.0": "https://spdx.org/licenses/CC-BY-SA-1.0.html", + "CC-BY-SA-2.0": "https://spdx.org/licenses/CC-BY-SA-2.0.html", + "CC-BY-SA-2.0-UK": "https://spdx.org/licenses/CC-BY-SA-2.0-UK.html", + "CC-BY-SA-2.1-JP": "https://spdx.org/licenses/CC-BY-SA-2.1-JP.html", + "CC-BY-SA-2.5": "https://spdx.org/licenses/CC-BY-SA-2.5.html", + "CC-BY-SA-3.0": "https://spdx.org/licenses/CC-BY-SA-3.0.html", + "CC-BY-SA-3.0-AT": "https://spdx.org/licenses/CC-BY-SA-3.0-AT.html", + "CC-BY-SA-3.0-DE": "https://spdx.org/licenses/CC-BY-SA-3.0-DE.html", + "CC-BY-SA-4.0": "https://spdx.org/licenses/CC-BY-SA-4.0.html", + "CC-PDDC": "https://spdx.org/licenses/CC-PDDC.html", + "CC0-1.0": "https://spdx.org/licenses/CC0-1.0.html", + "CDDL-1.0": "https://spdx.org/licenses/CDDL-1.0.html", + "CDDL-1.1": "https://spdx.org/licenses/CDDL-1.1.html", + "CDL-1.0": "https://spdx.org/licenses/CDL-1.0.html", + "CDLA-Permissive-1.0": "https://spdx.org/licenses/CDLA-Permissive-1.0.html", + "CDLA-Permissive-2.0": "https://spdx.org/licenses/CDLA-Permissive-2.0.html", + "CDLA-Sharing-1.0": "https://spdx.org/licenses/CDLA-Sharing-1.0.html", + "CECILL-1.0": "https://spdx.org/licenses/CECILL-1.0.html", + "CECILL-1.1": "https://spdx.org/licenses/CECILL-1.1.html", + "CECILL-2.0": "https://spdx.org/licenses/CECILL-2.0.html", + "CECILL-2.1": "https://spdx.org/licenses/CECILL-2.1.html", + "CECILL-B": "https://spdx.org/licenses/CECILL-B.html", + "CECILL-C": "https://spdx.org/licenses/CECILL-C.html", + "CERN-OHL-1.1": "https://spdx.org/licenses/CERN-OHL-1.1.html", + "CERN-OHL-1.2": "https://spdx.org/licenses/CERN-OHL-1.2.html", + "CERN-OHL-P-2.0": "https://spdx.org/licenses/CERN-OHL-P-2.0.html", + "CERN-OHL-S-2.0": "https://spdx.org/licenses/CERN-OHL-S-2.0.html", + "CERN-OHL-W-2.0": "https://spdx.org/licenses/CERN-OHL-W-2.0.html", + "CNRI-Jython": "https://spdx.org/licenses/CNRI-Jython.html", + "CNRI-Python": "https://spdx.org/licenses/CNRI-Python.html", + "CNRI-Python-GPL-Compatible": "https://spdx.org/licenses/CNRI-Python-GPL-Compatible.html", + "COIL-1.0": "https://spdx.org/licenses/COIL-1.0.html", + "CPAL-1.0": "https://spdx.org/licenses/CPAL-1.0.html", + "CPL-1.0": "https://spdx.org/licenses/CPL-1.0.html", + "CPOL-1.02": "https://spdx.org/licenses/CPOL-1.02.html", + "CUA-OPL-1.0": "https://spdx.org/licenses/CUA-OPL-1.0.html", + "Caldera": "https://spdx.org/licenses/Caldera.html", + "ClArtistic": "https://spdx.org/licenses/ClArtistic.html", + "Community-Spec-1.0": "https://spdx.org/licenses/Community-Spec-1.0.html", + "Condor-1.1": "https://spdx.org/licenses/Condor-1.1.html", + "Crossword": "https://spdx.org/licenses/Crossword.html", + "CrystalStacker": "https://spdx.org/licenses/CrystalStacker.html", + "Cube": "https://spdx.org/licenses/Cube.html", + "D-FSL-1.0": "https://spdx.org/licenses/D-FSL-1.0.html", + "DL-DE-BY-2.0": "https://spdx.org/licenses/DL-DE-BY-2.0.html", + "DOC": "https://spdx.org/licenses/DOC.html", + "DRL-1.0": "https://spdx.org/licenses/DRL-1.0.html", + "DSDP": "https://spdx.org/licenses/DSDP.html", + "Dotseqn": "https://spdx.org/licenses/Dotseqn.html", + "ECL-1.0": "https://spdx.org/licenses/ECL-1.0.html", + "ECL-2.0": "https://spdx.org/licenses/ECL-2.0.html", + "EFL-1.0": "https://spdx.org/licenses/EFL-1.0.html", + "EFL-2.0": "https://spdx.org/licenses/EFL-2.0.html", + "EPICS": "https://spdx.org/licenses/EPICS.html", + "EPL-1.0": "https://spdx.org/licenses/EPL-1.0.html", + "EPL-2.0": "https://spdx.org/licenses/EPL-2.0.html", + "EUDatagrid": "https://spdx.org/licenses/EUDatagrid.html", + "EUPL-1.0": "https://spdx.org/licenses/EUPL-1.0.html", + "EUPL-1.1": "https://spdx.org/licenses/EUPL-1.1.html", + "EUPL-1.2": "https://spdx.org/licenses/EUPL-1.2.html", + "Elastic-2.0": "https://spdx.org/licenses/Elastic-2.0.html", + "Entessa": "https://spdx.org/licenses/Entessa.html", + "ErlPL-1.1": "https://spdx.org/licenses/ErlPL-1.1.html", + "Eurosym": "https://spdx.org/licenses/Eurosym.html", + "FDK-AAC": "https://spdx.org/licenses/FDK-AAC.html", + "FSFAP": "https://spdx.org/licenses/FSFAP.html", + "FSFUL": "https://spdx.org/licenses/FSFUL.html", + "FSFULLR": "https://spdx.org/licenses/FSFULLR.html", + "FTL": "https://spdx.org/licenses/FTL.html", + "Fair": "https://spdx.org/licenses/Fair.html", + "Frameworx-1.0": "https://spdx.org/licenses/Frameworx-1.0.html", + "FreeBSD-DOC": "https://spdx.org/licenses/FreeBSD-DOC.html", + "FreeImage": "https://spdx.org/licenses/FreeImage.html", + "GD": "https://spdx.org/licenses/GD.html", + "GFDL-1.1-invariants-only": "https://spdx.org/licenses/GFDL-1.1-invariants-only.html", + "GFDL-1.1-invariants-or-later": "https://spdx.org/licenses/GFDL-1.1-invariants-or-later.html", + "GFDL-1.1-no-invariants-only": "https://spdx.org/licenses/GFDL-1.1-no-invariants-only.html", + "GFDL-1.1-no-invariants-or-later": "https://spdx.org/licenses/GFDL-1.1-no-invariants-or-later.html", + "GFDL-1.1-only": "https://spdx.org/licenses/GFDL-1.1-only.html", + "GFDL-1.1-or-later": "https://spdx.org/licenses/GFDL-1.1-or-later.html", + "GFDL-1.2-invariants-only": "https://spdx.org/licenses/GFDL-1.2-invariants-only.html", + "GFDL-1.2-invariants-or-later": "https://spdx.org/licenses/GFDL-1.2-invariants-or-later.html", + "GFDL-1.2-no-invariants-only": "https://spdx.org/licenses/GFDL-1.2-no-invariants-only.html", + "GFDL-1.2-no-invariants-or-later": "https://spdx.org/licenses/GFDL-1.2-no-invariants-or-later.html", + "GFDL-1.2-only": "https://spdx.org/licenses/GFDL-1.2-only.html", + "GFDL-1.2-or-later": "https://spdx.org/licenses/GFDL-1.2-or-later.html", + "GFDL-1.3-invariants-only": "https://spdx.org/licenses/GFDL-1.3-invariants-only.html", + "GFDL-1.3-invariants-or-later": "https://spdx.org/licenses/GFDL-1.3-invariants-or-later.html", + "GFDL-1.3-no-invariants-only": "https://spdx.org/licenses/GFDL-1.3-no-invariants-only.html", + "GFDL-1.3-no-invariants-or-later": "https://spdx.org/licenses/GFDL-1.3-no-invariants-or-later.html", + "GFDL-1.3-only": "https://spdx.org/licenses/GFDL-1.3-only.html", + "GFDL-1.3-or-later": "https://spdx.org/licenses/GFDL-1.3-or-later.html", + "GL2PS": "https://spdx.org/licenses/GL2PS.html", + "GLWTPL": "https://spdx.org/licenses/GLWTPL.html", + "GPL-1.0-only": "https://spdx.org/licenses/GPL-1.0-only.html", + "GPL-1.0-or-later": "https://spdx.org/licenses/GPL-1.0-or-later.html", + "GPL-2.0-only": "https://spdx.org/licenses/GPL-2.0-only.html", + "GPL-2.0-or-later": "https://spdx.org/licenses/GPL-2.0-or-later.html", + "GPL-3.0-only": "https://spdx.org/licenses/GPL-3.0-only.html", + "GPL-3.0-or-later": "https://spdx.org/licenses/GPL-3.0-or-later.html", + "Giftware": "https://spdx.org/licenses/Giftware.html", + "Glide": "https://spdx.org/licenses/Glide.html", + "Glulxe": "https://spdx.org/licenses/Glulxe.html", + "HPND": "https://spdx.org/licenses/HPND.html", + "HPND-sell-variant": "https://spdx.org/licenses/HPND-sell-variant.html", + "HTMLTIDY": "https://spdx.org/licenses/HTMLTIDY.html", + "HaskellReport": "https://spdx.org/licenses/HaskellReport.html", + "Hippocratic-2.1": "https://spdx.org/licenses/Hippocratic-2.1.html", + "IBM-pibs": "https://spdx.org/licenses/IBM-pibs.html", + "ICU": "https://spdx.org/licenses/ICU.html", + "IJG": "https://spdx.org/licenses/IJG.html", + "IPA": "https://spdx.org/licenses/IPA.html", + "IPL-1.0": "https://spdx.org/licenses/IPL-1.0.html", + "ISC": "https://spdx.org/licenses/ISC.html", + "ImageMagick": "https://spdx.org/licenses/ImageMagick.html", + "Imlib2": "https://spdx.org/licenses/Imlib2.html", + "Info-ZIP": "https://spdx.org/licenses/Info-ZIP.html", + "Intel": "https://spdx.org/licenses/Intel.html", + "Intel-ACPI": "https://spdx.org/licenses/Intel-ACPI.html", + "Interbase-1.0": "https://spdx.org/licenses/Interbase-1.0.html", + "JPNIC": "https://spdx.org/licenses/JPNIC.html", + "JSON": "https://spdx.org/licenses/JSON.html", + "Jam": "https://spdx.org/licenses/Jam.html", + "JasPer-2.0": "https://spdx.org/licenses/JasPer-2.0.html", + "LAL-1.2": "https://spdx.org/licenses/LAL-1.2.html", + "LAL-1.3": "https://spdx.org/licenses/LAL-1.3.html", + "LGPL-2.0-only": "https://spdx.org/licenses/LGPL-2.0-only.html", + "LGPL-2.0-or-later": "https://spdx.org/licenses/LGPL-2.0-or-later.html", + "LGPL-2.1-only": "https://spdx.org/licenses/LGPL-2.1-only.html", + "LGPL-2.1-or-later": "https://spdx.org/licenses/LGPL-2.1-or-later.html", + "LGPL-3.0-only": "https://spdx.org/licenses/LGPL-3.0-only.html", + "LGPL-3.0-or-later": "https://spdx.org/licenses/LGPL-3.0-or-later.html", + "LGPLLR": "https://spdx.org/licenses/LGPLLR.html", + "LPL-1.0": "https://spdx.org/licenses/LPL-1.0.html", + "LPL-1.02": "https://spdx.org/licenses/LPL-1.02.html", + "LPPL-1.0": "https://spdx.org/licenses/LPPL-1.0.html", + "LPPL-1.1": "https://spdx.org/licenses/LPPL-1.1.html", + "LPPL-1.2": "https://spdx.org/licenses/LPPL-1.2.html", + "LPPL-1.3a": "https://spdx.org/licenses/LPPL-1.3a.html", + "LPPL-1.3c": "https://spdx.org/licenses/LPPL-1.3c.html", + "Latex2e": "https://spdx.org/licenses/Latex2e.html", + "Leptonica": "https://spdx.org/licenses/Leptonica.html", + "LiLiQ-P-1.1": "https://spdx.org/licenses/LiLiQ-P-1.1.html", + "LiLiQ-R-1.1": "https://spdx.org/licenses/LiLiQ-R-1.1.html", + "LiLiQ-Rplus-1.1": "https://spdx.org/licenses/LiLiQ-Rplus-1.1.html", + "Libpng": "https://spdx.org/licenses/Libpng.html", + "Linux-OpenIB": "https://spdx.org/licenses/Linux-OpenIB.html", + "Linux-man-pages-copyleft": "https://spdx.org/licenses/Linux-man-pages-copyleft.html", + "MIT": "https://spdx.org/licenses/MIT.html", + "MIT-0": "https://spdx.org/licenses/MIT-0.html", + "MIT-CMU": "https://spdx.org/licenses/MIT-CMU.html", + "MIT-Modern-Variant": "https://spdx.org/licenses/MIT-Modern-Variant.html", + "MIT-advertising": "https://spdx.org/licenses/MIT-advertising.html", + "MIT-enna": "https://spdx.org/licenses/MIT-enna.html", + "MIT-feh": "https://spdx.org/licenses/MIT-feh.html", + "MIT-open-group": "https://spdx.org/licenses/MIT-open-group.html", + "MITNFA": "https://spdx.org/licenses/MITNFA.html", + "MPL-1.0": "https://spdx.org/licenses/MPL-1.0.html", + "MPL-1.1": "https://spdx.org/licenses/MPL-1.1.html", + "MPL-2.0": "https://spdx.org/licenses/MPL-2.0.html", + "MPL-2.0-no-copyleft-exception": "https://spdx.org/licenses/MPL-2.0-no-copyleft-exception.html", + "MS-PL": "https://spdx.org/licenses/MS-PL.html", + "MS-RL": "https://spdx.org/licenses/MS-RL.html", + "MTLL": "https://spdx.org/licenses/MTLL.html", + "MakeIndex": "https://spdx.org/licenses/MakeIndex.html", + "MirOS": "https://spdx.org/licenses/MirOS.html", + "Motosoto": "https://spdx.org/licenses/Motosoto.html", + "MulanPSL-1.0": "https://spdx.org/licenses/MulanPSL-1.0.html", + "MulanPSL-2.0": "https://spdx.org/licenses/MulanPSL-2.0.html", + "Multics": "https://spdx.org/licenses/Multics.html", + "Mup": "https://spdx.org/licenses/Mup.html", + "NAIST-2003": "https://spdx.org/licenses/NAIST-2003.html", + "NASA-1.3": "https://spdx.org/licenses/NASA-1.3.html", + "NBPL-1.0": "https://spdx.org/licenses/NBPL-1.0.html", + "NCGL-UK-2.0": "https://spdx.org/licenses/NCGL-UK-2.0.html", + "NCSA": "https://spdx.org/licenses/NCSA.html", + "NGPL": "https://spdx.org/licenses/NGPL.html", + "NIST-PD": "https://spdx.org/licenses/NIST-PD.html", + "NIST-PD-fallback": "https://spdx.org/licenses/NIST-PD-fallback.html", + "NLOD-1.0": "https://spdx.org/licenses/NLOD-1.0.html", + "NLOD-2.0": "https://spdx.org/licenses/NLOD-2.0.html", + "NLPL": "https://spdx.org/licenses/NLPL.html", + "NOSL": "https://spdx.org/licenses/NOSL.html", + "NPL-1.0": "https://spdx.org/licenses/NPL-1.0.html", + "NPL-1.1": "https://spdx.org/licenses/NPL-1.1.html", + "NPOSL-3.0": "https://spdx.org/licenses/NPOSL-3.0.html", + "NRL": "https://spdx.org/licenses/NRL.html", + "NTP": "https://spdx.org/licenses/NTP.html", + "NTP-0": "https://spdx.org/licenses/NTP-0.html", + "Naumen": "https://spdx.org/licenses/Naumen.html", + "Net-SNMP": "https://spdx.org/licenses/Net-SNMP.html", + "NetCDF": "https://spdx.org/licenses/NetCDF.html", + "Newsletr": "https://spdx.org/licenses/Newsletr.html", + "Nokia": "https://spdx.org/licenses/Nokia.html", + "Noweb": "https://spdx.org/licenses/Noweb.html", + "O-UDA-1.0": "https://spdx.org/licenses/O-UDA-1.0.html", + "OCCT-PL": "https://spdx.org/licenses/OCCT-PL.html", + "OCLC-2.0": "https://spdx.org/licenses/OCLC-2.0.html", + "ODC-By-1.0": "https://spdx.org/licenses/ODC-By-1.0.html", + "ODbL-1.0": "https://spdx.org/licenses/ODbL-1.0.html", + "OFL-1.0": "https://spdx.org/licenses/OFL-1.0.html", + "OFL-1.0-RFN": "https://spdx.org/licenses/OFL-1.0-RFN.html", + "OFL-1.0-no-RFN": "https://spdx.org/licenses/OFL-1.0-no-RFN.html", + "OFL-1.1": "https://spdx.org/licenses/OFL-1.1.html", + "OFL-1.1-RFN": "https://spdx.org/licenses/OFL-1.1-RFN.html", + "OFL-1.1-no-RFN": "https://spdx.org/licenses/OFL-1.1-no-RFN.html", + "OGC-1.0": "https://spdx.org/licenses/OGC-1.0.html", + "OGDL-Taiwan-1.0": "https://spdx.org/licenses/OGDL-Taiwan-1.0.html", + "OGL-Canada-2.0": "https://spdx.org/licenses/OGL-Canada-2.0.html", + "OGL-UK-1.0": "https://spdx.org/licenses/OGL-UK-1.0.html", + "OGL-UK-2.0": "https://spdx.org/licenses/OGL-UK-2.0.html", + "OGL-UK-3.0": "https://spdx.org/licenses/OGL-UK-3.0.html", + "OGTSL": "https://spdx.org/licenses/OGTSL.html", + "OLDAP-1.1": "https://spdx.org/licenses/OLDAP-1.1.html", + "OLDAP-1.2": "https://spdx.org/licenses/OLDAP-1.2.html", + "OLDAP-1.3": "https://spdx.org/licenses/OLDAP-1.3.html", + "OLDAP-1.4": "https://spdx.org/licenses/OLDAP-1.4.html", + "OLDAP-2.0": "https://spdx.org/licenses/OLDAP-2.0.html", + "OLDAP-2.0.1": "https://spdx.org/licenses/OLDAP-2.0.1.html", + "OLDAP-2.1": "https://spdx.org/licenses/OLDAP-2.1.html", + "OLDAP-2.2": "https://spdx.org/licenses/OLDAP-2.2.html", + "OLDAP-2.2.1": "https://spdx.org/licenses/OLDAP-2.2.1.html", + "OLDAP-2.2.2": "https://spdx.org/licenses/OLDAP-2.2.2.html", + "OLDAP-2.3": "https://spdx.org/licenses/OLDAP-2.3.html", + "OLDAP-2.4": "https://spdx.org/licenses/OLDAP-2.4.html", + "OLDAP-2.5": "https://spdx.org/licenses/OLDAP-2.5.html", + "OLDAP-2.6": "https://spdx.org/licenses/OLDAP-2.6.html", + "OLDAP-2.7": "https://spdx.org/licenses/OLDAP-2.7.html", + "OLDAP-2.8": "https://spdx.org/licenses/OLDAP-2.8.html", + "OML": "https://spdx.org/licenses/OML.html", + "OPL-1.0": "https://spdx.org/licenses/OPL-1.0.html", + "OPUBL-1.0": "https://spdx.org/licenses/OPUBL-1.0.html", + "OSET-PL-2.1": "https://spdx.org/licenses/OSET-PL-2.1.html", + "OSL-1.0": "https://spdx.org/licenses/OSL-1.0.html", + "OSL-1.1": "https://spdx.org/licenses/OSL-1.1.html", + "OSL-2.0": "https://spdx.org/licenses/OSL-2.0.html", + "OSL-2.1": "https://spdx.org/licenses/OSL-2.1.html", + "OSL-3.0": "https://spdx.org/licenses/OSL-3.0.html", + "OpenSSL": "https://spdx.org/licenses/OpenSSL.html", + "PDDL-1.0": "https://spdx.org/licenses/PDDL-1.0.html", + "PHP-3.0": "https://spdx.org/licenses/PHP-3.0.html", + "PHP-3.01": "https://spdx.org/licenses/PHP-3.01.html", + "PSF-2.0": "https://spdx.org/licenses/PSF-2.0.html", + "Parity-6.0.0": "https://spdx.org/licenses/Parity-6.0.0.html", + "Parity-7.0.0": "https://spdx.org/licenses/Parity-7.0.0.html", + "Plexus": "https://spdx.org/licenses/Plexus.html", + "PolyForm-Noncommercial-1.0.0": "https://spdx.org/licenses/PolyForm-Noncommercial-1.0.0.html", + "PolyForm-Small-Business-1.0.0": "https://spdx.org/licenses/PolyForm-Small-Business-1.0.0.html", + "PostgreSQL": "https://spdx.org/licenses/PostgreSQL.html", + "Python-2.0": "https://spdx.org/licenses/Python-2.0.html", + "QPL-1.0": "https://spdx.org/licenses/QPL-1.0.html", + "Qhull": "https://spdx.org/licenses/Qhull.html", + "RHeCos-1.1": "https://spdx.org/licenses/RHeCos-1.1.html", + "RPL-1.1": "https://spdx.org/licenses/RPL-1.1.html", + "RPL-1.5": "https://spdx.org/licenses/RPL-1.5.html", + "RPSL-1.0": "https://spdx.org/licenses/RPSL-1.0.html", + "RSA-MD": "https://spdx.org/licenses/RSA-MD.html", + "RSCPL": "https://spdx.org/licenses/RSCPL.html", + "Rdisc": "https://spdx.org/licenses/Rdisc.html", + "Ruby": "https://spdx.org/licenses/Ruby.html", + "SAX-PD": "https://spdx.org/licenses/SAX-PD.html", + "SCEA": "https://spdx.org/licenses/SCEA.html", + "SGI-B-1.0": "https://spdx.org/licenses/SGI-B-1.0.html", + "SGI-B-1.1": "https://spdx.org/licenses/SGI-B-1.1.html", + "SGI-B-2.0": "https://spdx.org/licenses/SGI-B-2.0.html", + "SHL-0.5": "https://spdx.org/licenses/SHL-0.5.html", + "SHL-0.51": "https://spdx.org/licenses/SHL-0.51.html", + "SISSL": "https://spdx.org/licenses/SISSL.html", + "SISSL-1.2": "https://spdx.org/licenses/SISSL-1.2.html", + "SMLNJ": "https://spdx.org/licenses/SMLNJ.html", + "SMPPL": "https://spdx.org/licenses/SMPPL.html", + "SNIA": "https://spdx.org/licenses/SNIA.html", + "SPL-1.0": "https://spdx.org/licenses/SPL-1.0.html", + "SSH-OpenSSH": "https://spdx.org/licenses/SSH-OpenSSH.html", + "SSH-short": "https://spdx.org/licenses/SSH-short.html", + "SSPL-1.0": "https://spdx.org/licenses/SSPL-1.0.html", + "SWL": "https://spdx.org/licenses/SWL.html", + "Saxpath": "https://spdx.org/licenses/Saxpath.html", + "SchemeReport": "https://spdx.org/licenses/SchemeReport.html", + "Sendmail": "https://spdx.org/licenses/Sendmail.html", + "Sendmail-8.23": "https://spdx.org/licenses/Sendmail-8.23.html", + "SimPL-2.0": "https://spdx.org/licenses/SimPL-2.0.html", + "Sleepycat": "https://spdx.org/licenses/Sleepycat.html", + "Spencer-86": "https://spdx.org/licenses/Spencer-86.html", + "Spencer-94": "https://spdx.org/licenses/Spencer-94.html", + "Spencer-99": "https://spdx.org/licenses/Spencer-99.html", + "SugarCRM-1.1.3": "https://spdx.org/licenses/SugarCRM-1.1.3.html", + "TAPR-OHL-1.0": "https://spdx.org/licenses/TAPR-OHL-1.0.html", + "TCL": "https://spdx.org/licenses/TCL.html", + "TCP-wrappers": "https://spdx.org/licenses/TCP-wrappers.html", + "TMate": "https://spdx.org/licenses/TMate.html", + "TORQUE-1.1": "https://spdx.org/licenses/TORQUE-1.1.html", + "TOSL": "https://spdx.org/licenses/TOSL.html", + "TU-Berlin-1.0": "https://spdx.org/licenses/TU-Berlin-1.0.html", + "TU-Berlin-2.0": "https://spdx.org/licenses/TU-Berlin-2.0.html", + "UCL-1.0": "https://spdx.org/licenses/UCL-1.0.html", + "UPL-1.0": "https://spdx.org/licenses/UPL-1.0.html", + "Unicode-DFS-2015": "https://spdx.org/licenses/Unicode-DFS-2015.html", + "Unicode-DFS-2016": "https://spdx.org/licenses/Unicode-DFS-2016.html", + "Unicode-TOU": "https://spdx.org/licenses/Unicode-TOU.html", + "Unlicense": "https://spdx.org/licenses/Unlicense.html", + "VOSTROM": "https://spdx.org/licenses/VOSTROM.html", + "VSL-1.0": "https://spdx.org/licenses/VSL-1.0.html", + "Vim": "https://spdx.org/licenses/Vim.html", + "W3C": "https://spdx.org/licenses/W3C.html", + "W3C-19980720": "https://spdx.org/licenses/W3C-19980720.html", + "W3C-20150513": "https://spdx.org/licenses/W3C-20150513.html", + "WTFPL": "https://spdx.org/licenses/WTFPL.html", + "Watcom-1.0": "https://spdx.org/licenses/Watcom-1.0.html", + "Wsuipa": "https://spdx.org/licenses/Wsuipa.html", + "X11": "https://spdx.org/licenses/X11.html", + "X11-distribute-modifications-variant": "https://spdx.org/licenses/X11-distribute-modifications-variant.html", + "XFree86-1.1": "https://spdx.org/licenses/XFree86-1.1.html", + "XSkat": "https://spdx.org/licenses/XSkat.html", + "Xerox": "https://spdx.org/licenses/Xerox.html", + "Xnet": "https://spdx.org/licenses/Xnet.html", + "YPL-1.0": "https://spdx.org/licenses/YPL-1.0.html", + "YPL-1.1": "https://spdx.org/licenses/YPL-1.1.html", + "ZPL-1.1": "https://spdx.org/licenses/ZPL-1.1.html", + "ZPL-2.0": "https://spdx.org/licenses/ZPL-2.0.html", + "ZPL-2.1": "https://spdx.org/licenses/ZPL-2.1.html", + "Zed": "https://spdx.org/licenses/Zed.html", + "Zend-2.0": "https://spdx.org/licenses/Zend-2.0.html", + "Zimbra-1.3": "https://spdx.org/licenses/Zimbra-1.3.html", + "Zimbra-1.4": "https://spdx.org/licenses/Zimbra-1.4.html", + "Zlib": "https://spdx.org/licenses/Zlib.html", + "blessing": "https://spdx.org/licenses/blessing.html", + "bzip2-1.0.6": "https://spdx.org/licenses/bzip2-1.0.6.html", + "copyleft-next-0.3.0": "https://spdx.org/licenses/copyleft-next-0.3.0.html", + "copyleft-next-0.3.1": "https://spdx.org/licenses/copyleft-next-0.3.1.html", + "curl": "https://spdx.org/licenses/curl.html", + "diffmark": "https://spdx.org/licenses/diffmark.html", + "dvipdfm": "https://spdx.org/licenses/dvipdfm.html", + "eGenix": "https://spdx.org/licenses/eGenix.html", + "etalab-2.0": "https://spdx.org/licenses/etalab-2.0.html", + "gSOAP-1.3b": "https://spdx.org/licenses/gSOAP-1.3b.html", + "gnuplot": "https://spdx.org/licenses/gnuplot.html", + "iMatix": "https://spdx.org/licenses/iMatix.html", + "libpng-2.0": "https://spdx.org/licenses/libpng-2.0.html", + "libselinux-1.0": "https://spdx.org/licenses/libselinux-1.0.html", + "libtiff": "https://spdx.org/licenses/libtiff.html", + "mpich2": "https://spdx.org/licenses/mpich2.html", + "psfrag": "https://spdx.org/licenses/psfrag.html", + "psutils": "https://spdx.org/licenses/psutils.html", + "xinetd": "https://spdx.org/licenses/xinetd.html", + "xpp": "https://spdx.org/licenses/xpp.html", + "zlib-acknowledgement": "https://spdx.org/licenses/zlib-acknowledgement.html" +} diff --git a/jetbrains-core/resources/codewhisperer/workshop.png b/jetbrains-core/resources/codewhisperer/workshop.png new file mode 100644 index 0000000000..91ce4ad324 Binary files /dev/null and b/jetbrains-core/resources/codewhisperer/workshop.png differ diff --git a/jetbrains-core/resources/gettingstarted/codecatalyst.png b/jetbrains-core/resources/gettingstarted/codecatalyst.png new file mode 100644 index 0000000000..460d0a7e63 Binary files /dev/null and b/jetbrains-core/resources/gettingstarted/codecatalyst.png differ diff --git a/jetbrains-core/resources/gettingstarted/explorer.png b/jetbrains-core/resources/gettingstarted/explorer.png new file mode 100644 index 0000000000..ebcd6c503e Binary files /dev/null and b/jetbrains-core/resources/gettingstarted/explorer.png differ diff --git a/jetbrains-core/resources/gettingstarted/q.png b/jetbrains-core/resources/gettingstarted/q.png new file mode 100644 index 0000000000..e253fdc5d5 Binary files /dev/null and b/jetbrains-core/resources/gettingstarted/q.png differ diff --git a/jetbrains-core/resources/icons/logos/AWS_Q.svg b/jetbrains-core/resources/icons/logos/AWS_Q.svg new file mode 100644 index 0000000000..7058d77f10 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/AWS_Q.svg @@ -0,0 +1,4 @@ + + + + diff --git a/jetbrains-core/resources/icons/logos/AWS_Q_dark.svg b/jetbrains-core/resources/icons/logos/AWS_Q_dark.svg new file mode 100644 index 0000000000..f04d331fd8 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/AWS_Q_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/jetbrains-core/resources/icons/logos/AWS_smile.svg b/jetbrains-core/resources/icons/logos/AWS_smile.svg new file mode 100644 index 0000000000..4726c4a3d0 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/AWS_smile.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/logos/AWS_smile_Large.svg b/jetbrains-core/resources/icons/logos/AWS_smile_Large.svg new file mode 100644 index 0000000000..82b623c5b4 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/AWS_smile_Large.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/logos/AWS_smile_Large_dark.svg b/jetbrains-core/resources/icons/logos/AWS_smile_Large_dark.svg new file mode 100644 index 0000000000..241a3c8e03 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/AWS_smile_Large_dark.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/logos/AWS_smile_dark.svg b/jetbrains-core/resources/icons/logos/AWS_smile_dark.svg new file mode 100644 index 0000000000..0186dda963 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/AWS_smile_dark.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_Gradient_Large.svg b/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_Gradient_Large.svg new file mode 100644 index 0000000000..ff0771eb1f --- /dev/null +++ b/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_Gradient_Large.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_Gradient_Medium.svg b/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_Gradient_Medium.svg new file mode 100644 index 0000000000..12aa875939 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_Gradient_Medium.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_Squid-Ink_Medium.svg b/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_Squid-Ink_Medium.svg new file mode 100644 index 0000000000..39e10674b2 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_Squid-Ink_Medium.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_White_Medium.svg b/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_White_Medium.svg new file mode 100644 index 0000000000..b33a27a021 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/Amazon-Q-Icon_White_Medium.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Medium.svg b/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Medium.svg new file mode 100644 index 0000000000..4b5f1571b9 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Medium.svg @@ -0,0 +1,7 @@ + + + Icon-Service/32/Amazon-CodeCatalyst_32 + + + + diff --git a/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Medium_dark.svg b/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Medium_dark.svg new file mode 100644 index 0000000000..e57b01d888 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Medium_dark.svg @@ -0,0 +1,7 @@ + + + Icon-Service/32/Amazon-CodeCatalyst_32_White + + + + diff --git a/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Small.svg b/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Small.svg new file mode 100644 index 0000000000..022c0e18d0 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Small.svg @@ -0,0 +1,7 @@ + + + Icon-Service/16/Amazon-CodeCatalyst_16 + + + + diff --git a/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Small_dark.svg b/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Small_dark.svg new file mode 100644 index 0000000000..505028b98f --- /dev/null +++ b/jetbrains-core/resources/icons/logos/Amazon_CodeCatalyst_Small_dark.svg @@ -0,0 +1,7 @@ + + + Icon-Service/16/Amazon-CodeCatalyst_16_White + + + + diff --git a/jetbrains-core/resources/icons/logos/CW_InlineSuggestions_dark.svg b/jetbrains-core/resources/icons/logos/CW_InlineSuggestions_dark.svg new file mode 100644 index 0000000000..3e5fa00548 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/CW_InlineSuggestions_dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/logos/CW_InlineSuggestions_light.svg b/jetbrains-core/resources/icons/logos/CW_InlineSuggestions_light.svg new file mode 100644 index 0000000000..c1b0447902 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/CW_InlineSuggestions_light.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/logos/CodeWhisperer_Large.svg b/jetbrains-core/resources/icons/logos/CodeWhisperer_Large.svg new file mode 100644 index 0000000000..b1e571ac15 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/CodeWhisperer_Large.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/logos/MynahIcon.svg b/jetbrains-core/resources/icons/logos/MynahIcon.svg new file mode 100644 index 0000000000..cbef4eff9d --- /dev/null +++ b/jetbrains-core/resources/icons/logos/MynahIcon.svg @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/logos/MynahIcon_dark.svg b/jetbrains-core/resources/icons/logos/MynahIcon_dark.svg new file mode 100644 index 0000000000..1e14d6f367 --- /dev/null +++ b/jetbrains-core/resources/icons/logos/MynahIcon_dark.svg @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/misc/csharp.svg b/jetbrains-core/resources/icons/misc/csharp.svg new file mode 100644 index 0000000000..b93b39244e --- /dev/null +++ b/jetbrains-core/resources/icons/misc/csharp.svg @@ -0,0 +1,10 @@ + + Csharp(Gray) + + + + + + + + diff --git a/jetbrains-core/resources/icons/misc/csharp_dark.svg b/jetbrains-core/resources/icons/misc/csharp_dark.svg new file mode 100644 index 0000000000..0249b5a6d4 --- /dev/null +++ b/jetbrains-core/resources/icons/misc/csharp_dark.svg @@ -0,0 +1,10 @@ + + Csharp(GrayDark) + + + + + + + + diff --git a/jetbrains-core/resources/icons/misc/java.svg b/jetbrains-core/resources/icons/misc/java.svg new file mode 100644 index 0000000000..00e1a4c96f --- /dev/null +++ b/jetbrains-core/resources/icons/misc/java.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/jetbrains-core/resources/icons/misc/javaScript.svg b/jetbrains-core/resources/icons/misc/javaScript.svg new file mode 100644 index 0000000000..d8bff9651d --- /dev/null +++ b/jetbrains-core/resources/icons/misc/javaScript.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/jetbrains-core/resources/icons/misc/javaScript_dark.svg b/jetbrains-core/resources/icons/misc/javaScript_dark.svg new file mode 100644 index 0000000000..043bb73c89 --- /dev/null +++ b/jetbrains-core/resources/icons/misc/javaScript_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/jetbrains-core/resources/icons/misc/java_dark.svg b/jetbrains-core/resources/icons/misc/java_dark.svg new file mode 100644 index 0000000000..155295f592 --- /dev/null +++ b/jetbrains-core/resources/icons/misc/java_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/jetbrains-core/resources/icons/misc/learn.svg b/jetbrains-core/resources/icons/misc/learn.svg new file mode 100644 index 0000000000..1dac00e180 --- /dev/null +++ b/jetbrains-core/resources/icons/misc/learn.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/jetbrains-core/resources/icons/misc/learn_dark.svg b/jetbrains-core/resources/icons/misc/learn_dark.svg new file mode 100644 index 0000000000..76fd1c0298 --- /dev/null +++ b/jetbrains-core/resources/icons/misc/learn_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/jetbrains-core/resources/icons/misc/python.svg b/jetbrains-core/resources/icons/misc/python.svg new file mode 100644 index 0000000000..b81b75e9d1 --- /dev/null +++ b/jetbrains-core/resources/icons/misc/python.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/jetbrains-core/resources/icons/misc/typeScript.svg b/jetbrains-core/resources/icons/misc/typeScript.svg new file mode 100644 index 0000000000..957790f3ec --- /dev/null +++ b/jetbrains-core/resources/icons/misc/typeScript.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/jetbrains-core/resources/icons/misc/typeScript_dark.svg b/jetbrains-core/resources/icons/misc/typeScript_dark.svg new file mode 100644 index 0000000000..bcac440d46 --- /dev/null +++ b/jetbrains-core/resources/icons/misc/typeScript_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/jetbrains-core/resources/icons/resources/AppRunnerService.svg b/jetbrains-core/resources/icons/resources/AppRunnerService.svg new file mode 100644 index 0000000000..8c7b070aeb --- /dev/null +++ b/jetbrains-core/resources/icons/resources/AppRunnerService.svg @@ -0,0 +1,9 @@ + + + AWS-AppRunner_13 + + + + + + \ No newline at end of file diff --git a/jetbrains-core/resources/icons/resources/AppRunnerService_dark.svg b/jetbrains-core/resources/icons/resources/AppRunnerService_dark.svg new file mode 100644 index 0000000000..26990e1f28 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/AppRunnerService_dark.svg @@ -0,0 +1,9 @@ + + + AWS-AppRunner_13 + + + + + + \ No newline at end of file diff --git a/jetbrains-core/resources/icons/resources/CodewhispererCustom.svg b/jetbrains-core/resources/icons/resources/CodewhispererCustom.svg new file mode 100644 index 0000000000..bcb11c35c4 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/CodewhispererCustom.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/jetbrains-core/resources/icons/resources/ECRRepository.svg b/jetbrains-core/resources/icons/resources/ECRRepository.svg new file mode 100755 index 0000000000..6271afb19e --- /dev/null +++ b/jetbrains-core/resources/icons/resources/ECRRepository.svg @@ -0,0 +1,11 @@ + + + + Icon-Service/16/Amazon-Elastic-Container-Registry + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/jetbrains-core/resources/icons/resources/ECRRepository_dark.svg b/jetbrains-core/resources/icons/resources/ECRRepository_dark.svg new file mode 100755 index 0000000000..f2f1bfb152 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/ECRRepository_dark.svg @@ -0,0 +1,11 @@ + + + + Icon-Service/16/Amazon-Elastic-Container-Registry + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/jetbrains-core/resources/icons/resources/codetransform/checkmark.svg b/jetbrains-core/resources/icons/resources/codetransform/checkmark.svg new file mode 100644 index 0000000000..4ef6c7c7ce --- /dev/null +++ b/jetbrains-core/resources/icons/resources/codetransform/checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/jetbrains-core/resources/icons/resources/codetransform/greenCheckmark.svg b/jetbrains-core/resources/icons/resources/codetransform/greenCheckmark.svg new file mode 100644 index 0000000000..ae9c8b04f8 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/codetransform/greenCheckmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/jetbrains-core/resources/icons/resources/codetransform/transform-timeline-step-done-light.svg b/jetbrains-core/resources/icons/resources/codetransform/transform-timeline-step-done-light.svg new file mode 100644 index 0000000000..5e70a9c124 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/codetransform/transform-timeline-step-done-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/jetbrains-core/resources/icons/resources/codetransform/transform-timeline-step-done.svg b/jetbrains-core/resources/icons/resources/codetransform/transform-timeline-step-done.svg new file mode 100644 index 0000000000..f2c0fcec9f --- /dev/null +++ b/jetbrains-core/resources/icons/resources/codetransform/transform-timeline-step-done.svg @@ -0,0 +1,4 @@ + + + + diff --git a/jetbrains-core/resources/icons/resources/codewhisperer/severity-critical.svg b/jetbrains-core/resources/icons/resources/codewhisperer/severity-critical.svg new file mode 100644 index 0000000000..cb70a16dd2 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/codewhisperer/severity-critical.svg @@ -0,0 +1 @@ +CriticalCritical \ No newline at end of file diff --git a/jetbrains-core/resources/icons/resources/codewhisperer/severity-high.svg b/jetbrains-core/resources/icons/resources/codewhisperer/severity-high.svg new file mode 100644 index 0000000000..9bcd00e66c --- /dev/null +++ b/jetbrains-core/resources/icons/resources/codewhisperer/severity-high.svg @@ -0,0 +1 @@ +HighHigh diff --git a/jetbrains-core/resources/icons/resources/codewhisperer/severity-info.svg b/jetbrains-core/resources/icons/resources/codewhisperer/severity-info.svg new file mode 100644 index 0000000000..2b43ff0d06 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/codewhisperer/severity-info.svg @@ -0,0 +1 @@ +InfoInfo diff --git a/jetbrains-core/resources/icons/resources/codewhisperer/severity-low.svg b/jetbrains-core/resources/icons/resources/codewhisperer/severity-low.svg new file mode 100644 index 0000000000..d719553266 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/codewhisperer/severity-low.svg @@ -0,0 +1 @@ +LowLow diff --git a/jetbrains-core/resources/icons/resources/codewhisperer/severity-medium.svg b/jetbrains-core/resources/icons/resources/codewhisperer/severity-medium.svg new file mode 100644 index 0000000000..7377b683ed --- /dev/null +++ b/jetbrains-core/resources/icons/resources/codewhisperer/severity-medium.svg @@ -0,0 +1 @@ +MediumMedium diff --git a/jetbrains-core/resources/icons/resources/dynamodb/DynamoDbTable.svg b/jetbrains-core/resources/icons/resources/dynamodb/DynamoDbTable.svg new file mode 100644 index 0000000000..dc56c4eb17 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/dynamodb/DynamoDbTable.svg @@ -0,0 +1,7 @@ + + + Icon-Resource/Database/Res_Amazon-DynamoDB_Table_48_Dark + + + + diff --git a/jetbrains-core/resources/icons/resources/dynamodb/DynamoDbTable_dark.svg b/jetbrains-core/resources/icons/resources/dynamodb/DynamoDbTable_dark.svg new file mode 100644 index 0000000000..761baab660 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/dynamodb/DynamoDbTable_dark.svg @@ -0,0 +1,7 @@ + + + Icon-Resource/Database/Res_Amazon-DynamoDB_Table_48_Dark + + + + diff --git a/jetbrains-core/resources/icons/resources/sqs/SqsQueue.svg b/jetbrains-core/resources/icons/resources/sqs/SqsQueue.svg new file mode 100644 index 0000000000..01a0a80202 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/sqs/SqsQueue.svg @@ -0,0 +1,13 @@ + + + Amazon simple queue service (SQS) - dark + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/resources/sqs/SqsQueue_dark.svg b/jetbrains-core/resources/icons/resources/sqs/SqsQueue_dark.svg new file mode 100644 index 0000000000..8c89760c8f --- /dev/null +++ b/jetbrains-core/resources/icons/resources/sqs/SqsQueue_dark.svg @@ -0,0 +1,13 @@ + + + Amazon simple queue service (SQS) - light + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/resources/sqs/SqsToolWindow.svg b/jetbrains-core/resources/icons/resources/sqs/SqsToolWindow.svg new file mode 100644 index 0000000000..9e5df0cc26 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/sqs/SqsToolWindow.svg @@ -0,0 +1,13 @@ + + + Amazon simple queue service (SQS) - dark + + + + + + + + + + diff --git a/jetbrains-core/resources/icons/resources/sqs/SqsToolWindow_dark.svg b/jetbrains-core/resources/icons/resources/sqs/SqsToolWindow_dark.svg new file mode 100644 index 0000000000..ecb6efcf70 --- /dev/null +++ b/jetbrains-core/resources/icons/resources/sqs/SqsToolWindow_dark.svg @@ -0,0 +1,13 @@ + + + Amazon simple queue service (SQS) - light + + + + + + + + + + diff --git a/jetbrains-core/resources/software/aws/toolkits/jetbrains/services/caws/parameterDescriptions.json b/jetbrains-core/resources/software/aws/toolkits/jetbrains/services/caws/parameterDescriptions.json new file mode 100644 index 0000000000..b664596f56 --- /dev/null +++ b/jetbrains-core/resources/software/aws/toolkits/jetbrains/services/caws/parameterDescriptions.json @@ -0,0 +1,28 @@ +{ + "environment": { + "instanceType": { + "dev.standard1.small": { + "vcpus" : 2, + "ram": { "value": 4, "unit": "gigabyte" }, + "arch": "x86_64" + }, + "dev.standard1.medium": { + "vcpus" : 4, + "ram": { "value": 8, "unit": "gigabyte" }, + "arch": "x86_64" + }, + "dev.standard1.large": { + "vcpus" : 8, + "ram": { "value": 16, "unit": "gigabyte" }, + "arch": "x86_64" + }, + "dev.standard1.xlarge": { + "vcpus" : 16, + "ram": { "value": 32, "unit": "gigabyte" }, + "arch": "x86_64" + } + }, + "persistentStorageSize": [ 16, 32, 64 ], + "defaultDevfileLocation": "https://registry.devfile.io/devfiles/nodejs" + } +} diff --git a/jetbrains-core/resources/telemetryOverride.json b/jetbrains-core/resources/telemetryOverride.json new file mode 100644 index 0000000000..d1f1c30edc --- /dev/null +++ b/jetbrains-core/resources/telemetryOverride.json @@ -0,0 +1,512 @@ +{ + "types": [ + { + "name": "codecatalyst_createDevEnvironmentRepoType", + "type": "string", + "description": "Type of Git repository provided to the Amazon CodeCatalyst dev environment create wizard", + "allowedValues": [ + "linked", + "unlinked", + "none" + ] + }, + { + "name": "codecatalyst_updateDevEnvironmentLocationType", + "type": "string", + "description": "Locality of the Amazon CodeCatalyst update dev environment request (i.e., from the thin client or the local IDE instance)", + "allowedValues": [ + "remote", + "local" + ] + }, + { + "name": "userId", + "type": "string", + "description": "Opaque AWS ID identifier" + }, + { + "name": "codecatalyst_devEnvironmentWorkflowError", + "type": "string", + "description": "Workflow error name" + }, + { + "name": "codecatalyst_devEnvironmentWorkflowStep", + "type": "string", + "description": "Workflow step name" + }, + { + "name": "cwsprChatTriggerInteraction", + "type": "string", + "allowedValues": ["hotkeys", "click", "contextMenu"], + "description": "Identifies the specific interaction that opens the chat panel" + }, + { + "name": "cwsprChatConversationId", + "type": "string", + "description": "Unique identifier for each conversation" + }, + { + "name": "cwsprChatUserIntent", + "type": "string", + "allowedValues": [ + "suggestAlternateImplementation", + "applyCommonBestPractices", + "improveCode", + "showExample", + "citeSources", + "explainLineByLine", + "explainCodeSelection" + ], + "description": "Explict user intent associated with a chat message" + }, + { + "name": "cwsprChatHasCodeSnippet", + "type": "boolean", + "description": "true if user has selected code snippet, false otherwise." + }, + { + "name": "cwsprChatProgrammingLanguage", + "type": "string", + "description": "Programming language associated with the message" + }, + { + "name": "cwsprChatConversationType", + "type": "string", + "allowedValues": [ + "Chat", + "Assign", + "Transform" + ], + "description": "Identifies the type of conversation" + }, + { + "name": "cwsprChatMessageId", + "type": "string", + "description": "Unique identifier for each message in an conversation" + }, + { + "name": "cwsprChatActiveEditorTotalCharacters", + "type": "int", + "description": "Total number of characters in the active editor" + }, + { + "name": "cwsprChatActiveEditorImportCount", + "type": "int", + "description": "Number of import statements in the active editor" + }, + { + "name": "cwsprChatResponseCodeSnippetCount", + "type": "int", + "description": "Number of code snippets in response" + }, + { + "name": "cwsprChatResponseCode", + "type": "int", + "description": "HTTP response code for message API invocation" + }, + { + "name": "cwsprChatSourceLinkCount", + "type": "int", + "description": "Number of links in response" + }, + { + "name": "cwsprChatReferencesCount", + "type": "int", + "description": "Number of references in response" + }, + { + "name": "cwsprChatFollowUpCount", + "type": "int", + "description": "Number of follow ups in response" + }, + { + "name": "cwsprChatTimeToFirstChunk", + "type": "int", + "description": "Time taken in ms to get back the first chunk" + }, + { + "name": "cwsprChatTimeBetweenChunks", + "type": "string", + "description": "Time (list of int) taken in ms between chunks" + }, + { + "name": "cwsprChatFullResponseLatency", + "type": "int", + "description": "Time taken to get the full response in ms" + }, + { + "name": "cwsprChatResponseLength", + "type": "int", + "description": "Number of characters in response" + }, + { + "name": "cwsprChatRequestLength", + "type": "int", + "description": "Number of characters in request" + }, + { + "name": "cwsprChatInteractionType", + "allowedValues": [ + "insertAtCursor", + "copySnippet", + "copy", + "clickLink", + "clickFollowUp", + "hoverReference", + "upvote", + "downvote", + "clickBodyLink" + ], + "type": "string", + "description": "Indicates the specific interaction type with a message in a conversation" + }, + { + "name": "cwsprChatInteractionTarget", + "type": "string", + "description": "Identifies the entity within the message that user interacts with." + }, + { + "name": "cwsprChatAcceptedCharactersLength", + "type": "int", + "description": "Count of code characters copied to the editor" + }, + { + "name": "cwsprChatHasReference", + "type": "boolean", + "description": "True if the code snippet that user interacts with has a reference." + }, + { + "name": "cwsprChatModificationPercentage", + "type": "double", + "description": "Percentage of characters edited by user after copying/inserting code from a message" + }, + { + "name": "cwsprChatCommandType", + "type": "string", + "allowedValues": [ + "clear", + "help", + "transform", + "auth" + ], + "description": "Type of chat command (/command) executed" + }, + { + "name": "cwsprChatCommandName", + "type": "string", + "description": "Type of chat command name executed" + }, + { + "name": "featureId", + "type": "string", + "description": "The id of the feature the user is interacting in.", + "allowedValues": [ + "awsExplorer", + "codewhisperer", + "codecatalyst", + "q", + "codewhispererQ" + ] + } + ], + "metrics": [ + { + "name": "aws_openLocalTerminal", + "description": "Open local terminal with aws connection injected", + "metadata": [ + { + "type": "result" + } + ] + }, + { + "name": "codecatalyst_createDevEnvironment", + "description": "Create an Amazon CodeCatalyst Dev Environment", + "metadata": [ + { "type": "userId" }, + { "type": "result" }, + { + "type": "codecatalyst_createDevEnvironmentRepoType", + "required": false + } + ] + }, + { + "name": "codecatalyst_updateDevEnvironmentSettings", + "description": "Update properties of a Amazon CodeCatalyst Dev Environment", + "metadata": [ + { "type": "userId" }, + { "type": "result" }, + { "type": "codecatalyst_updateDevEnvironmentLocationType" } + ] + }, + { + "name": "codecatalyst_updateDevfile", + "description": "Trigger a devfile update on a Amazon CodeCatalyst dev environment", + "metadata": [ + { "type": "userId" }, + { "type": "result" } + ] + }, + { + "name": "codecatalyst_localClone", + "description": "Clone a Amazon CodeCatalyst code repository locally", + "metadata": [ + { "type": "userId" }, + { "type": "result" } + ] + }, + { + "name": "codecatalyst_connect", + "description": "Connect to a Amazon CodeCatalyst dev environment", + "metadata": [ + { "type": "userId" }, + { "type": "result" }, + { "type": "reason", "required": false } + ] + }, + { + "name": "codecatalyst_devEnvironmentWorkflowStatistic", + "description": "Workflow statistic for connecting to a dev environment", + "passive": true, + "metadata": [ + { "type": "userId" }, + { "type": "result" }, + { "type": "duration"}, + { "type": "codecatalyst_devEnvironmentWorkflowStep"}, + { "type": "codecatalyst_devEnvironmentWorkflowError", "required": false } + ] + }, + { + "name": "amazonq_openChat", + "description": "When user opens CWSPR chat panel" + }, + { + "name": "amazonq_enterFocusChat", + "description": "When chat panel comes into focus" + }, + { + "name": "amazonq_exitFocusChat", + "description": "When chat panel goes out of focus" + }, + { + "name": "amazonq_closeChat", + "description": "When chat panel is closed" + }, + { + "name": "amazonq_startConversation", + "description": "When user starts a new conversation", + "metadata": [ + { + "type": "cwsprChatConversationId" + }, + { + "type": "cwsprChatTriggerInteraction" + }, + { + "type": "cwsprChatUserIntent", + "required": false + }, + { + "type": "cwsprChatHasCodeSnippet", + "required": false + }, + { + "type": "cwsprChatProgrammingLanguage", + "required": false + }, + { + "type": "cwsprChatConversationType" + } + ] + }, + { + "name": "amazonq_addMessage", + "description": "When a message is added to the conversation", + "metadata": [ + { + "type": "cwsprChatConversationId" + }, + { + "type": "cwsprChatMessageId" + }, + { + "type": "cwsprChatTriggerInteraction" + }, + { + "type": "cwsprChatUserIntent", + "required": false + }, + { + "type": "cwsprChatHasCodeSnippet", + "required": false + }, + { + "type": "cwsprChatProgrammingLanguage", + "required": false + }, + { + "type": "cwsprChatActiveEditorTotalCharacters", + "required": false + }, + { + "type": "cwsprChatActiveEditorImportCount", + "required": false + }, + { + "type": "cwsprChatResponseCodeSnippetCount", + "required": false + }, + { + "type": "cwsprChatResponseCode" + }, + { + "type": "cwsprChatSourceLinkCount", + "required": false + }, + { + "type": "cwsprChatReferencesCount", + "required": false + }, + { + "type": "cwsprChatFollowUpCount", + "required": false + }, + { + "type": "cwsprChatTimeToFirstChunk" + }, + { + "type": "cwsprChatTimeBetweenChunks" + }, + { + "type": "cwsprChatFullResponseLatency" + }, + { + "type": "cwsprChatRequestLength" + }, + { + "type": "cwsprChatResponseLength", + "required": false + }, + { + "type": "cwsprChatConversationType" + } + ] + }, + { + "name": "amazonq_messageResponseError", + "description": "When an error has occured in response to a prompt", + "metadata": [ + { + "type": "cwsprChatConversationId", + "required": false + }, + { + "type": "cwsprChatTriggerInteraction" + }, + { + "type": "cwsprChatUserIntent", + "required": false + }, + { + "type": "cwsprChatHasCodeSnippet", + "required": false + }, + { + "type": "cwsprChatProgrammingLanguage", + "required": false + }, + { + "type": "cwsprChatActiveEditorTotalCharacters", + "required": false + }, + { + "type": "cwsprChatActiveEditorImportCount", + "required": false + }, + { + "type": "cwsprChatResponseCode" + }, + { + "type": "cwsprChatRequestLength" + }, + { + "type": "cwsprChatConversationType" + } + ] + }, + { + "name": "amazonq_interactWithMessage", + "description": "When a user interacts with a message in the conversation", + "metadata": [ + { + "type": "cwsprChatConversationId" + }, + { + "type": "cwsprChatMessageId" + }, + { + "type": "cwsprChatInteractionType" + }, + { + "type": "cwsprChatInteractionTarget", + "required": false + }, + { + "type": "cwsprChatAcceptedCharactersLength", + "required": false + }, + { + "type": "cwsprChatHasReference", + "required": false + } + ] + }, + { + "name": "amazonq_modifyCode", + "description": "Percentage of code modified by the user after copying/inserting code from a message", + "metadata": [ + { + "type": "cwsprChatConversationId" + }, + { + "type": "cwsprChatMessageId" + }, + { + "type": "cwsprChatModificationPercentage" + } + ] + }, + { + "name": "amazonq_enterFocusConversation", + "description": "When a conversation comes into focus", + "metadata": [ + { + "type": "cwsprChatConversationId" + } + ] + }, + { + "name": "amazonq_exitFocusConversation", + "description": "When a conversation goes out of focus", + "metadata": [ + { + "type": "cwsprChatConversationId" + } + ] + }, + { + "name": "amazonq_runCommand", + "description": "When a chat command is executed", + "metadata": [ + { + "type": "cwsprChatCommandType" + }, + { + "type": "cwsprChatCommandName", + "required": false + } + ] + } + ] +} diff --git a/jetbrains-core/src-231-232/org/jetbrains/plugins/terminal/TerminalIcons.kt b/jetbrains-core/src-231-232/org/jetbrains/plugins/terminal/TerminalIcons.kt new file mode 100644 index 0000000000..d9a3115a9f --- /dev/null +++ b/jetbrains-core/src-231-232/org/jetbrains/plugins/terminal/TerminalIcons.kt @@ -0,0 +1,5 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package org.jetbrains.plugins.terminal + +typealias TerminalIcons = icons.TerminalIcons diff --git a/jetbrains-core/src/icons/AwsIcons.kt b/jetbrains-core/src/icons/AwsIcons.kt index 4b8220c4e4..816926144a 100644 --- a/jetbrains-core/src/icons/AwsIcons.kt +++ b/jetbrains-core/src/icons/AwsIcons.kt @@ -13,45 +13,134 @@ import javax.swing.Icon */ object AwsIcons { object Logos { - @JvmField val AWS = IconLoader.getIcon("/icons/logos/AWS.svg") // 13x13 - @JvmField val CLOUD_FORMATION_TOOL = IconLoader.getIcon("/icons/logos/CloudFormationTool.svg") // 13x13 - @JvmField val EVENT_BRIDGE = IconLoader.getIcon("/icons/logos/EventBridge.svg") // 13x13 + @JvmField val AWS = load("/icons/logos/AWS.svg") // 13x13 + + @JvmField val AWS_SMILE_SMALL = load("/icons/logos/AWS_smile.svg") // 16x16 + + @JvmField val AWS_SMILE_LARGE = load("/icons/logos/AWS_smile_Large.svg") // 64x64 + + @JvmField val CLOUD_FORMATION_TOOL = load("/icons/logos/CloudFormationTool.svg") // 13x13 + + @JvmField val CODE_CATALYST_MEDIUM = load("/icons/logos/Amazon_CodeCatalyst_Medium.svg") // 32x32 + + @JvmField val CODE_CATALYST_SMALL = load("/icons/logos/Amazon_CodeCatalyst_Small.svg") // 16x16 + + @JvmField val EVENT_BRIDGE = load("/icons/logos/EventBridge.svg") // 13x13 + + @JvmField val CODEWHISPERER_LARGE = load("/icons/logos/CodeWhisperer_Large.svg") // 54x54 + + @JvmField val AWS_Q = load("/icons/logos/AWS_Q.svg") // 13x13 + + @JvmField val AWS_Q_GRADIENT = load("/icons/logos/Amazon-Q-Icon_Gradient_Large.svg") // 54x54 } object Misc { - @JvmField val SMILE = IconLoader.getIcon("/icons/misc/smile.svg") // 16x16 - @JvmField val SMILE_GREY = IconLoader.getIcon("/icons/misc/smile_grey.svg") // 16x16 - @JvmField val FROWN = IconLoader.getIcon("/icons/misc/frown.svg") // 16x16 + @JvmField val SMILE = load("/icons/misc/smile.svg") // 16x16 + + @JvmField val SMILE_GREY = load("/icons/misc/smile_grey.svg") // 16x16 + + @JvmField val FROWN = load("/icons/misc/frown.svg") // 16x16 + + @JvmField val LEARN = load("/icons/misc/learn.svg") // 16x16 + + @JvmField val JAVA = load("/icons/misc/java.svg") // 16x16 + + @JvmField val PYTHON = load("/icons/misc/python.svg") // 16x16 + + @JvmField val JAVASCRIPT = load("/icons/misc/javaScript.svg") // 16x16 + + @JvmField val TYPESCRIPT = load("/icons/misc/typeScript.svg") // 16x16 + + @JvmField val CSHARP = load("/icons/misc/csharp.svg") // 16x16 } object Resources { - @JvmField val CLOUDFORMATION_STACK = IconLoader.getIcon("/icons/resources/CloudFormationStack.svg") // 16x16 + @JvmField val APPRUNNER_SERVICE = load("/icons/resources/AppRunnerService.svg") // 16x16 + + @JvmField val CLOUDFORMATION_STACK = load("/icons/resources/CloudFormationStack.svg") // 16x16 + object CloudWatch { - @JvmField val LOGS = IconLoader.getIcon("/icons/resources/cloudwatchlogs/CloudWatchLogs.svg") // 16x16 - @JvmField val LOGS_TOOL_WINDOW = IconLoader.getIcon("/icons/resources/cloudwatchlogs/CloudWatchLogsToolWindow.svg") // 13x13 - @JvmField val LOG_GROUP = IconLoader.getIcon("/icons/resources/cloudwatchlogs/CloudWatchLogsGroup.svg") // 16x16 + @JvmField val LOGS = load("/icons/resources/cloudwatchlogs/CloudWatchLogs.svg") // 16x16 + + @JvmField val LOGS_TOOL_WINDOW = load("/icons/resources/cloudwatchlogs/CloudWatchLogsToolWindow.svg") // 13x13 + + @JvmField val LOG_GROUP = load("/icons/resources/cloudwatchlogs/CloudWatchLogsGroup.svg") // 16x16 } - @JvmField val LAMBDA_FUNCTION = IconLoader.getIcon("/icons/resources/LambdaFunction.svg") // 16x16 - @JvmField val SCHEMA_REGISTRY = IconLoader.getIcon("/icons/resources/SchemaRegistry.svg") // 16x16 - @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 + + @JvmField val ECR_REPOSITORY = load("/icons/resources/ECRRepository.svg") // 16x16 + + @JvmField val LAMBDA_FUNCTION = load("/icons/resources/LambdaFunction.svg") // 16x16 + + @JvmField val SCHEMA_REGISTRY = load("/icons/resources/SchemaRegistry.svg") // 16x16 + + @JvmField val SCHEMA = load("/icons/resources/Schema.svg") // 16x16 + + @JvmField val SERVERLESS_APP = load("/icons/resources/ServerlessApp.svg") // 16x16 + + @JvmField val S3_BUCKET = load("/icons/resources/S3Bucket.svg") // 16x16 + + @JvmField val REDSHIFT = load("/icons/resources/Redshift.svg") // 16x16 + + object DynamoDb { + @JvmField val TABLE = load("/icons/resources/dynamodb/DynamoDbTable.svg") + } + 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") + @JvmField val ECS_CLUSTER = load("/icons/resources/ecs/EcsCluster.svg") + + @JvmField val ECS_SERVICE = load("/icons/resources/ecs/EcsService.svg") + + @JvmField val ECS_TASK_DEFINITION = load("/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 + @JvmField val MYSQL = load("/icons/resources/rds/Mysql.svg") // 16x16 + + @JvmField val POSTGRES = load("/icons/resources/rds/Postgres.svg") // 16x16 + } + + object Sqs { + @JvmField val SQS_QUEUE = load("/icons/resources/sqs/SqsQueue.svg") // 16x16 + + @JvmField val SQS_TOOL_WINDOW = load("/icons/resources/sqs/SqsToolWindow.svg") // 13x13 + } + + object CodeWhisperer { + @JvmField val CUSTOM = load("icons/resources/CodewhispererCustom.svg") // 16 * 16 + + @JvmField val SEVERITY_INFO = load("/icons/resources/codewhisperer/severity-info.svg") + + @JvmField val SEVERITY_LOW = load("/icons/resources/codewhisperer/severity-low.svg") + + @JvmField val SEVERITY_MEDIUM = load("/icons/resources/codewhisperer/severity-medium.svg") + + @JvmField val SEVERITY_HIGH = load("/icons/resources/codewhisperer/severity-high.svg") + + @JvmField val SEVERITY_CRITICAL = load("/icons/resources/codewhisperer/severity-critical.svg") } } object Actions { @JvmField val LAMBDA_FUNCTION_NEW: Icon = LayeredIcon.create(Resources.LAMBDA_FUNCTION, AllIcons.Actions.New) + @JvmField val SCHEMA_VIEW: Icon = AllIcons.Actions.Preview + @JvmField val SCHEMA_CODE_GEN: Icon = AllIcons.Actions.Download + @JvmField val SCHEMA_SEARCH: Icon = AllIcons.Actions.Search } + + object CodeTransform { + @JvmField val TIMELINE_STEP_DARK = load("/icons/resources/codetransform/transform-timeline-step-done.svg") // 16 * 16 + + @JvmField val TIMELINE_STEP_LIGHT = load("/icons/resources/codetransform/transform-timeline-step-done-light.svg") // 16 * 16 + + @JvmField val CHECKMARK_GREEN = load("/icons/resources/codetransform/greenCheckmark.svg") + + @JvmField val CHECKMARK_GRAY = load("/icons/resources/codetransform/checkmark.svg") + + @JvmField val TIMELINE_STEP = load("/icons/resources/codetransform/transform-timeline-step-done.svg") // 16 * 16 + } + + private fun load(path: String): Icon = IconLoader.getIcon(path, AwsIcons::class.java) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/AwsToolkit.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/AwsToolkit.kt index 16b65bf681..ca6e16dae0 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/AwsToolkit.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/AwsToolkit.kt @@ -4,13 +4,30 @@ package software.aws.toolkits.jetbrains import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.extensions.PluginDescriptor import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.util.registry.Registry +import java.nio.file.Paths object AwsToolkit { - private const val PLUGIN_ID = "aws.toolkit" + const val PLUGIN_ID = "aws.toolkit" + const val GITHUB_URL = "https://github.com/aws/aws-toolkit-jetbrains" + const val AWS_DOCS_URL = "https://docs.aws.amazon.com/console/toolkit-for-jetbrains" val PLUGIN_VERSION: String by lazy { - // PluginManagerCore.getPlugin Requires MIN 193.2252. However we cannot set our IDE min to that because not all JB IDEs use the same build numbers - PluginManagerCore.getPlugin(PluginId.getId(PLUGIN_ID))?.version ?: "Unknown" + DESCRIPTOR?.version ?: "Unknown" } + + val DESCRIPTOR: PluginDescriptor? by lazy { + PluginManagerCore.getPlugin(PluginId.getId(PLUGIN_ID)) + } + + fun pluginPath() = if (ApplicationManager.getApplication().isUnitTestMode) { + Paths.get(System.getProperty("plugin.path")) + } else { + DESCRIPTOR?.pluginPath ?: throw RuntimeException("Toolkit root not available") + } + + fun isDeveloperMode() = Registry.`is`("aws.toolkit.developerMode", false) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt new file mode 100644 index 0000000000..11a9dde795 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt @@ -0,0 +1,13 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains + +object ToolkitPlaces { + const val DEVTOOLS_TOOL_WINDOW = "DevToolsToolWindow" + const val EXPLORER_TOOL_WINDOW = "ExplorerToolWindow" + const val EDITOR_PSI_REFERENCE = "Editor" + const val DEV_TOOL_WINDOW = "DeveloperToolsWindow" + const val CWQ_TOOL_WINDOW = "CodeWhispererQWindow" + const val ADD_CONNECTION_DIALOG = "ToolkitAddConnectionDialog" +} 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 155b542ab0..ecfcf862ee 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsClientManager.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsClientManager.kt @@ -7,85 +7,109 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ApplicationNamesInfo import com.intellij.openapi.application.ex.ApplicationInfoEx -import com.intellij.openapi.components.ServiceManager -import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.components.service +import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider +import software.amazon.awssdk.awscore.client.builder.AwsClientBuilder import software.amazon.awssdk.core.SdkClient +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration import software.amazon.awssdk.http.SdkHttpClient +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.ToolkitClientCustomizer 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.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.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.settings.AwsSettings -open class AwsClientManager(project: Project) : ToolkitClientManager(), Disposable { - - private val accountSettingsManager = AwsConnectionManager.getInstance(project) - private val regionProvider = AwsRegionProvider.getInstance() - +open class AwsClientManager : ToolkitClientManager(), Disposable { init { - Disposer.register(project, Disposable { this.dispose() }) - - val busConnection = ApplicationManager.getApplication().messageBus.connect(project) - busConnection.subscribe(CredentialManager.CREDENTIALS_CHANGED, object : ToolkitCredentialsChangeListener { - override fun providerRemoved(identifier: CredentialIdentifier) { - invalidateSdks(identifier.id) + val busConnection = ApplicationManager.getApplication().messageBus.connect(this) + busConnection.subscribe( + CredentialManager.CREDENTIALS_CHANGED, + object : ToolkitCredentialsChangeListener { + override fun providerRemoved(identifier: CredentialIdentifier) { + invalidateSdks(identifier.id) + } + + override fun providerRemoved(providerId: String) { + invalidateSdks(providerId) + } } - }) + ) + + busConnection.subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun onChange(providerId: String) { + // otherwise we potentially cache the provider with the wrong token + invalidateSdks(providerId) + } + + override fun invalidate(providerId: String) { + invalidateSdks(providerId) + } + } + ) } + override val userAgent = AwsClientManager.userAgent + override fun dispose() { shutdown() } - override val sdkHttpClient: SdkHttpClient - get() = AwsSdkClient.getInstance().sdkHttpClient - - override val userAgent = AwsClientManager.userAgent + override fun sdkHttpClient(): SdkHttpClient = AwsSdkClient.getInstance().sharedSdkClient() - override fun getCredentialsProvider(): ToolkitCredentialsProvider { - try { - return accountSettingsManager.activeCredentialProvider - } catch (e: CredentialProviderNotFoundException) { - // TODO: Notify user + override fun getRegionProvider(): ToolkitRegionProvider = AwsRegionProvider.getInstance() - // Throw canceled exception to stop any task relying on this call - throw ProcessCanceledException(e) - } + override fun globalClientCustomizer( + credentialProvider: AwsCredentialsProvider?, + tokenProvider: SdkTokenProvider?, + regionId: String, + builder: AwsClientBuilder<*, *>, + clientOverrideConfiguration: ClientOverrideConfiguration.Builder + ) { + CUSTOMIZER_EP.extensionList.forEach { it.customize(credentialProvider, tokenProvider, regionId, builder, clientOverrideConfiguration) } } - override fun getRegion(): AwsRegion = accountSettingsManager.activeRegion - - override fun getRegionProvider(): ToolkitRegionProvider = regionProvider - companion object { @JvmStatic - fun getInstance(project: Project): ToolkitClientManager = ServiceManager.getService(project, ToolkitClientManager::class.java) + fun getInstance(): ToolkitClientManager = service() val userAgent: String by lazy { val platformName = tryOrNull { ApplicationNamesInfo.getInstance().fullProductNameWithEdition.replace(' ', '-') } val platformVersion = tryOrNull { ApplicationInfoEx.getInstanceEx().fullVersion.replace(' ', '-') } - "AWS-Toolkit-For-JetBrains/${AwsToolkit.PLUGIN_VERSION} $platformName/$platformVersion" + "AWS-Toolkit-For-JetBrains/${AwsToolkit.PLUGIN_VERSION} $platformName/$platformVersion ClientId/${AwsSettings.getInstance().clientId}" } + + internal val CUSTOMIZER_EP = ExtensionPointName("aws.toolkit.sdk.clientCustomizer") } } -inline fun Project.awsClient( - credentialsProviderOverride: ToolkitCredentialsProvider? = null, - regionOverride: AwsRegion? = null -): T = AwsClientManager - .getInstance(this) - .getClient(credentialsProviderOverride = credentialsProviderOverride, regionOverride = regionOverride) +inline fun Project.awsClient(): T { + val accountSettingsManager = AwsConnectionManager.getInstance(this) -inline fun Project.awsClient(connectionSettings: ConnectionSettings): T = AwsClientManager - .getInstance(this) - .getClient(connectionSettings.credentials, connectionSettings.region) + return AwsClientManager + .getInstance() + .getClient(accountSettingsManager.activeCredentialProvider, accountSettingsManager.activeRegion) +} + +inline fun ConnectionSettings.awsClient(): T = AwsClientManager.getInstance().getClient(credentials, region) + +inline fun TokenConnectionSettings.awsClient(): T = AwsClientManager.getInstance().getClient(this) + +inline fun ClientConnectionSettings<*>.awsClient(): T = when (this) { + is ConnectionSettings -> awsClient() + is TokenConnectionSettings -> awsClient() +} 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 83d461cf86..be67991448 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsResourceCache.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsResourceCache.kt @@ -6,20 +6,33 @@ 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.components.service import com.intellij.openapi.project.Project import com.intellij.util.Alarm import com.intellij.util.AlarmFactory +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.jetbrains.annotations.VisibleForTesting import software.amazon.awssdk.core.SdkClient +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.TokenConnectionSettings import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider import software.aws.toolkits.core.credentials.ToolkitCredentialsChangeListener import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider +import software.aws.toolkits.core.credentials.toEnvironmentVariables 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.coroutines.disposableCoroutineScope import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettingsOrThrow +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance import software.aws.toolkits.jetbrains.core.executables.ExecutableManager import software.aws.toolkits.jetbrains.core.executables.ExecutableType @@ -34,44 +47,53 @@ import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import kotlin.reflect.KClass +// Getting resources can take a long time on a slow connection or if there are a lot of resources. This call should +// always be done in an async context so it should be OK to take multiple seconds. +private val DEFAULT_TIMEOUT = Duration.ofSeconds(30) + /** * Intended to prevent repeated unnecessary calls to AWS to understand resource state. * * Will cache responses from AWS by [AwsRegion]/[ToolkitCredentialsProvider] - generically applicable to any AWS call. */ 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 [AwsConnectionManager]. + * Get a [resource] either by making a call or returning it from the cache if present and unexpired. * * @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 */ - fun getResource(resource: Resource, useStale: Boolean = true, forceFetch: Boolean = false): CompletionStage + fun getResource( + resource: Resource, + region: AwsRegion, + credentialProvider: ToolkitCredentialsProvider, + useStale: Boolean = true, + forceFetch: Boolean = false + ): CompletionStage /** * @see [getResource] - * - * @param[region] the specific [AwsRegion] to use for this resource - * @param[credentialProvider] the specific [ToolkitCredentialsProvider] to use for this resource */ fun getResource( resource: Resource, region: AwsRegion, - credentialProvider: ToolkitCredentialsProvider, + tokenProvider: ToolkitBearerTokenProvider, useStale: Boolean = true, forceFetch: Boolean = false ): CompletionStage /** - * Blocking version of [getResource] - * - * @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 + * @see [getResource] */ - fun getResourceNow(resource: Resource, timeout: Duration = DEFAULT_TIMEOUT, useStale: Boolean = true, forceFetch: Boolean = false): T = - wait(timeout) { getResource(resource, useStale, forceFetch) } + fun getResource( + resource: Resource, + connectionSettings: ClientConnectionSettings<*>, + useStale: Boolean = true, + forceFetch: Boolean = false + ): CompletionStage = when (connectionSettings) { + is ConnectionSettings -> getResource(resource, connectionSettings.region, connectionSettings.credentials, useStale, forceFetch) + is TokenConnectionSettings -> getResource(resource, connectionSettings.region, connectionSettings.tokenProvider, useStale, forceFetch) + } /** * Blocking version of [getResource] @@ -89,11 +111,30 @@ interface AwsResourceCache { ): T = wait(timeout) { getResource(resource, region, credentialProvider, useStale, forceFetch) } /** - * Gets the [resource] if it exists in the cache. - * - * @param[useStale] return a cached version if it exists (even if it's expired). Default: true + * Blocking version of [getResource] */ - fun getResourceIfPresent(resource: Resource, useStale: Boolean = true): T? + fun getResourceNow( + resource: Resource, + region: AwsRegion, + tokenProvider: ToolkitBearerTokenProvider, + timeout: Duration = DEFAULT_TIMEOUT, + useStale: Boolean = true, + forceFetch: Boolean = false + ): T = wait(timeout) { getResource(resource, region, tokenProvider, useStale, forceFetch) } + + /** + * Blocking version of [getResource] + */ + fun getResourceNow( + resource: Resource, + connectionSettings: ClientConnectionSettings<*>, + timeout: Duration = DEFAULT_TIMEOUT, + useStale: Boolean = true, + forceFetch: Boolean = false + ): T = when (connectionSettings) { + is ConnectionSettings -> getResourceNow(resource, connectionSettings.region, connectionSettings.credentials, timeout, useStale, forceFetch) + is TokenConnectionSettings -> getResourceNow(resource, connectionSettings.region, connectionSettings.tokenProvider, timeout, useStale, forceFetch) + } /** * Gets the [resource] if it exists in the cache. @@ -103,28 +144,38 @@ interface AwsResourceCache { */ fun getResourceIfPresent(resource: Resource, region: AwsRegion, credentialProvider: ToolkitCredentialsProvider, useStale: Boolean = true): T? + /** + * Gets the [resource] if it exists in the cache. + */ + fun getResourceIfPresent(resource: Resource, region: AwsRegion, tokenProvider: ToolkitBearerTokenProvider, useStale: Boolean = true): T? + + /** + * Gets the [resource] if it exists in the cache. + */ + fun getResourceIfPresent(resource: Resource, connectionSettings: ClientConnectionSettings<*>, useStale: Boolean = true): T? = + when (connectionSettings) { + is ConnectionSettings -> getResourceIfPresent(resource, connectionSettings.region, connectionSettings.credentials, useStale) + is TokenConnectionSettings -> getResourceIfPresent(resource, connectionSettings.region, connectionSettings.tokenProvider, useStale) + } + /** * Clears the contents of the cache across all regions, credentials and resource types. */ - fun clear() + suspend fun clear() // TODO: ultimately all of these calls need to be made suspend - start with this one to resolve UI lock /** - * Clears the contents of the cache for the specific [resource] type, in the currently active [AwsRegion] & [ToolkitCredentialsProvider] + * Clears the contents of the cache for the specific [ClientConnectionSettings] */ - fun clear(resource: Resource<*>) + fun clear(connectionSettings: ClientConnectionSettings<*>) /** - * Clears the contents of the cache for the specific [resource] type, [AwsRegion] & [ToolkitCredentialsProvider] + * Clears the contents of the cache for the specific [resource] type] & [ClientConnectionSettings] */ - fun clear(resource: Resource<*>, region: AwsRegion, credentialProvider: ToolkitCredentialsProvider) + fun clear(resource: Resource<*>, connectionSettings: ClientConnectionSettings<*>) companion object { @JvmStatic - fun getInstance(project: Project): AwsResourceCache = ServiceManager.getService(project, AwsResourceCache::class.java) - - // Getting resources can take a long time on a slow connection or if there are a lot of resources. This call should - // always be done in an async context so it should be OK to take multiple seconds. - private val DEFAULT_TIMEOUT = Duration.ofSeconds(30) + fun getInstance(): AwsResourceCache = service() private fun wait(timeout: Duration, call: () -> CompletionStage) = try { call().toCompletableFuture().get(timeout.toMillis(), TimeUnit.MILLISECONDS) @@ -134,8 +185,52 @@ interface AwsResourceCache { } } -fun Project.getResource(resource: Resource, useStale: Boolean = true, forceFetch: Boolean = false) = - AwsResourceCache.getInstance(this).getResource(resource, useStale, forceFetch) +/** + * 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 [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 + */ +fun Project.getResource(resource: Resource, useStale: Boolean = true, forceFetch: Boolean = false): CompletionStage = + AwsResourceCache.getInstance().getResource(resource, this.getConnectionSettingsOrThrow(), useStale, forceFetch) + +/** + * Blocking version of [getResource] + * + * @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 + */ +fun Project.getResourceNow(resource: Resource, timeout: Duration = DEFAULT_TIMEOUT, useStale: Boolean = true, forceFetch: Boolean = false): T = + AwsResourceCache.getInstance().getResourceNow(resource, this.getConnectionSettingsOrThrow(), timeout, useStale, forceFetch) + +/** + * Gets the [resource] if it exists in the cache. + * + * @param[useStale] return a cached version if it exists (even if it's expired). Default: true + */ +fun ConnectionSettings.getResourceIfPresent(resource: Resource, useStale: Boolean = true): T? = + AwsResourceCache.getInstance().getResourceIfPresent(resource, this, useStale) + +/** + * Gets the [resource] if it exists in the cache. + * + * @see [ConnectionSettings.getResourceIfPresent] + */ +fun Project.getResourceIfPresent(resource: Resource, useStale: Boolean = true): T? = + getConnectionSettingsOrThrow().getResourceIfPresent(resource, useStale) + +/** + * Clears the contents of the cache for the specific [resource] type, in the currently active [ConnectionSettings] + */ +fun Project.clearResourceForCurrentConnection(resource: Resource<*>) = + AwsResourceCache.getInstance().clear(resource, this.getConnectionSettingsOrThrow()) + +/** + * Clears the contents of the cache of all resource types for the currently active [ConnectionSettings] + */ +fun Project.clearResourceForCurrentConnection() = + AwsResourceCache.getInstance().clear(this.getConnectionSettingsOrThrow()) sealed class Resource { @@ -143,7 +238,7 @@ sealed class Resource { * A [Cached] resource is one whose fetch is potentially expensive, the result of which should be memoized for a period of time ([expiry]). */ abstract class Cached : Resource() { - abstract fun fetch(project: Project, region: AwsRegion, credentials: ToolkitCredentialsProvider): T + abstract fun fetch(connectionSettings: ClientConnectionSettings<*>): T open fun expiry(): Duration = DEFAULT_EXPIRY abstract val id: String @@ -157,17 +252,22 @@ sealed class Resource { * in order to return the desired type [Output]. The [transform] result is not cached, [transform]s are re-applied on each fetch - thus should * should be relatively cheap. */ - class View(val underlying: Resource, private val transform: Input.() -> Output) : Resource() { + class View(val underlying: Resource, private val transform: (Input, AwsRegion) -> Output) : Resource() { @Suppress("UNCHECKED_CAST") - fun doMap(input: Any) = transform(input as Input) + fun doMap(input: Any, region: AwsRegion) = transform(input as Input, region) + } + + companion object { + fun view(underlying: Resource, transform: Input.() -> Output): Resource = + View(underlying) { input, _ -> transform(input) } } } -fun Resource>.map(transform: (Input) -> Output): Resource> = Resource.View(this) { map(transform) } +fun Resource>.map(transform: (Input) -> Output): Resource> = Resource.view(this) { map(transform) } -fun Resource>.filter(predicate: (T) -> Boolean): Resource> = Resource.View(this) { filter(predicate) } +fun Resource>.filter(predicate: (T) -> Boolean): Resource> = Resource.view(this) { filter(predicate) } -fun Resource>.find(predicate: (T) -> Boolean): Resource = Resource.View(this) { find(predicate) } +fun Resource>.find(predicate: (T) -> Boolean): Resource = Resource.view(this) { find(predicate) } class ClientBackedCachedResource( private val sdkClientClass: KClass, @@ -178,8 +278,8 @@ class ClientBackedCachedResource( constructor(sdkClientClass: KClass, id: String, fetchCall: ClientType.() -> ReturnType) : this(sdkClientClass, id, null, fetchCall) - override fun fetch(project: Project, region: AwsRegion, credentials: ToolkitCredentialsProvider): ReturnType { - val client = AwsClientManager.getInstance(project).getClient(sdkClientClass, credentials, region) + override fun fetch(connectionSettings: ClientConnectionSettings<*>): ReturnType { + val client = AwsClientManager.getInstance().getClient(sdkClientClass, connectionSettings) return fetchCall(client) } @@ -194,7 +294,7 @@ class ExecutableBackedCacheResource>( private val fetchCall: GeneralCommandLine.() -> ReturnType ) : Resource.Cached() { - override fun fetch(project: Project, region: AwsRegion, credentials: ToolkitCredentialsProvider): ReturnType { + override fun fetch(connectionSettings: ClientConnectionSettings<*>): ReturnType { val executableType = ExecutableType.getExecutable(executableTypeClass.java) val executable = ExecutableManager.getInstance().getExecutableIfPresent(executableType).let { @@ -207,8 +307,12 @@ class ExecutableBackedCacheResource>( return fetchCall( executable.getCommandLine() - .withEnvironment(region.toEnvironmentVariables()) - .withEnvironment(credentials.resolveCredentials().toEnvironmentVariables()) + .withEnvironment(connectionSettings.region.toEnvironmentVariables()) + .apply { + if (connectionSettings is ConnectionSettings) { + withEnvironment(connectionSettings.credentials.resolveCredentials().toEnvironmentVariables()) + } + } ) } @@ -216,28 +320,36 @@ class ExecutableBackedCacheResource>( override fun toString(): String = "ExecutableBackedCacheResource(id='$id')" } +@ExperimentalCoroutinesApi class DefaultAwsResourceCache( - private val project: Project, private val clock: Clock, private val maximumCacheEntries: Int, private val maintenanceInterval: Duration ) : AwsResourceCache, Disposable, ToolkitCredentialsChangeListener { + private val coroutineScope = disposableCoroutineScope(this) @Suppress("unused") - constructor(project: Project) : this(project, Clock.systemDefaultZone(), MAXIMUM_CACHE_ENTRIES, DEFAULT_MAINTENANCE_INTERVAL) + constructor() : this(Clock.systemDefaultZone(), MAXIMUM_CACHE_ENTRIES, DEFAULT_MAINTENANCE_INTERVAL) private val cache = ConcurrentHashMap>() - private val accountSettings by lazy { AwsConnectionManager.getInstance(project) } - private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, project) + private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this) init { - ApplicationManager.getApplication().messageBus.connect(this).subscribe(CredentialManager.CREDENTIALS_CHANGED, this) + ApplicationManager.getApplication().messageBus.connect(this).apply { + subscribe(CredentialManager.CREDENTIALS_CHANGED, this@DefaultAwsResourceCache) + + subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun onChange(providerId: String) { + clearByCredential(providerId) + } + } + ) + } scheduleCacheMaintenance() } - override fun getResource(resource: Resource, useStale: Boolean, forceFetch: Boolean) = - getResource(resource, accountSettings.activeRegion, accountSettings.activeCredentialProvider, useStale, forceFetch) - override fun getResource( resource: Resource, region: AwsRegion, @@ -245,18 +357,59 @@ class DefaultAwsResourceCache( useStale: Boolean, forceFetch: Boolean ): CompletionStage = when (resource) { - is Resource.View<*, T> -> getResource(resource.underlying, region, credentialProvider, useStale, forceFetch).thenApply { resource.doMap(it as Any) } - is Resource.Cached -> Context(resource, region, credentialProvider, useStale, forceFetch).also { getCachedResource(it) }.future + is Resource.View<*, T> -> getResource( + resource.underlying, + region, + credentialProvider, + useStale, + forceFetch + ).thenApply { resource.doMap(it as Any, region) } + is Resource.Cached -> Context(resource, region, ConnectionSettings(credentialProvider, region), useStale, forceFetch) + .also { getCachedResource(it) } + .future + } + + override fun getResource( + resource: Resource, + region: AwsRegion, + tokenProvider: ToolkitBearerTokenProvider, + useStale: Boolean, + forceFetch: Boolean + ): CompletionStage = when (resource) { + is Resource.View<*, T> -> getResource( + resource.underlying, + region, + tokenProvider, + useStale, + forceFetch + ).thenApply { resource.doMap(it as Any, region) } + is Resource.Cached -> Context(resource, region, TokenConnectionSettings(tokenProvider, region), useStale, forceFetch) + .also { getCachedResource(it) } + .future } private fun getCachedResource(context: Context) { ApplicationManager.getApplication().executeOnPooledThread { + var currentValue: Entry? = null try { @Suppress("UNCHECKED_CAST") val result = cache.compute(context.cacheKey) { _, value -> - fetchIfNeeded(context, value as Entry?) + currentValue = value as Entry? + fetchIfNeeded(context, currentValue) } as Entry - context.future.complete(result.value) + + coroutineScope.launch { + try { + context.future.complete(result.value.await()) + } catch (e: Throwable) { + val previousValue = currentValue + if (context.useStale && previousValue != null && previousValue.value.isCompleted && !previousValue.value.isCompletedExceptionally) { + context.future.complete(previousValue.value.getCompleted()) + } else { + context.future.completeExceptionally(e) + } + } + } } catch (e: Throwable) { context.future.completeExceptionally(e) } @@ -265,59 +418,88 @@ class DefaultAwsResourceCache( private fun runCacheMaintenance() { try { - var totalWeight = 0 - val entries = cache.entries.asSequence().onEach { totalWeight += it.value.weight }.toList() - var exceededWeight = totalWeight - maximumCacheEntries - if (exceededWeight <= 0) return - entries.sortedBy { it.value.expiry }.forEach { (key, value) -> - if (exceededWeight <= 0) return@runCacheMaintenance - if (cache.computeRemoveIf(key) { it === value }) { - exceededWeight -= value.weight - } - } + doRunCacheMaintenance() } finally { scheduleCacheMaintenance() } } + @VisibleForTesting + internal fun doRunCacheMaintenance() { + var totalWeight = 0 + cache.entries.removeIf { it.value.value.isCompletedExceptionally } + val entries = cache.entries.asSequence().filter { it.value.value.isCompleted }.onEach { totalWeight += it.value.weight }.toList() + var exceededWeight = totalWeight - maximumCacheEntries + if (exceededWeight <= 0) return + entries.sortedBy { it.value.expiry }.forEach { (key, value) -> + if (exceededWeight <= 0) return@doRunCacheMaintenance + if (cache.computeRemoveIf(key) { it === value }) { + exceededWeight -= value.weight + } + } + } + private fun scheduleCacheMaintenance() { if (!alarm.isDisposed) { alarm.addRequest(this::runCacheMaintenance, maintenanceInterval.toMillis()) } } - override fun getResourceIfPresent(resource: Resource, useStale: Boolean): T? = - getResourceIfPresent(resource, accountSettings.activeRegion, accountSettings.activeCredentialProvider, useStale) - override fun getResourceIfPresent(resource: Resource, region: AwsRegion, credentialProvider: ToolkitCredentialsProvider, useStale: Boolean): T? = when (resource) { is Resource.Cached -> { - val entry = cache.getTyped(CacheKey(resource.id, region.id, credentialProvider.id)) + val key = CacheKey(resource.id, region.id, credentialProvider.id) + val entry = cache.getTyped(key) when { - entry != null && (useStale || entry.notExpired) -> entry.value + entry != null && (useStale || entry.notExpired) && + entry.value.isCompleted && entry.value.getCompletionExceptionOrNull() == null -> entry.value.getCompleted() else -> null } } - is Resource.View<*, T> -> getResourceIfPresent(resource.underlying, region, credentialProvider, useStale)?.let { resource.doMap(it) } + is Resource.View<*, T> -> getResourceIfPresent(resource.underlying, region, credentialProvider, useStale)?.let { + resource.doMap( + it, + region + ) + } } - override fun clear(resource: Resource<*>) { - clear(resource, accountSettings.activeRegion, accountSettings.activeCredentialProvider) - } + override fun getResourceIfPresent(resource: Resource, region: AwsRegion, tokenProvider: ToolkitBearerTokenProvider, useStale: Boolean): T? = + when (resource) { + is Resource.Cached -> { + val key = CacheKey(resource.id, region.id, tokenProvider.id) + val entry = cache.getTyped(key) + when { + entry != null && (useStale || entry.notExpired) && + entry.value.isCompleted && entry.value.getCompletionExceptionOrNull() == null -> entry.value.getCompleted() + else -> null + } + } + is Resource.View<*, T> -> getResourceIfPresent(resource.underlying, region, tokenProvider, useStale)?.let { + resource.doMap( + it, + region + ) + } + } - override fun clear(resource: Resource<*>, region: AwsRegion, credentialProvider: ToolkitCredentialsProvider) { + override fun clear(resource: Resource<*>, connectionSettings: ClientConnectionSettings<*>) { when (resource) { - is Resource.Cached<*> -> cache.remove(CacheKey(resource.id, region.id, credentialProvider.id)) - is Resource.View<*, *> -> clear(resource.underlying, region, credentialProvider) + is Resource.Cached<*> -> cache.remove(CacheKey(resource.id, connectionSettings.region.id, connectionSettings.providerId)) + is Resource.View<*, *> -> clear(resource.underlying, connectionSettings) } } - override fun clear() { - cache.clear() + override suspend fun clear() { + coroutineScope { launch { cache.clear() } } + } + + override fun clear(connectionSettings: ClientConnectionSettings<*>) { + cache.keys.removeIf { it.providerId == connectionSettings.providerId && it.regionId == connectionSettings.region.id } } override fun dispose() { - clear() + coroutineScope.launch { clear() } } override fun providerRemoved(identifier: CredentialIdentifier) = clearByCredential(identifier.id) @@ -325,16 +507,19 @@ class DefaultAwsResourceCache( override fun providerModified(identifier: CredentialIdentifier) = clearByCredential(identifier.id) private fun clearByCredential(providerId: String) { - cache.keys.removeIf { it.credentialsId == providerId } + cache.keys.removeIf { it.providerId == providerId } } private fun fetchIfNeeded(context: Context, currentEntry: Entry?) = when { currentEntry == null -> fetch(context) + currentEntry.value.isCompletedExceptionally -> fetch(context) currentEntry.notExpired && !context.forceFetch -> currentEntry context.useStale -> fetchWithFallback(context, currentEntry) else -> fetch(context) } + private val Deferred<*>.isCompletedExceptionally get() = isCompleted && getCompletionExceptionOrNull() != null + private fun fetchWithFallback(context: Context, currentEntry: Entry) = try { fetch(context) } catch (e: Exception) { @@ -343,35 +528,46 @@ class DefaultAwsResourceCache( } private fun fetch(context: Context): Entry { - val value = context.resource.fetch(project, context.region, context.credentials) + val value = coroutineScope.async { + context.resource.fetch(context.connectionSettings) + } + return Entry(clock.instant().plus(context.resource.expiry()), value) } - private val Entry<*>.notExpired get() = clock.instant().isBefore(expiry) + private val Entry<*>.notExpired get() = value.isActive || clock.instant().isBefore(expiry) + + @VisibleForTesting + internal fun hasCacheEntry(resourceId: String): Boolean = cache.filterKeys { it.resourceId == resourceId }.isNotEmpty() companion object { private val LOG = getLogger() private const val MAXIMUM_CACHE_ENTRIES = 1000 private val DEFAULT_MAINTENANCE_INTERVAL: Duration = Duration.ofMinutes(5) - private data class CacheKey(val resourceId: String, val regionId: String, val credentialsId: String) + private data class CacheKey(val resourceId: String, val regionId: String, val providerId: String) private class Context( val resource: Resource.Cached, val region: AwsRegion, - val credentials: ToolkitCredentialsProvider, + val connectionSettings: ClientConnectionSettings<*>, val useStale: Boolean, val forceFetch: Boolean ) { - val cacheKey = CacheKey(resource.id, region.id, credentials.id) + val cacheKey = CacheKey(resource.id, region.id, connectionSettings.providerId) val future = CompletableFuture() } - private class Entry(val expiry: Instant, val value: T) { - val weight = when (value) { - is Collection<*> -> value.size - else -> 1 - } + private class Entry(val expiry: Instant, val value: Deferred) { + val weight: Int + get() = if (value.isCompleted && value.getCompletionExceptionOrNull() == null) { + when (val underlying = value.getCompleted()) { + is Collection<*> -> underlying.size + else -> 1 + } + } else { + 1 + } } private fun ConcurrentMap>.getTyped(key: CacheKey) = this[key]?.let { @@ -390,7 +586,9 @@ class DefaultAwsResourceCache( if (predicate(v)) { removed = true null - } else v + } else { + v + } } return removed } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsSdkClient.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsSdkClient.kt index 8b37f17b01..9c3b0a7fa6 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsSdkClient.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsSdkClient.kt @@ -1,12 +1,11 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.jetbrains.core import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.components.ServiceManager -import com.intellij.openapi.util.Disposer +import com.intellij.openapi.components.service import com.intellij.util.net.ssl.CertificateManager import com.intellij.util.proxy.CommonProxy import org.apache.http.impl.client.SystemDefaultCredentialsProvider @@ -16,16 +15,13 @@ import software.amazon.awssdk.http.HttpExecuteRequest import software.amazon.awssdk.http.SdkHttpClient import software.amazon.awssdk.http.apache.ApacheHttpClient import software.amazon.awssdk.http.apache.ProxyConfiguration +import software.aws.toolkits.core.clients.SdkClientProvider import software.aws.toolkits.core.utils.assertTrue import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info -class AwsSdkClient : Disposable { - init { - Disposer.register(ApplicationManager.getApplication(), this) - } - - val sdkHttpClient: SdkHttpClient by lazy { +class AwsSdkClient : SdkClientProvider, Disposable { + private val sdkHttpClient: SdkHttpClient by lazy { LOG.info { "Create new Apache client" } val httpClientBuilder = ApacheHttpClient.builder() .proxyConfiguration(ProxyConfiguration.builder().useSystemPropertyValues(false).build()) @@ -36,6 +32,8 @@ class AwsSdkClient : Disposable { ValidateCorrectThreadClient(httpClientBuilder.build()) } + override fun sharedSdkClient(): SdkHttpClient = sdkHttpClient + override fun dispose() { sdkHttpClient.close() } @@ -58,6 +56,6 @@ class AwsSdkClient : Disposable { private val LOG = getLogger() private const val WRONG_THREAD = "Network calls can't be made inside read/write action" - fun getInstance(): AwsSdkClient = ServiceManager.getService(AwsSdkClient::class.java) + fun getInstance(): SdkClientProvider = service() } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsTelemetryPrompter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsTelemetryPrompter.kt index a9236fe1dd..f7d2d71c06 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsTelemetryPrompter.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/AwsTelemetryPrompter.kt @@ -4,43 +4,34 @@ package software.aws.toolkits.jetbrains.core import com.intellij.notification.Notification -import com.intellij.notification.NotificationDisplayType -import com.intellij.notification.NotificationGroup -import com.intellij.notification.NotificationListener import com.intellij.notification.NotificationType -import com.intellij.notification.Notifications import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.openapi.startup.StartupActivity import software.aws.toolkits.jetbrains.settings.AwsSettings import software.aws.toolkits.jetbrains.settings.AwsSettingsConfigurable import software.aws.toolkits.resources.message -import javax.swing.event.HyperlinkEvent - -internal const val GROUP_DISPLAY_ID = "AWS Telemetry" - -class AwsTelemetryPrompter : StartupActivity { +class AwsTelemetryPrompter : StartupActivity.Background { override fun runActivity(project: Project) { - if (!AwsSettings.getInstance().promptedForTelemetry) { - val group = NotificationGroup(GROUP_DISPLAY_ID, NotificationDisplayType.STICKY_BALLOON, true) + if (AwsSettings.getInstance().promptedForTelemetry || System.getProperty("aws.telemetry.skip_prompt", null)?.toBoolean() == true) { + return + } - val notification = group.createNotification( - message("aws.settings.telemetry.prompt.title"), - message("aws.settings.telemetry.prompt.message"), - NotificationType.INFORMATION, - // 2020.1 fails to compile this when this argument is a lambda instead - object : NotificationListener { - override fun hyperlinkUpdate(notification: Notification, event: HyperlinkEvent) { - ShowSettingsUtil.getInstance().showSettingsDialog(project, AwsSettingsConfigurable::class.java) - notification.expire() - } - } - ) + val notification = Notification( + "aws.toolkit_telemetry", + message("aws.settings.telemetry.prompt.title"), + message("aws.settings.telemetry.prompt.message"), + NotificationType.INFORMATION + ).also { + it.setListener { notification, _ -> + ShowSettingsUtil.getInstance().showSettingsDialog(project, AwsSettingsConfigurable::class.java) + notification.expire() + } + } - Notifications.Bus.notify(notification, project) + notification.notify(project) - AwsSettings.getInstance().promptedForTelemetry = true - } + AwsSettings.getInstance().promptedForTelemetry = true } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt index 9ce15a73b4..0a6737f998 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt @@ -5,10 +5,22 @@ package software.aws.toolkits.jetbrains.core import com.intellij.openapi.progress.ProgressIndicator import com.intellij.util.io.HttpRequests +import org.apache.http.entity.ContentType import java.nio.file.Path fun saveFileFromUrl(url: String, path: Path, indicator: ProgressIndicator? = null) = HttpRequests.request(url).userAgent(AwsClientManager.userAgent).saveToFile(path.toFile(), indicator) +fun readBytesFromUrl(url: String, indicator: ProgressIndicator? = null) = + HttpRequests.request(url).userAgent(AwsClientManager.userAgent).readBytes(indicator) + fun getTextFromUrl(url: String): String = HttpRequests.request(url).userAgent(AwsClientManager.userAgent).readString() + +fun writeJsonToUrl(url: String, jsonString: String, indicator: ProgressIndicator? = null): String = + HttpRequests.post(url, ContentType.APPLICATION_JSON.toString()) + .userAgent(AwsClientManager.userAgent) + .connect { request -> + request.write(jsonString) + request.readString(indicator) + } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/IdBasedExtensionPoint.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/IdBasedExtensionPoint.kt new file mode 100644 index 0000000000..b0467202c8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/IdBasedExtensionPoint.kt @@ -0,0 +1,28 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.core + +import com.intellij.openapi.extensions.CustomLoadingExtensionPointBean +import com.intellij.util.KeyedLazyInstance +import com.intellij.util.xmlb.annotations.Attribute + +/** + * Extension point that is used to tie multiple extension points together by a common ID. + * + * For example, if you have a "parent" extension point that defines a new ID, i.e Lambda runtime. + * "Children" extension points that support it can then use the same ID to correlate each other, + * i.e Lambda building, Lambda handler can be looked up using the same ID as defined by the Runtime parent EP. + * + * Additional attributes can be defined on the EP by extending this class. + */ +open class IdBasedExtensionPoint : CustomLoadingExtensionPointBean(), KeyedLazyInstance { + @Attribute("id") + lateinit var id: String + + @Attribute("implementationClass") + lateinit var implementationClass: String + + override fun getImplementationClassName(): String = implementationClass + + override fun getKey(): String = id +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/RemoteResourceResolverProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/RemoteResourceResolverProvider.kt index f3be8775fd..45381a9e14 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/RemoteResourceResolverProvider.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/RemoteResourceResolverProvider.kt @@ -5,7 +5,7 @@ package software.aws.toolkits.jetbrains.core import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.PathManager -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.components.service import com.intellij.util.io.createDirectories import software.aws.toolkits.core.utils.DefaultRemoteResourceResolver import software.aws.toolkits.core.utils.RemoteResourceResolver @@ -18,7 +18,7 @@ interface RemoteResourceResolverProvider { fun get(): RemoteResourceResolver companion object { - fun getInstance(): RemoteResourceResolverProvider = ServiceManager.getService(RemoteResourceResolverProvider::class.java) + fun getInstance(): RemoteResourceResolverProvider = service() } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/coroutines/contexts.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/coroutines/contexts.kt new file mode 100644 index 0000000000..ba982a9424 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/coroutines/contexts.kt @@ -0,0 +1,34 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// kotlinx.coroutines.Dispatchers is banned +@file:Suppress("BannedImports") + +package software.aws.toolkits.jetbrains.core.coroutines + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.util.concurrency.AppExecutorUtil +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +private class ModalityStateElement(val modalityState: ModalityState) : AbstractCoroutineContextElement(ModalityStateElementKey) + +private object ModalityStateElementKey : CoroutineContext.Key + +private object EdtCoroutineDispatcher : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + val state = context[ModalityStateElementKey]?.modalityState ?: ModalityState.any() + ApplicationManager.getApplication().invokeLater(block, state) + } +} + +@Deprecated("Always uses ModalityState.any() by default", ReplaceWith("EDT", "software.aws.toolkits.jetbrains.core.coroutines.EDT")) +fun getCoroutineUiContext(): CoroutineContext = EdtCoroutineDispatcher + +fun getCoroutineBgContext(): CoroutineContext = AppExecutorUtil.getAppExecutorService().asCoroutineDispatcher() + +val EDT = Dispatchers.EDT diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/coroutines/scopes.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/coroutines/scopes.kt new file mode 100644 index 0000000000..119863c7da --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/coroutines/scopes.kt @@ -0,0 +1,88 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.coroutines + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.Application +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import java.util.concurrent.CancellationException + +/** + * Coroutine scope that is tied to the full Application closing, or plugin unloading. Default to dispatching to background thread pool. + * + * Use this if the coroutine needs to live past a project being closed or across projects such as an Application Service + */ +fun applicationCoroutineScope(coroutineName: String): CoroutineScope = + PluginCoroutineScopeTracker.getInstance().applicationThreadPoolScope(coroutineName) + +/** + * Coroutine scope that is tied to a project closing, or plugin unloading. Default to dispatching to background thread pool. + * + * Use this if the coroutine needs to live past a UI being closed, or tied to a project's life cycle such as a Project Service. + */ +fun projectCoroutineScope(project: Project, coroutineName: String): CoroutineScope = + PluginCoroutineScopeTracker.getInstance(project).applicationThreadPoolScope(coroutineName) + +/** + * Coroutine scope that is tied to a disposable or a plugin unloading. Default to dispatching to background thread pool. + * + * Use this if the coroutine is tied to a UI such as a tool window, or a run configuration + * + * **Note: If a call lives past the closing of a UI such as kicking off a resource creation, use [projectCoroutineScope]. + * Otherwise, the coroutine will be canceled when the UI is closed!** + */ +fun disposableCoroutineScope(disposable: Disposable, coroutineName: String): CoroutineScope { + check(disposable !is Project && disposable !is Application) { "disposable should not be a project or application" } + return PluginCoroutineScopeTracker.getInstance().applicationThreadPoolScope(coroutineName).also { + Disposer.register(disposable) { + it.cancel(CancellationException("Parent disposable was disposed")) + } + } +} + +/** + * Version of [applicationCoroutineScope] the class name as the coroutine name. + */ +inline fun T.applicationCoroutineScope(): CoroutineScope = + applicationCoroutineScope(T::class.java.name) + +/** + * Version of [projectCoroutineScope] the class name as the coroutine name. + */ +inline fun T.projectCoroutineScope(project: Project): CoroutineScope = + projectCoroutineScope(project, T::class.java.name) + +/** + * Version of [disposableCoroutineScope] the class name as the coroutine name. + */ +inline fun T.disposableCoroutineScope(disposable: Disposable): CoroutineScope = + disposableCoroutineScope(disposable, T::class.java.name) + +class PluginCoroutineScopeTracker : Disposable { + @PublishedApi + internal fun applicationThreadPoolScope(coroutineName: String): CoroutineScope = BackgroundThreadPoolScope(coroutineName, this) + + override fun dispose() {} + + companion object { + fun getInstance() = service() + fun getInstance(project: Project) = project.service() + } +} + +private class BackgroundThreadPoolScope(coroutineName: String, disposable: Disposable) : CoroutineScope { + override val coroutineContext = SupervisorJob() + CoroutineName(coroutineName) + getCoroutineBgContext() + + init { + Disposer.register(disposable) { + coroutineContext.cancel(CancellationException("Parent disposable was disposed")) + } + } +} 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 index 548d6bea14..76ba327bb2 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsConnectionManager.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsConnectionManager.kt @@ -3,20 +3,19 @@ package software.aws.toolkits.jetbrains.core.credentials +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.application.AppUIExecutor import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.service 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 org.jetbrains.concurrency.AsyncPromise +import software.aws.toolkits.core.ConnectionSettings import software.aws.toolkits.core.credentials.CredentialIdentifier import software.aws.toolkits.core.credentials.CredentialProviderNotFoundException import software.aws.toolkits.core.credentials.ToolkitCredentialsChangeListener @@ -30,20 +29,22 @@ 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.atomic.AtomicReference -abstract class AwsConnectionManager(private val project: Project) : SimpleModificationTracker() { - private val resourceCache = AwsResourceCache.getInstance(project) +abstract class AwsConnectionManager(private val project: Project) : SimpleModificationTracker(), Disposable { + private val resourceCache = AwsResourceCache.getInstance() private val regionProvider = AwsRegionProvider.getInstance() private val credentialsRegionHandler = CredentialsRegionHandler.getInstance(project) - @Volatile - private var validationJob: Job? = null + private val validationJob = AtomicReference>() @Volatile var connectionState: ConnectionState = ConnectionState.InitializingToolkit internal set(value) { field = value - if (!project.isDisposed) { + incModificationCount() + + AppUIExecutor.onWriteThread(ModalityState.any()).expireWith(this).execute { project.messageBus.syncPublisher(CONNECTION_SETTINGS_STATE_CHANGED).settingsStateChanged(value) } } @@ -55,23 +56,25 @@ abstract class AwsConnectionManager(private val project: Project) : SimpleModifi 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) + @Suppress("LeakingThis") + ApplicationManager.getApplication().messageBus.connect(this) + .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() + override fun providerModified(identifier: CredentialIdentifier) { + if (selectedCredentialIdentifier == identifier) { + refreshConnectionState() + } } } - }) + ) } fun isValidConnectionSettings(): Boolean = connectionState is ConnectionState.ValidConnection @@ -109,7 +112,7 @@ abstract class AwsConnectionManager(private val project: Project) : SimpleModifi /** * Changes the credentials and then validates them. Notifies listeners of results */ - fun changeCredentialProvider(identifier: CredentialIdentifier) { + fun changeCredentialProvider(identifier: CredentialIdentifier, passive: Boolean = false) { changeFieldsAndNotify { recentlyUsedProfiles.add(identifier.id) @@ -117,59 +120,97 @@ abstract class AwsConnectionManager(private val project: Project) : SimpleModifi selectedRegion = credentialsRegionHandler.determineSelectedRegion(identifier, selectedRegion) } + + AwsTelemetry.setCredentials(project = project, credentialType = identifier.credentialType.toTelemetryType(), passive = passive) } /** * Changes the region and then validates them. Notifies listeners of results */ - fun changeRegion(region: AwsRegion) { + fun changeRegion(region: AwsRegion, passive: Boolean = false) { + val oldRegion = selectedRegion changeFieldsAndNotify { recentlyUsedRegions.add(region.id) selectedRegion = region } + + if (oldRegion?.partitionId != region.partitionId) { + AwsTelemetry.setPartition(project = project, partitionId = region.partitionId, passive = passive) + } + + AwsTelemetry.setRegion(project = project, passive = passive) } @Synchronized private fun changeFieldsAndNotify(fieldUpdateBlock: () -> Unit) { - incModificationCount() - - validationJob?.cancel(CancellationException("Newer connection settings chosen")) val isInitial = connectionState is ConnectionState.InitializingToolkit connectionState = ConnectionState.ValidatingConnection + // Grab the current state stamp + val modificationStamp = this.modificationCount + fieldUpdateBlock() - validationJob = GlobalScope.launch(Dispatchers.IO) { - val credentialsIdentifier = selectedCredentialIdentifier - val region = selectedRegion + val validateCredentialsResult = validateCredentials(selectedCredentialIdentifier, selectedRegion, isInitial) + validationJob.getAndSet(validateCredentialsResult)?.cancel() + + validateCredentialsResult.onSuccess { + // Validate we are still operating in the latest view of the world + if (modificationStamp == this.modificationCount) { + connectionState = it + } else { + LOGGER.warn { "validateCredentials returned but the account manager state has been manipulated before results were back, ignoring" } + } + } + } + + private fun validateCredentials(credentialsIdentifier: CredentialIdentifier?, region: AwsRegion?, isInitial: Boolean): AsyncPromise { + val promise = AsyncPromise() + ApplicationManager.getApplication().executeOnPooledThread { if (credentialsIdentifier == null || region == null) { - connectionState = ConnectionState.IncompleteConfiguration(credentialsIdentifier, region) - incModificationCount() - return@launch + promise.setResult(ConnectionState.IncompleteConfiguration(credentialsIdentifier, region)) + return@executeOnPooledThread } if (isInitial && credentialsIdentifier is InteractiveCredential && credentialsIdentifier.userActionRequired()) { - connectionState = ConnectionState.RequiresUserAction(credentialsIdentifier) - - incModificationCount() - return@launch + promise.setResult(ConnectionState.RequiresUserAction(credentialsIdentifier)) + return@executeOnPooledThread } + var success = true try { val credentialsProvider = CredentialManager.getInstance().getAwsCredentialProvider(credentialsIdentifier, region) validate(credentialsProvider, region) - selectedCredentialsProvider = credentialsProvider - connectionState = ConnectionState.ValidConnection(credentialsProvider, region) + + promise.setResult(ConnectionState.ValidConnection(credentialsProvider, region)) } catch (e: Exception) { - connectionState = ConnectionState.InvalidConnection(e) LOGGER.warn(e) { message("credentials.profile.validation_error", credentialsIdentifier.displayName) } + val result = if (credentialsIdentifier is PostValidateInteractiveCredential) { + try { + credentialsIdentifier.handleValidationException(e) + } catch (nested: Exception) { + LOGGER.warn(nested) { "$credentialsIdentifier threw while attempting to handle initial validation exception" } + null + } + } else { + null + } + + if (result == null) { + success = false + } + promise.setResult(result ?: ConnectionState.InvalidConnection(e)) } finally { - incModificationCount() - AwsTelemetry.validateCredentials(project, success = isValidConnectionSettings()) - validationJob = null + AwsTelemetry.validateCredentials( + project, + success = success, + credentialType = credentialsIdentifier.credentialType.toTelemetryType() + ) } } + + return promise } /** @@ -185,8 +226,19 @@ abstract class AwsConnectionManager(private val project: Project) : SimpleModifi */ 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" } + get() { + val state = connectionState + return if (state is ConnectionState.ValidConnection) { + state.credentials + } else { + if (selectedCredentialIdentifier == null) { + LOGGER.warn(IllegalStateException()) { + "Using activeCredentialProvider when credentials is null, calling code needs to be migrated to handle null" + } + } + + throw CredentialProviderNotFoundException(message("credentials.profile.not_configured")) + } } /** @@ -205,17 +257,17 @@ abstract class AwsConnectionManager(private val project: Project) : SimpleModifi /** * 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() - } + protected open fun validate(credentialsProvider: ToolkitCredentialsProvider, region: AwsRegion) { + resourceCache.getResource( + StsResources.ACCOUNT, + region = region, + credentialProvider = credentialsProvider, + useStale = false, + forceFetch = true + ).toCompletableFuture().get() + } + + override fun dispose() { } companion object { @@ -228,7 +280,7 @@ abstract class AwsConnectionManager(private val project: Project) : SimpleModifi ) @JvmStatic - fun getInstance(project: Project): AwsConnectionManager = ServiceManager.getService(project, AwsConnectionManager::class.java) + fun getInstance(project: Project): AwsConnectionManager = project.service() private val LOGGER = getLogger() private const val MAX_HISTORY = 5 @@ -236,12 +288,26 @@ abstract class AwsConnectionManager(private val project: Project) : SimpleModifi } } +fun Project.getConnectionSettingsOrThrow(): ConnectionSettings = getConnectionSettings() + ?: throw IllegalStateException("Bug: Attempting to retrieve connection settings with invalid connection state") + +fun Project.getConnectionSettings(): ConnectionSettings? = AwsConnectionManager.getInstance(this).connectionSettings() + +fun Project.withAwsConnection(block: (ConnectionSettings) -> T): T { + val connectionSettings = AwsConnectionManager.getInstance(this).connectionSettings() + ?: throw IllegalStateException("Connection settings are not configured") + return block(connectionSettings) +} + /** * 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) { + protected val gettingStartedAction: AnAction = ActionManager.getInstance().getAction("aws.toolkit.toolwindow.newConnection") + protected val editCredsAction: AnAction = ActionManager.getInstance().getAction("aws.settings.upsertCredentials") + /** * An optional short message to display in places where space is at a premium */ @@ -258,6 +324,7 @@ sealed class ConnectionState(val displayMessage: String, val isTerminal: Boolean 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}" + val connection by lazy { ConnectionSettings(credentials, region) } } class IncompleteConfiguration(credentials: CredentialIdentifier?, region: AwsRegion?) : ConnectionState( @@ -268,13 +335,15 @@ sealed class ConnectionState(val displayMessage: String, val isTerminal: Boolean else -> throw IllegalArgumentException("At least one of regionId ($region) or toolkitCredentialsIdentifier ($credentials) must be null") }, isTerminal = true - ) + ) { + override val actions: List = listOf(gettingStartedAction, editCredsAction) + } 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"))) + override val actions: List = listOf(RefreshConnectionAction(message("settings.retry")), gettingStartedAction, editCredsAction) } class RequiresUserAction(interactiveCredentials: InteractiveCredential) : diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsConnectionManagerConnection.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsConnectionManagerConnection.kt new file mode 100644 index 0000000000..f6a8592701 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/AwsConnectionManagerConnection.kt @@ -0,0 +1,15 @@ +// Copyright 2022 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.project.Project +import software.aws.toolkits.core.ConnectionSettings + +class AwsConnectionManagerConnection(private val project: Project) : AwsCredentialConnection { + override val id: String = "AwsConnectionManagerConnection" + override val label: String + get() = AwsConnectionManager.getInstance(project).connectionState.displayMessage + + override fun getConnectionSettings(): ConnectionSettings = error("Use AwsConnectionManager for connection") +} 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 90d9906829..ce450915df 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 @@ -3,52 +3,134 @@ package software.aws.toolkits.jetbrains.core.credentials +import com.intellij.icons.AllIcons import com.intellij.ide.DataManager -import com.intellij.openapi.actionSystem.ActionPlaces -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.DataContext -import com.intellij.openapi.actionSystem.PlatformDataKeys -import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.ui.popup.ListPopup +import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.StatusBar import com.intellij.openapi.wm.StatusBarWidget -import com.intellij.openapi.wm.StatusBarWidgetProvider +import com.intellij.openapi.wm.StatusBarWidgetFactory import com.intellij.util.Consumer -import software.aws.toolkits.jetbrains.core.credentials.ChangeAccountSettingsMode.BOTH +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener import software.aws.toolkits.resources.message -import java.awt.Component import java.awt.event.MouseEvent +import javax.swing.Icon -class AwsSettingsPanelInstaller : StatusBarWidgetProvider { - override fun getWidget(project: Project): StatusBarWidget = AwsSettingsPanel(project) +private const val WIDGET_ID = "AwsSettingsPanel" + +class AwsSettingsPanelInstaller : StatusBarWidgetFactory { + override fun getId(): String = WIDGET_ID + + override fun getDisplayName(): String = message("settings.title") + + override fun isAvailable(project: Project): Boolean = true + + override fun createWidget(project: Project): StatusBarWidget = AwsSettingsPanel(project) + + override fun disposeWidget(widget: StatusBarWidget) { + Disposer.dispose(widget) + } + + override fun canBeEnabledOn(statusBar: StatusBar): Boolean = true } -private class AwsSettingsPanel(private val project: Project) : StatusBarWidget, +private class AwsSettingsPanel(private val project: Project) : + StatusBarWidget, StatusBarWidget.MultipleTextValuesPresentation, - ConnectionSettingsStateChangeNotifier { + ConnectionSettingsStateChangeNotifier, + ToolkitConnectionManagerListener { + private val settingsSelector = ProjectLevelSettingSelector(project, ChangeSettingsMode.BOTH) private val accountSettingsManager = AwsConnectionManager.getInstance(project) - private val settingsSelector = SettingsSelector(project) + private val connectionManager = ToolkitConnectionManager.getInstance(project) private lateinit var statusBar: StatusBar - @Suppress("FunctionName") - override fun ID(): String = "AwsSettingsPanel" + override fun ID(): String = WIDGET_ID - override fun getTooltipText() = "${SettingsSelector.tooltipText} [${accountSettingsManager.connectionState.displayMessage}]" + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this - override fun getSelectedValue() = "AWS: ${accountSettingsManager.connectionState.shortMessage}" + override fun getTooltipText(): String { + val displayMessage = when (val connection = connectionManager.activeConnection()) { + null, is AwsConnectionManagerConnection -> accountSettingsManager.connectionState.displayMessage + else -> connection.label + } - override fun getPopupStep() = settingsSelector.settingsPopup(statusBar.component) + return "${message("settings.title")} [$displayMessage]" + } - override fun getClickConsumer(): Consumer? = null + override fun getSelectedValue(): String { + if (!accountSettingsManager.connectionState.isTerminal) { + return accountSettingsManager.connectionState.shortMessage + } + + val currentProfileInvalid = accountSettingsManager.connectionState.let { it.isTerminal && it !is ConnectionState.ValidConnection } + val invalidBearerConnections = lazyGetUnauthedBearerConnections() + + if (currentProfileInvalid || invalidBearerConnections.isNotEmpty()) { + val numInvalid = invalidBearerConnections.size + if (currentProfileInvalid) 1 else 0 + if (numInvalid == 1) { + invalidBearerConnections.firstOrNull()?.let { + return message("settings.statusbar.widget.format", message("settings.statusbar.widget.expired.1", it.label)) + } + + return message("settings.statusbar.widget.format", accountSettingsManager.connectionState.shortMessage) + } + + return message("settings.statusbar.widget.format", message("settings.statusbar.widget.expired.n", numInvalid)) + } + + val totalConnections = ToolkitAuthManager.getInstance().listConnections().size + CredentialManager.getInstance().getCredentialIdentifiers().size + if (totalConnections == 1) { + val displayText = when (val connection = connectionManager.activeConnection()) { + null -> message("settings.credentials.none_selected") + is AwsConnectionManagerConnection -> accountSettingsManager.connectionState.shortMessage + else -> connection.label + } + + return message("settings.statusbar.widget.format", displayText) + } + + return message("settings.statusbar.widget.format", message("settings.statusbar.widget.connections.n", totalConnections)) + } - override fun getPresentation(type: StatusBarWidget.PlatformType) = this + override fun getPopupStep(): ListPopup = settingsSelector.createPopup(DataManager.getInstance().getDataContext(statusBar.component)) + + override fun getClickConsumer(): Consumer? = null override fun install(statusBar: StatusBar) { this.statusBar = statusBar project.messageBus.connect(this).subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, this) + ApplicationManager.getApplication().messageBus.connect(this).subscribe(ToolkitConnectionManagerListener.TOPIC, this) + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun onChange(providerId: String) { + updateWidget() + } + + override fun invalidate(providerId: String) { + updateWidget() + } + } + ) + + updateWidget() + + // ideally should be through notification bus. instead we simulate the update() method used by the actions + disposableCoroutineScope(this, "AwsSettingsPanel icon update loop").launch { + while (isActive) { + updateWidget() + delay(10000) + } + } + } + + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { updateWidget() } @@ -60,32 +142,12 @@ private class AwsSettingsPanel(private val project: Project) : StatusBarWidget, statusBar.updateWidget(ID()) } - override fun dispose() {} -} - -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, mode).showCenteredInCurrentWindow(project) - } -} + override fun getIcon(): Icon? = + if (lazyGetUnauthedBearerConnections().isNotEmpty()) { + AllIcons.General.Warning + } else { + null + } -class SettingsSelector(private val project: Project) { - 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") - } + override fun dispose() {} } 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 deleted file mode 100644 index 4bc1d93786..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ChangeConnectionSettingsMenu.kt +++ /dev/null @@ -1,213 +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.actionSystem.ActionManager -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.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 -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 (mode.showRegions) { - val usedRegions = accountSettingsManager.recentlyUsedRegions() - if (usedRegions.isEmpty()) { - regionSelector.isPopup = false - actions.add(regionSelector) - } else { - actions.add(Separator.create(message("settings.regions.recent"))) - usedRegions.forEach { - actions.add(ChangeRegionAction(it)) - } - - regionSelector.isPopup = true - actions.add(regionSelector) - } - } - - 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 = true - actions.add(credentialSelector) - } - } - - actions.add(Separator.create()) - actions.addAll(accountSettingsManager.connectionState.actions) - - CachedValueProvider.Result.create(actions.toTypedArray(), accountSettingsManager) - } -} - -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 { - override fun createChildrenProvider(actionManager: ActionManager?): CachedValueProvider> = CachedValueProvider { - val credentialManager = CredentialManager.getInstance() - - val actions = mutableListOf() - credentialManager.getCredentialIdentifiers().forEach { - actions.add(ChangeCredentialsAction(it)) - } - actions.add(Separator.create()) - actions.add(ActionManager.getInstance().getAction("aws.settings.upsertCredentials")) - - CachedValueProvider.Result.create(actions.toTypedArray(), credentialManager) - } -} - -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 (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 actions = mutableListOf() - - regionMap.values.groupBy { it.category }.forEach { (category, subRegions) -> - actions.add(Separator.create(category)) - subRegions.forEach { - actions.add(ChangeRegionAction(it)) - } - } - - if (partitionGroup != null && regionProvider.partitions().size > 1) { - actions.add(Separator.create()) - actions.add(partitionGroup) - } - - CachedValueProvider.Result.create(actions.toTypedArray(), accountSettingsManager) - } -} - -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) { - if (state) { - getAccountSetting(e).changeRegion(region) - } - } -} - -internal class ChangeCredentialsAction(private val credentialsProvider: CredentialIdentifier) : ToggleAction(credentialsProvider.displayName), - DumbAware { - override fun isSelected(e: AnActionEvent): Boolean = getAccountSetting(e).selectedCredentialIdentifier == credentialsProvider - - override fun setSelected(e: AnActionEvent, state: Boolean) { - if (state) { - getAccountSetting(e).changeCredentialProvider(credentialsProvider) - } - } -} - -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/ConfigFilesFacade.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConfigFilesFacade.kt new file mode 100644 index 0000000000..f2c819e8ae --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConfigFilesFacade.kt @@ -0,0 +1,291 @@ +// 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.fileEditor.FileDocumentManager +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.profiles.ProfileFile +import software.amazon.awssdk.profiles.ProfileFileLocation +import software.aws.toolkits.core.utils.appendText +import software.aws.toolkits.core.utils.createParentDirectories +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.inputStreamIfExists +import software.aws.toolkits.core.utils.touch +import software.aws.toolkits.core.utils.tryDirOp +import software.aws.toolkits.core.utils.tryFileOp +import software.aws.toolkits.core.utils.writeText +import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileWatcher +import software.aws.toolkits.jetbrains.core.credentials.profiles.SsoSessionConstants +import software.aws.toolkits.jetbrains.core.credentials.profiles.ssoSessions +import java.nio.file.Path + +interface ConfigFilesFacade { + val configPath: Path + val credentialsPath: Path + + /** + * Returns all valid "profile" sections defined in config/credentials. + * This should follow the same semantics of [software.amazon.awssdk.profiles.ProfileFile.profiles] + */ + fun readAllProfiles(): Map + fun readSsoSessions(): Map + + fun createConfigFile() + + fun appendProfileToConfig(profile: Profile) + fun appendProfileToCredentials(profile: Profile) + fun appendSectionToConfig(sectionName: String, profile: Profile) + fun updateSectionInConfig(sectionName: String, profile: Profile) + + fun deleteSsoConnectionFromConfig(sessionName: String) +} + +class DefaultConfigFilesFacade( + override val configPath: Path = ProfileFileLocation.configurationFilePath(), + override val credentialsPath: Path = ProfileFileLocation.credentialsFilePath(), +) : ConfigFilesFacade { + companion object { + private val LOG = getLogger() + + val TEMPLATE = + """ + # Amazon Web Services Config File used by AWS CLI, SDKs, and tools + # This file was created by the AWS Toolkit for JetBrains plugin. + # + # Your AWS credentials are represented by access keys associated with IAM users. + # For information about how to create and manage AWS access keys for a user, see: + # https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html + # + # This config file can store multiple access keys by placing each one in a + # named "profile". For information about how to change the access keys in a + # profile or to add a new profile with a different access key, see: + # https://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html + # + # If both a credential and config file exists, the values in the credential file + # take precedence + + [default] + # The access key and secret key pair identify your account and grant access to AWS. + aws_access_key_id = [accessKey] + # Treat your secret key like a password. Never share your secret key with anyone. Do + # not post it in online forums, or store it in a source control system. If your secret + # key is ever disclosed, immediately use IAM to delete the access key and secret key + # and create a new key pair. Then, update this file with the replacement key details. + aws_secret_access_key = [secretKey] + + # [profile user1] + # aws_access_key_id = [accessKey1] + # aws_secret_access_key = [secretKey1] + + + # AWS IAM Identity Center (successor to AWS Single Sign-On) helps you stay logged into AWS tools + # without needing to enter your info all the time + # For information about how to create and manage AWS IAM Identity Center see: + # https://docs.aws.amazon.com/singlesignon/latest/userguide/get-started-enable-identity-center.html + # For more information on how to configure this file to use AWS IAM Identity Center, see: + # https://docs.aws.amazon.com/cli/latest/userguide/sso-configure-profile-token.html + + # [sso-session my-sso] + # sso_region = us-east-1 + # sso_start_url = https://my-sso-portal.awsapps.com/start + # sso_registration_scopes = sso:account:access + + # [profile dev] + # sso_session = my-sso + # sso_account_id = 111122223333 + # sso_role_name = SampleRole + """.trimIndent() + } + + private fun aggregateProfiles() = ProfileFile.aggregator() + .applyMutation { + if (credentialsPath.exists()) { + it.addFile( + ProfileFile.builder() + .content(credentialsPath) + .type(ProfileFile.Type.CREDENTIALS) + .build() + ) + } + } + .applyMutation { + if (configPath.exists()) { + it.addFile( + ProfileFile.builder() + .content(configPath) + .type(ProfileFile.Type.CONFIGURATION) + .build() + ) + } + } + .build() + + override fun readAllProfiles(): Map = aggregateProfiles().profiles() + + override fun readSsoSessions(): Map = aggregateProfiles().ssoSessions() + + override fun createConfigFile() { + configPath.tryDirOp(LOG) { createParentDirectories() } + + configPath.tryFileOp(LOG) { + touch(restrictToOwner = true) + writeText(TEMPLATE) + } + } + + override fun appendProfileToConfig(profile: Profile) = + appendSection(configPath, "profile", profile) + + override fun appendProfileToCredentials(profile: Profile) = + appendSection(credentialsPath, "profile", profile) + + override fun appendSectionToConfig(sectionName: String, profile: Profile) = + appendSection(configPath, sectionName, profile) + + override fun updateSectionInConfig(sectionName: String, profile: Profile) { + assert(sectionName == "sso-session") { "Method only supports updating sso-session" } + configPath.tryFileOp(LOG) { + touch(restrictToOwner = true) + val lines = inputStreamIfExists()?.reader()?.readLines().orEmpty() + val profileHeaderLine = lines.indexOfFirst { it.startsWith("[$sectionName ${profile.name()}]") } + if (profileHeaderLine == -1) { + // does not have profile, just write directly to end + appendSectionToConfig(sectionName, profile) + } else { + // has profile + val nextHeaderLine = lines.subList(profileHeaderLine + 1, lines.size).indexOfFirst { it.startsWith("[") } + val endIndex = if (nextHeaderLine == -1) { + // is last profile in file + lines.size + } else { + nextHeaderLine + profileHeaderLine + 1 + } + + // update contents between profileHeaderLine and nextHeaderLine + val profileLines = lines.subList(profileHeaderLine, endIndex).toMutableList() + profile.properties().forEach { key, value -> + val line = profileLines.indexOfLast { it.startsWith("$key=") } + if (line == -1) { + profileLines.add("$key=$value") + } else { + profileLines[line] = "$key=$value" + } + } + writeText((lines.subList(0, profileHeaderLine) + profileLines + lines.subList(endIndex, lines.size)).joinToString("\n")) + } + } + } + + override fun deleteSsoConnectionFromConfig(sessionName: String) { + val filePath = configPath + val lines = filePath.inputStreamIfExists()?.reader()?.readLines().orEmpty() + val ssoHeaderLine = lines.indexOfFirst { it.startsWith("[${SsoSessionConstants.SSO_SESSION_SECTION_NAME} $sessionName]") } + if (ssoHeaderLine == -1) return + val nextHeaderLine = lines.subList(ssoHeaderLine + 1, lines.size).indexOfFirst { it.startsWith("[") } + val endIndex = if (nextHeaderLine == -1) lines.size else ssoHeaderLine + nextHeaderLine + 1 + val updatedArray = lines.subList(0, ssoHeaderLine) + lines.subList(endIndex, lines.size) + val profileHeaderLine = getCorrespondingSsoSessionProfilePosition(updatedArray, sessionName) + filePath.writeText(profileHeaderLine.joinToString("\n")) + + val applicationManager = ApplicationManager.getApplication() + if (applicationManager != null && !applicationManager.isUnitTestMode) { + FileDocumentManager.getInstance().saveAllDocuments() + ProfileWatcher.getInstance().forceRefresh() + } + } + + private fun getCorrespondingSsoSessionProfilePosition(updatedArray: List, sessionName: String): List { + var content = updatedArray + val finalContent = mutableListOf() + while (content.size > 0) { + val sessionProfile = checkIfProfileIsPartOfSession(content, sessionName) + if (sessionProfile != null) { // There is atleast one profile with the prefix matching the session name + if (sessionProfile.shouldBeWrittenToConfig) { + finalContent.addAll(content.subList(0, sessionProfile.endIndex)) + } else { + finalContent.addAll(content.subList(0, sessionProfile.startIndex)) + } + content = content.subList(sessionProfile.endIndex, content.size) + } else { + finalContent.addAll(content) + break + } + } + return finalContent + } + + private fun checkIfProfileIsPartOfSession(content: List, sessionName: String): ProfileLimitsInConfig? { + val pos = content.indexOfFirst { it.startsWith("[profile") } + // if no matching profile section found + if (pos == -1) return null + + // if matching profile section found which is an sso-profile + val contentAfterProfileHeader = content.subList(pos + 1, content.size) + val checkIfProfileIsValid = isProfileSso(contentAfterProfileHeader, sessionName) + + return ProfileLimitsInConfig(pos, pos + checkIfProfileIsValid.endIndex + 1, shouldBeWrittenToConfig = !checkIfProfileIsValid.isProfileSso) + } + + private fun isProfileSso(configContent: List, sessionName: String): CurrentProfileLimitsInConfig { + val nextSectionHeaderIndex = configContent.indexOfFirst { it.startsWith("[") } + val endIndex = if (nextSectionHeaderIndex == -1) configContent.size else nextSectionHeaderIndex + val currentProfile = configContent.subList(0, endIndex) + currentProfile.forEach { + if (it.startsWith("sso_session")) { + return if (it.substringAfter("=").trim() == sessionName) { + CurrentProfileLimitsInConfig(isProfileSso = true, endIndex) + } else { + CurrentProfileLimitsInConfig( + isProfileSso = false, + endIndex + ) + } + } + } + return CurrentProfileLimitsInConfig(isProfileSso = false, endIndex) + } + + data class ProfileLimitsInConfig( + val startIndex: Int, + val endIndex: Int, + val shouldBeWrittenToConfig: Boolean = true + ) + + data class CurrentProfileLimitsInConfig( + val isProfileSso: Boolean, + val endIndex: Int = 0 + ) + + private fun appendSection(path: Path, sectionName: String, profile: Profile) { + val isConfigFile = path.fileName.toString() != "credentials" + if (sectionName == "sso-session" && !isConfigFile) { + error("sso-session is only allowed in 'config'") + } + + // "credentials" file doesn't have the "profile" prefix + // and "sso-session" is not allowed in the "config" file + // https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html#cli-configure-files-format + val sectionTitle = if (!isConfigFile || profile.name().trim() == "default") { + profile.name() + } else { + "$sectionName ${profile.name()}" + }.trim() + + val body = buildString { + appendLine() + appendLine("[$sectionTitle]") + profile.properties().forEach { k, v -> + appendLine("$k=$v") + } + } + + path.tryDirOp(LOG) { createParentDirectories() } + path.tryFileOp(LOG) { + touch(restrictToOwner = true) + appendText(body) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConfigureAwsConnectionAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConfigureAwsConnectionAction.kt new file mode 100644 index 0000000000..5438232e19 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConfigureAwsConnectionAction.kt @@ -0,0 +1,17 @@ +// Copyright 2021 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.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.resources.message + +class ConfigureAwsConnectionAction(private val mode: ChangeSettingsMode = ChangeSettingsMode.BOTH) : DumbAwareAction(message("configure.toolkit")) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(PlatformDataKeys.PROJECT) + val selector = ProjectLevelSettingSelector(project, mode) + selector.createPopup(e.dataContext).showCenteredInCurrentWindow(project) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettings.kt deleted file mode 100644 index b02084e7fa..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettings.kt +++ /dev/null @@ -1,9 +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 software.aws.toolkits.core.credentials.ToolkitCredentialsProvider -import software.aws.toolkits.core.region.AwsRegion - -data class ConnectionSettings(val credentials: ToolkitCredentialsProvider, val region: AwsRegion) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt new file mode 100644 index 0000000000..b727d4b0ae --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt @@ -0,0 +1,259 @@ +// Copyright 2021 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.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.DumbAwareAction +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.credentials.actions.SsoLogoutAction +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.resources.message + +class ConnectionSettingsMenuBuilder private constructor() { + private data class RegionSelectionSettings(val currentSelection: AwsRegion?, val onChange: (AwsRegion) -> Unit) + private data class ProfileSelectionSettings(val currentSelection: CredentialIdentifier?, val onChange: (CredentialIdentifier) -> Unit) + + private sealed interface IdentitySelectionSettings + private data class SelectableIdentitySelectionSettings( + val currentSelection: AwsBearerTokenConnection?, + val onChange: (AwsBearerTokenConnection) -> Unit + ) : IdentitySelectionSettings + private data class ActionsIdentitySelectionSettings(val project: Project?) : IdentitySelectionSettings + + private var regionSelectionSettings: RegionSelectionSettings? = null + private var profileSelectionSettings: ProfileSelectionSettings? = null + private var identitySelectionSettings: IdentitySelectionSettings? = null + private var accountSettingsManager: AwsConnectionManager? = null + + fun withRegions(currentSelection: AwsRegion?, onChange: (AwsRegion) -> Unit): ConnectionSettingsMenuBuilder = apply { + regionSelectionSettings = RegionSelectionSettings(currentSelection, onChange) + } + + fun withCredentials(currentSelection: CredentialIdentifier?, onChange: (CredentialIdentifier) -> Unit): ConnectionSettingsMenuBuilder = apply { + profileSelectionSettings = ProfileSelectionSettings(currentSelection, onChange) + } + + fun withRecentChoices(project: Project): ConnectionSettingsMenuBuilder = apply { + accountSettingsManager = AwsConnectionManager.getInstance(project) + } + + fun withIndividualIdentitySettings(project: Project) { + identitySelectionSettings = SelectableIdentitySelectionSettings( + currentSelection = ToolkitConnectionManager.getInstance(project).activeConnection() as? AwsBearerTokenConnection, + onChange = ToolkitConnectionManager.getInstance(project)::switchConnection + ) + } + + fun withIndividualIdentityActions(project: Project?) { + identitySelectionSettings = ActionsIdentitySelectionSettings(project) + } + + fun build(): DefaultActionGroup { + val topLevelGroup = DefaultActionGroup() + + identitySelectionSettings?.let { settings -> + val connections = ToolkitAuthManager.getInstance().listConnections().filterIsInstance() + if (connections.isEmpty()) { + return@let + } + + topLevelGroup.add(Separator.create(message("settings.credentials.individual_identity_sub_menu"))) + val actions = when (settings) { + is SelectableIdentitySelectionSettings -> { + connections.map { + object : DumbAwareToggleAction( + title = it.label, + value = it, + selected = it == settings.currentSelection, + onSelect = settings.onChange + ) { + override fun update(e: AnActionEvent) { + super.update(e) + if (value.lazyIsUnauthedBearerConnection()) { + e.presentation.icon = AllIcons.General.Warning + } + } + } + } + } + + is ActionsIdentitySelectionSettings -> { + connections.map { + IndividualIdentityActionGroup(it) + } + } + } + + topLevelGroup.addAll(actions) + + topLevelGroup.add(Separator.create()) + } + + val profileActions = createProfileActions() + val regionActions = createRegionActions() + + // no header if only regions + if (profileActions.isNotEmpty() && regionActions.isNotEmpty()) { + // both profiles & regions + topLevelGroup.add(Separator.create(message("settings.credentials.iam_and_regions"))) + } else if (profileActions.isNotEmpty() && regionActions.isEmpty()) { + // only profiles + topLevelGroup.add(Separator.create(message("settings.credentials.iam"))) + } + + val regionSettings = regionSelectionSettings + val recentRegions = accountSettingsManager?.recentlyUsedRegions() + if (recentRegions?.isNotEmpty() == true && regionSettings != null) { + recentRegions.forEach { + topLevelGroup.add(SwitchRegionAction(it, it == regionSettings.currentSelection, regionSettings.onChange)) + } + + val allRegionsGroup = DefaultActionGroup.createPopupGroup { message("settings.regions.region_sub_menu") } + allRegionsGroup.addAll(regionActions) + topLevelGroup.add(allRegionsGroup) + } else { + topLevelGroup.addAll(regionActions) + } + + topLevelGroup.add(Separator.create()) + + val credentialsSettings = profileSelectionSettings + val recentCredentials = accountSettingsManager?.recentlyUsedCredentials() + if (recentCredentials?.isNotEmpty() == true && credentialsSettings != null) { + recentCredentials.forEach { + topLevelGroup.add(SwitchCredentialsAction(it, it == credentialsSettings.currentSelection, credentialsSettings.onChange)) + } + + val allCredentialsGroup = DefaultActionGroup.createPopupGroup { message("settings.credentials.profile_sub_menu") } + allCredentialsGroup.addAll(profileActions) + topLevelGroup.add(allCredentialsGroup) + } else { + topLevelGroup.addAll(profileActions) + } + + return topLevelGroup + } + + private fun createRegionActions(): List = buildList { + val (currentSelection, onChange) = regionSelectionSettings ?: return@buildList + + val regionProvider = AwsRegionProvider.getInstance() + + val primaryRegions = currentSelection?.partitionId?.let { + regionProvider.regions(it).values + } ?: regionProvider.allRegions().values + + addAll(createRegionGroupActions(primaryRegions, currentSelection, onChange)) + + if (currentSelection != null && regionProvider.partitions().size > 1) { + val otherPartitionActionGroup = DefaultActionGroup.createPopupGroup { message("settings.partitions") } + val otherPartitions = regionProvider.partitions().values.filterNot { it.id == currentSelection.partitionId }.sortedBy { it.displayName } + otherPartitions.forEach { + val partitionGroup = DefaultActionGroup.createPopupGroup { it.displayName } + partitionGroup.addAll(createRegionGroupActions(it.regions, currentSelection = null, onChange)) + + otherPartitionActionGroup.add(partitionGroup) + } + + add(Separator.create()) + add(otherPartitionActionGroup) + } + } + + private fun createRegionGroupActions(regions: Collection, currentSelection: AwsRegion?, onChange: (AwsRegion) -> Unit) = buildList { + regions.groupBy { it.category } + .forEach { (category, categoryRegions) -> + add(Separator.create(category)) + categoryRegions.sortedBy { it.displayName } + .forEach { add(SwitchRegionAction(it, it == currentSelection, onChange)) } + } + } + + private fun createProfileActions(): List = buildList { + val (currentSelection, onChange) = profileSelectionSettings ?: return@buildList + + add(Separator.create(message("settings.credentials"))) + + val credentialManager = CredentialManager.getInstance() + credentialManager.getCredentialIdentifiers().forEach { + add(SwitchCredentialsAction(it, it == currentSelection, onChange)) + } + + add(Separator.create()) + add(ActionManager.getInstance().getAction("aws.settings.upsertCredentials")) + } + + // Helper actions, note: these are public to help make tests easier by leveraging instanceOf checks + + abstract inner class DumbAwareToggleAction( + title: String, + val value: T, + private val selected: Boolean, + private val onSelect: (T) -> Unit + ) : ToggleAction(title), DumbAware { + override fun isSelected(e: AnActionEvent): Boolean = selected + + override fun setSelected(e: AnActionEvent, state: Boolean) { + if (!isSelected(e)) { + onSelect.invoke(value) + } + } + } + + inner class SwitchRegionAction( + value: AwsRegion, + selected: Boolean, + onSelect: (AwsRegion) -> Unit + ) : DumbAwareToggleAction(value.displayName, value, selected, onSelect) + + inner class SwitchCredentialsAction( + value: CredentialIdentifier, + selected: Boolean, + onSelect: (CredentialIdentifier) -> Unit + ) : DumbAwareToggleAction(value.displayName, value, selected, onSelect) + + inner class IndividualIdentityActionGroup(private val value: AwsBearerTokenConnection) : + DefaultActionGroup( + { + val suffix = if (value.lazyIsUnauthedBearerConnection()) { + message("credentials.individual_identity.expired") + } else { + message("credentials.individual_identity.connected") + } + + "${value.label} $suffix" + }, + true + ) { + init { + templatePresentation.icon = if (value.lazyIsUnauthedBearerConnection()) AllIcons.General.Warning else null + + addAll( + object : DumbAwareAction(message("credentials.individual_identity.reconnect")) { + override fun actionPerformed(e: AnActionEvent) { + reauthConnectionIfNeeded(e.project, value) + + ToolkitConnectionManager.getInstance(e.project).switchConnection(value) + } + }, + + SsoLogoutAction(value) + ) + } + } + + companion object { + fun connectionSettingsMenuBuilder(): ConnectionSettingsMenuBuilder = ConnectionSettingsMenuBuilder() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenus.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenus.kt new file mode 100644 index 0000000000..996cacb4f4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenus.kt @@ -0,0 +1,254 @@ +// 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.ide.DataManager +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.ex.ComboBoxAction +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.ListPopup +import com.intellij.util.EventDispatcher +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.jetbrains.core.credentials.ChangeSettingsMode.BOTH +import software.aws.toolkits.jetbrains.core.credentials.ChangeSettingsMode.CREDENTIALS +import software.aws.toolkits.jetbrains.core.credentials.ChangeSettingsMode.NONE +import software.aws.toolkits.jetbrains.core.credentials.ChangeSettingsMode.REGIONS +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsMenuBuilder.Companion.connectionSettingsMenuBuilder +import software.aws.toolkits.jetbrains.ui.ActionPopupComboLogic +import software.aws.toolkits.resources.message +import javax.swing.JComponent +import javax.swing.event.ChangeEvent +import javax.swing.event.ChangeListener + +/** + * Determine what settings the settings selector is capable of changing + */ +enum class ChangeSettingsMode(val showRegions: Boolean, val showCredentials: Boolean) { + NONE(false, false), + CREDENTIALS(false, true), + REGIONS(true, false), + BOTH(true, true) +} + +/** + * Base logic for different ways to present connection settings in a consistent manner across the IDE + * + * @see ProjectLevelSettingSelector + * @see SettingsSelectorComboBoxAction + */ +abstract class SettingsSelectorLogicBase(private val menuMode: ChangeSettingsMode) : ActionPopupComboLogic { + private val listeners by lazy { + EventDispatcher.create(ChangeListener::class.java) + } + + override fun displayValue(): String = when (menuMode) { + CREDENTIALS -> credentialsDisplay() + REGIONS -> regionDisplay() + BOTH -> "${credentialsDisplay()}@${regionDisplay()}" + NONE -> "" + } + + override fun tooltip(): String? = when (menuMode) { + CREDENTIALS -> credentialsTooltip() + REGIONS -> regionTooltip() + NONE, BOTH -> null + } + + private fun regionDisplay() = currentRegion()?.id ?: message("settings.regions.none_selected") + private fun regionTooltip() = currentRegion()?.displayName + + protected abstract fun currentRegion(): AwsRegion? + protected open fun onRegionChange(region: AwsRegion) {} + + private fun credentialsDisplay() = currentCredentials()?.shortName ?: message("settings.credentials.none_selected") + private fun credentialsTooltip() = currentCredentials()?.displayName + + protected abstract fun currentCredentials(): CredentialIdentifier? + protected open fun onCredentialChange(identifier: CredentialIdentifier) {} + + fun selectionMenuActions(): DefaultActionGroup = connectionSettingsMenuBuilder().apply { + if (menuMode.showRegions) { + withRegions(currentRegion()) { + onRegionChange(it) + + listeners.multicaster.stateChanged(ChangeEvent(this)) + } + } + + if (menuMode.showCredentials) { + withCredentials(currentCredentials()) { + onCredentialChange(it) + + listeners.multicaster.stateChanged(ChangeEvent(this)) + } + } + + customizeSelectionMenu(this) + }.build() + + fun createPopup(context: DataContext): ListPopup = JBPopupFactory.getInstance().createActionGroupPopup( + message("settings.title"), + selectionMenuActions(), + context, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true + ) + + protected open fun customizeSelectionMenu(builder: ConnectionSettingsMenuBuilder) {} + + override fun addChangeListener(changeListener: ChangeListener) { + listeners.addListener(changeListener) + } + + override fun showPopup(sourceComponent: JComponent) { + createPopup(DataManager.getInstance().getDataContext(sourceComponent)).showUnderneathOf(sourceComponent) + } +} + +/** + * Version of a [SettingsSelectorLogicBase] that stores the settings locally to the instance of the selector. Typically, this would be used if the settings + * differ from the Project settings such as a UI panel. + */ +class LocalSettingsSelector(initialRegion: AwsRegion? = null, initialCredentialIdentifier: CredentialIdentifier? = null, settingsMode: ChangeSettingsMode) : + SettingsSelectorLogicBase(settingsMode) { + var currentRegion: AwsRegion? = initialRegion + set(value) { + if (field == value) return + field = value + value?.let { onRegionChange(it) } + } + var currentCredentials: CredentialIdentifier? = initialCredentialIdentifier + set(value) { + if (field == value) return + field = value + value?.let { onCredentialChange(it) } + } + + override fun currentRegion(): AwsRegion? = currentRegion + + override fun onRegionChange(region: AwsRegion) { + currentRegion = region + } + + override fun currentCredentials(): CredentialIdentifier? = currentCredentials + + override fun onCredentialChange(identifier: CredentialIdentifier) { + currentCredentials = identifier + } +} + +/** + * Version of a [SettingsSelectorLogicBase] that stores the settings at the project level. + */ +open class ProjectLevelSettingSelector(private val project: Project, settingsMode: ChangeSettingsMode) : SettingsSelectorLogicBase(settingsMode) { + override fun currentRegion(): AwsRegion? = AwsConnectionManager.getInstance(project).selectedRegion + + override fun onRegionChange(region: AwsRegion) { + AwsConnectionManager.getInstance(project).changeRegion(region) + } + + override fun currentCredentials(): CredentialIdentifier? = AwsConnectionManager.getInstance(project).selectedCredentialIdentifier + + override fun onCredentialChange(identifier: CredentialIdentifier) { + AwsConnectionManager.getInstance(project).changeCredentialProvider(identifier) + } + + override fun customizeSelectionMenu(builder: ConnectionSettingsMenuBuilder) { + builder.withRecentChoices(project) + builder.withIndividualIdentityActions(project) + } +} + +class ToolkitConnectionComboBoxAction(private val project: Project) : ComboBoxAction(), DumbAware { + private val logic = object : ProjectLevelSettingSelector(project, CREDENTIALS) { + override fun currentCredentials(): CredentialIdentifier? { + val active = ToolkitConnectionManager.getInstance(project).activeConnection() + if (active is AwsConnectionManagerConnection) { + return super.currentCredentials() + } + + return null + } + + override fun onCredentialChange(identifier: CredentialIdentifier) { + super.onCredentialChange(identifier) + val connectionManager = ToolkitConnectionManager.getInstance(project) + connectionManager.switchConnection(AwsConnectionManagerConnection(project)) + } + + override fun customizeSelectionMenu(builder: ConnectionSettingsMenuBuilder) { + super.customizeSelectionMenu(builder) + builder.withIndividualIdentitySettings(project) + } + } + + override fun createPopupActionGroup(button: JComponent?) = logic.selectionMenuActions() + + override fun update(e: AnActionEvent) { + val active = ToolkitConnectionManager.getInstance(project).activeConnection() + if (active is AwsConnectionManagerConnection) { + e.presentation.text = logic.displayValue() + e.presentation.description = logic.tooltip() + } else { + e.presentation.text = active?.label?.let { + "Connected with $it" + } ?: message("settings.credentials.none_selected") + + e.presentation.description = null + } + } +} + +class SettingsSelectorComboBoxAction(private val selectorLogic: SettingsSelectorLogicBase) : ComboBoxAction(), DumbAware { + override fun createPopupActionGroup(button: JComponent?) = selectorLogic.selectionMenuActions() + + override fun update(e: AnActionEvent) { + updatePresentation(e.presentation) + } + + override fun displayTextInToolbar(): Boolean = true + + private fun updatePresentation(presentation: Presentation) { + presentation.text = selectorLogic.displayValue() + presentation.description = selectorLogic.tooltip() + } +} + +class CredsComboBoxActionGroup(private val project: Project) : DefaultActionGroup() { + private val toolkitConnectionAction = ToolkitConnectionComboBoxAction(project) + private val profileRegionSelectorGroup: Array by lazy { + arrayOf( + toolkitConnectionAction, + SettingsSelectorComboBoxAction(ProjectLevelSettingSelector(project, ChangeSettingsMode.REGIONS)) + ) + } + + private val ssoSelectorGroup: Array by lazy { + arrayOf( + toolkitConnectionAction + ) + } + + override fun getChildren(e: AnActionEvent?): Array { + val activeConnection = ToolkitConnectionManager.getInstance(project).activeConnection() + + return if (activeConnection is AwsBearerTokenConnection) { + ssoSelectorGroup + } else if (activeConnection == null) { + arrayOf( + ActionManager.getInstance().getAction("aws.toolkit.toolwindow.explorer.newConnection") + ) + } else { + profileRegionSelectorGroup + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CorrectThreadCredentialsProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CorrectThreadCredentialsProvider.kt deleted file mode 100644 index 9d8b6f98e4..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CorrectThreadCredentialsProvider.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.core.credentials - -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.util.ThrowableComputable -import software.amazon.awssdk.auth.credentials.AwsCredentials -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider -import software.aws.toolkits.resources.message - -/** - * Offloads fetching credentials to a background task and a modal progress bar if the current thread is EDT - */ -class CorrectThreadCredentialsProvider(private val delegate: AwsCredentialsProvider) : AwsCredentialsProvider { - override fun resolveCredentials(): AwsCredentials = if (ApplicationManager.getApplication().isDispatchThread) { - ProgressManager.getInstance().runProcessWithProgressSynchronously( - ThrowableComputable { - delegate.resolveCredentials() - }, - message("credentials.retrieving"), - /* canBeCancelled */false, - /* project */null - ) - } else { - delegate.resolveCredentials() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CreateOrUpdateCredentialProfilesAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CreateOrUpdateCredentialProfilesAction.kt new file mode 100644 index 0000000000..9e20ad5734 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CreateOrUpdateCredentialProfilesAction.kt @@ -0,0 +1,97 @@ +// Copyright 2023 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.actionSystem.PlatformDataKeys +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.fileTypes.FileTypes +import com.intellij.openapi.fileTypes.ex.FileTypeManagerEx +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.vfs.LocalFileSystem +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.profiles.ProfileFileLocation +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry +import java.nio.file.Path + +class CreateOrUpdateCredentialProfilesAction @TestOnly constructor( + private val writer: ConfigFilesFacade +) : AnAction(message("configure.toolkit.upsert_credentials.action")), DumbAware { + @Suppress("unused") + constructor() : this( + DefaultConfigFilesFacade( + configPath = ProfileFileLocation.configurationFilePath(), + credentialsPath = ProfileFileLocation.credentialsFilePath() + ) + ) + + private val localFileSystem = LocalFileSystem.getInstance() + + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(PlatformDataKeys.PROJECT) + + // if both config and credential files do not exist, create a new config file + if (!writer.configPath.exists() && !writer.credentialsPath.exists()) { + if (confirm(project, writer.configPath)) { + try { + writer.createConfigFile() + } finally { + AwsTelemetry.createCredentials(project) + } + } else { + return + } + } + + // open both config and credential files, if they exist + // credential file is opened last since it takes precedence over the config file + val virtualFiles = listOf(writer.configPath.toFile(), writer.credentialsPath.toFile()).filter { it.exists() }.map { + localFileSystem.refreshAndFindFileByIoFile(it) ?: throw RuntimeException( + message( + "credentials.could_not_open", + it + ) + ) + } + + val fileEditorManager = FileEditorManager.getInstance(project) + + localFileSystem.refreshFiles(virtualFiles, false, false) { + virtualFiles.forEach { + if (it.fileType == FileTypes.UNKNOWN) { + ApplicationManager.getApplication().runWriteAction { + FileTypeManagerEx.getInstanceEx().associatePattern( + FileTypes.PLAIN_TEXT, + it.name + ) + } + } + + if (fileEditorManager.openTextEditor(OpenFileDescriptor(project, it), true) == null) { + AwsTelemetry.openCredentials(project, success = false) + throw RuntimeException(message("credentials.could_not_open", it)) + } + AwsTelemetry.openCredentials(project, success = true) + } + } + } + + private fun confirm(project: Project, file: Path): Boolean = Messages.showOkCancelDialog( + project, + message("configure.toolkit.upsert_credentials.confirm_file_create", file), + message("configure.toolkit.upsert_credentials.confirm_file_create.title"), + message("configure.toolkit.upsert_credentials.confirm_file_create.okay"), + Messages.getCancelButton(), + AllIcons.General.QuestionDialog, + null + ) == Messages.OK +} 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 1757d3b95c..4a802da3ce 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,39 +3,49 @@ 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.components.service import com.intellij.openapi.extensions.ExtensionPointName 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.SsoSessionBackedCredentialIdentifier +import software.aws.toolkits.core.credentials.SsoSessionIdentifier 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.tryOrNull -import software.aws.toolkits.jetbrains.core.AwsSdkClient +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.utils.runUnderProgressIfNeeded +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger abstract class CredentialManager : SimpleModificationTracker() { private val providerIds = ConcurrentHashMap() - private val awsCredentialProviderCache = ConcurrentHashMap>() + private val ssoSessionIds = ConcurrentHashMap() + private val awsCredentialProviderCache = ConcurrentHashMap>() protected abstract fun factoryMapping(): Map @Throws(CredentialProviderNotFoundException::class) fun getAwsCredentialProvider(providerId: CredentialIdentifier, region: AwsRegion): ToolkitCredentialsProvider = - ToolkitCredentialsProvider(providerId, AwsCredentialProviderProxy(providerId, region)) + ToolkitCredentialsProvider(providerId, AwsCredentialProviderProxy(providerId.id, region)) fun getCredentialIdentifiers(): List = providerIds.values .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) + fun getSsoSessionIdentifiers(): List = ssoSessionIds.values + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.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 @@ -47,39 +57,74 @@ abstract class CredentialManager : SimpleModificationTracker() { } protected fun modifyProvider(identifier: CredentialIdentifier) { - awsCredentialProviderCache.remove(identifier) + awsCredentialProviderCache.remove(identifier.id) + providerIds[identifier.id] = identifier incModificationCount() ApplicationManager.getApplication().messageBus.syncPublisher(CREDENTIALS_CHANGED).providerModified(identifier) } + protected fun modifyDependentProviders(providerId: String) { + providerIds.values.forEach { + if (it is SsoSessionBackedCredentialIdentifier && it.sessionIdentifier == providerId) { + modifyProvider(it) + } + } + } + protected fun removeProvider(identifier: CredentialIdentifier) { providerIds.remove(identifier.id) - awsCredentialProviderCache.remove(identifier) + awsCredentialProviderCache.remove(identifier.id) incModificationCount() ApplicationManager.getApplication().messageBus.syncPublisher(CREDENTIALS_CHANGED).providerRemoved(identifier) } + protected fun addSsoSession(identifier: SsoSessionIdentifier) { + ssoSessionIds[identifier.id] = identifier + + incModificationCount() + ApplicationManager.getApplication().messageBus.syncPublisher(CREDENTIALS_CHANGED).ssoSessionAdded(identifier) + } + + protected fun modifySsoSession(identifier: SsoSessionIdentifier) { + ssoSessionIds[identifier.id] = identifier + + incModificationCount() + ApplicationManager.getApplication().messageBus.syncPublisher(CREDENTIALS_CHANGED).ssoSessionModified(identifier) + } + + protected fun removeSsoSession(identifier: SsoSessionIdentifier) { + ssoSessionIds.remove(identifier.id) + + incModificationCount() + ApplicationManager.getApplication().messageBus.syncPublisher(CREDENTIALS_CHANGED).ssoSessionRemoved(identifier) + } + /** * Inner class that lazy-ily requests the true AwsCredentialsProvider from the factory when needed. This acts as a middle man that allows for existing * 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: CredentialIdentifier, private val region: AwsRegion) : - AwsCredentialsProvider { - override fun resolveCredentials(): AwsCredentials = getOrCreateAwsCredentialsProvider(providerId, region).resolveCredentials() + private inner class AwsCredentialProviderProxy(private val providerId: String, private val region: AwsRegion) : AwsCredentialsProvider { + override fun resolveCredentials(): AwsCredentials = runUnderProgressIfNeeded(null, message("credentials.retrieving"), cancelable = true) { + getOrCreateAwsCredentialsProvider(providerId, region).resolveCredentials() + } + + private fun getOrCreateAwsCredentialsProvider(providerId: String, region: AwsRegion): AwsCredentialsProvider { + // Validate that the provider ID is still valid and get the latest copy + val identifier = providerIds[providerId] + ?: throw CredentialProviderNotFoundException("Provider ID $providerId was removed, can't resolve credentials") - 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 + // If we already resolved creds for this partition and provider ID, just return it, else compute new one return partitionCache.computeIfAbsent(region.partitionId) { - val providerFactory = factoryMapping()[providerId.factoryId] - ?: throw CredentialProviderNotFoundException("No provider found with ID ${providerId.id}") + val providerFactory = factoryMapping()[identifier.factoryId] + ?: throw CredentialProviderNotFoundException("No provider factory found with ID ${identifier.factoryId}") try { - providerFactory.createAwsCredentialProvider(providerId, region) { AwsSdkClient.getInstance().sdkHttpClient } + providerFactory.createAwsCredentialProvider(identifier, region) } catch (e: Exception) { throw CredentialProviderNotFoundException("Failed to create underlying AwsCredentialProvider", e) } @@ -89,7 +134,7 @@ abstract class CredentialManager : SimpleModificationTracker() { companion object { @JvmStatic - fun getInstance(): CredentialManager = ServiceManager.getService(CredentialManager::class.java) + fun getInstance(): CredentialManager = service() /*** * [MessageBus] topic for when credential providers get added/changed/deleted @@ -101,19 +146,20 @@ abstract class CredentialManager : SimpleModificationTracker() { } } -class DefaultCredentialManager : CredentialManager() { - private val extensionMap: Map by lazy { - EP_NAME.extensionList.associateBy { - it.id - } - } +class DefaultCredentialManager : CredentialManager(), Disposable { + private val extensionMap: Map + get() = EP_NAME.extensionList.associateBy { + it.id + } init { extensionMap.values.forEach { providerFactory -> + val count = AtomicInteger(0) LOG.tryOrNull("Failed to set up $providerFactory") { providerFactory.setUp { change -> change.added.forEach { addProvider(it) + count.incrementAndGet() } change.modified.forEach { @@ -122,12 +168,46 @@ class DefaultCredentialManager : CredentialManager() { change.removed.forEach { removeProvider(it) + count.decrementAndGet() + } + + change.ssoAdded.forEach { + addSsoSession(it) + } + + change.ssoModified.forEach { + modifySsoSession(it) } + + change.ssoRemoved.forEach { + removeSsoSession(it) + } + + AwsTelemetry.loadCredentials( + credentialSourceId = providerFactory.credentialSourceId.toTelemetryCredentialSourceId(), + value = count.get().toDouble() + ) } } } + + // sync bearer changes back to any profiles with a dependency + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun onChange(providerId: String) { + modifyDependentProviders(providerId) + } + + override fun invalidate(providerId: String) { + modifyDependentProviders(providerId) + } + } + ) } + override fun dispose() {} + override fun factoryMapping(): Map = extensionMap companion object { @@ -135,18 +215,3 @@ class DefaultCredentialManager : CredentialManager() { private val LOG = getLogger() } } - -fun AwsCredentials.toEnvironmentVariables(): Map { - val map = mutableMapOf() - map["AWS_ACCESS_KEY"] = this.accessKeyId() - map["AWS_ACCESS_KEY_ID"] = this.accessKeyId() - map["AWS_SECRET_KEY"] = this.secretAccessKey() - map["AWS_SECRET_ACCESS_KEY"] = this.secretAccessKey() - - if (this is AwsSessionCredentials) { - map["AWS_SESSION_TOKEN"] = this.sessionToken() - map["AWS_SECURITY_TOKEN"] = this.sessionToken() - } - - return map -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialSourceId.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialSourceId.kt new file mode 100644 index 0000000000..bfcf29523b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialSourceId.kt @@ -0,0 +1,16 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import software.aws.toolkits.core.credentials.CredentialSourceId +import software.aws.toolkits.telemetry.CredentialSourceId as TelemetryCredentialSourceId + +fun CredentialSourceId?.toTelemetryCredentialSourceId(): TelemetryCredentialSourceId = when (this) { + CredentialSourceId.SharedCredentials -> TelemetryCredentialSourceId.SharedCredentials + CredentialSourceId.SdkStore -> TelemetryCredentialSourceId.SdkStore + CredentialSourceId.Ec2 -> TelemetryCredentialSourceId.Ec2 + CredentialSourceId.Ecs -> TelemetryCredentialSourceId.Ecs + CredentialSourceId.EnvVars -> TelemetryCredentialSourceId.EnvVars + else -> TelemetryCredentialSourceId.Other +} 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 a89a089d83..6bc8f568a1 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 @@ -3,7 +3,6 @@ package software.aws.toolkits.jetbrains.core.credentials -import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.project.Project import software.aws.toolkits.jetbrains.utils.createNotificationExpiringAction import software.aws.toolkits.jetbrains.utils.createShowMoreInfoDialogAction @@ -11,7 +10,6 @@ import software.aws.toolkits.jetbrains.utils.notifyWarn import software.aws.toolkits.resources.message 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") @@ -28,8 +26,7 @@ class CredentialStatusNotification(private val project: Project) : ConnectionSet message, newState.displayMessage ), - createNotificationExpiringAction(actionManager.getAction("aws.settings.upsertCredentials")), - createNotificationExpiringAction(RefreshConnectionAction(message("settings.retry"))) + *newState.actions.map { createNotificationExpiringAction(it) }.toTypedArray() ) ) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialTypeUtil.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialTypeUtil.kt new file mode 100644 index 0000000000..3fb1e15b98 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialTypeUtil.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.jetbrains.core.credentials + +import software.aws.toolkits.core.credentials.CredentialType +import software.aws.toolkits.telemetry.CredentialType as TelemetryCredentialType + +fun CredentialType?.toTelemetryType(): TelemetryCredentialType = when (this) { + CredentialType.StaticProfile -> TelemetryCredentialType.StaticProfile + CredentialType.StaticSessionProfile -> TelemetryCredentialType.StaticSessionProfile + CredentialType.CredentialProcessProfile -> TelemetryCredentialType.CredentialProcessProfile + CredentialType.AssumeRoleProfile -> TelemetryCredentialType.AssumeRoleProfile + CredentialType.AssumeMfaRoleProfile -> TelemetryCredentialType.AssumeMfaRoleProfile + CredentialType.SsoProfile -> TelemetryCredentialType.SsoProfile + CredentialType.Ec2Metadata -> TelemetryCredentialType.Ec2Metadata + CredentialType.EcsMetadata -> TelemetryCredentialType.EcsMetatdata + null -> TelemetryCredentialType.Other +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialWriter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialWriter.kt deleted file mode 100644 index b446d18f06..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialWriter.kt +++ /dev/null @@ -1,145 +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.icons.AllIcons -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.PlatformDataKeys -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.openapi.fileEditor.OpenFileDescriptor -import com.intellij.openapi.fileTypes.FileTypes -import com.intellij.openapi.fileTypes.ex.FileTypeManagerEx -import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.Messages -import com.intellij.openapi.vfs.LocalFileSystem -import org.jetbrains.annotations.TestOnly -import software.amazon.awssdk.profiles.ProfileFileLocation -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.AwsTelemetry -import java.io.File - -class CreateOrUpdateCredentialProfilesAction @TestOnly constructor( - private val writer: ConfigFileWriter, - private val configFile: File, - private val credentialsFile: File -) : AnAction(message("configure.toolkit.upsert_credentials.action")), DumbAware { - @Suppress("unused") - constructor() : this( - DefaultConfigFileWriter, - ProfileFileLocation.configurationFilePath().toFile(), - ProfileFileLocation.credentialsFilePath().toFile() - ) - - private val localFileSystem = LocalFileSystem.getInstance() - - override fun actionPerformed(e: AnActionEvent) { - val project = e.getRequiredData(PlatformDataKeys.PROJECT) - - // if both config and credential files do not exist, create a new config file - if (!configFile.exists() && !credentialsFile.exists()) { - if (confirm(project, configFile)) { - writer.createFile(configFile) - } else { - return - } - } - - // open both config and credential files, if they exist - // credential file is opened last since it takes precedence over the config file - val virtualFiles = listOf(configFile, credentialsFile).filter { it.exists() }.map { - localFileSystem.refreshAndFindFileByIoFile(it) ?: throw RuntimeException(message("credentials.could_not_open", it)) - } - - val fileEditorManager = FileEditorManager.getInstance(project) - - localFileSystem.refreshFiles(virtualFiles, false, false) { - virtualFiles.forEach { - if (it.fileType == FileTypes.UNKNOWN) { - ApplicationManager.getApplication().runWriteAction { - FileTypeManagerEx.getInstanceEx().associatePattern( - FileTypes.PLAIN_TEXT, - it.name - ) - } - } - - if (fileEditorManager.openTextEditor(OpenFileDescriptor(project, it), true) == null) { - AwsTelemetry.openCredentials(project, success = false) - throw RuntimeException(message("credentials.could_not_open", it)) - } - AwsTelemetry.openCredentials(project, success = true) - } - } - } - - private fun confirm(project: Project, file: File): Boolean = Messages.showOkCancelDialog( - project, - message("configure.toolkit.upsert_credentials.confirm_file_create", file), - message("configure.toolkit.upsert_credentials.confirm_file_create.title"), - message("configure.toolkit.upsert_credentials.confirm_file_create.okay"), - Messages.CANCEL_BUTTON, - AllIcons.General.QuestionDialog, - null - ) == Messages.OK -} - -interface ConfigFileWriter { - fun createFile(file: File) -} - -object DefaultConfigFileWriter : ConfigFileWriter { - val TEMPLATE = """ - # Amazon Web Services Config File used by AWS CLI, SDKs, and tools - # This file was created by the AWS Toolkit for JetBrains plugin. - # - # Your AWS credentials are represented by access keys associated with IAM users. - # For information about how to create and manage AWS access keys for a user, see: - # https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html - # - # This config file can store multiple access keys by placing each one in a - # named "profile". For information about how to change the access keys in a - # profile or to add a new profile with a different access key, see: - # https://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html - # - # If both a credential and config file exists, the values in the credential file - # take precedence - - [default] - # The access key and secret key pair identify your account and grant access to AWS. - aws_access_key_id = [accessKey] - # Treat your secret key like a password. Never share your secret key with anyone. Do - # not post it in online forums, or store it in a source control system. If your secret - # key is ever disclosed, immediately use IAM to delete the access key and secret key - # and create a new key pair. Then, update this file with the replacement key details. - aws_secret_access_key = [secretKey] - - # [profile user1] - # aws_access_key_id = [accessKey1] - # aws_secret_access_key = [secretKey1] - """.trimIndent() - - override fun createFile(file: File) { - val parent = file.parentFile - if (parent.mkdirs()) { - parent.setReadable(false, false) - parent.setReadable(true) - parent.setWritable(false, false) - parent.setWritable(true) - parent.setExecutable(false, false) - parent.setExecutable(true) - } - - file.writeText(TEMPLATE) - - file.setReadable(false, false) - file.setReadable(true) - file.setWritable(false, false) - file.setWritable(true) - file.setExecutable(false, false) - file.setExecutable(false) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialsFileHelpNotificationProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialsFileHelpNotificationProvider.kt new file mode 100644 index 0000000000..9545e93e5e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialsFileHelpNotificationProvider.kt @@ -0,0 +1,60 @@ +// Copyright 2021 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.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.help.HelpManager +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.EditorNotifications +import software.amazon.awssdk.profiles.ProfileFileLocation +import software.aws.toolkits.jetbrains.core.credentials.CredentialsFileHelpNotificationProvider.CredentialFileNotificationPanel +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry + +class CredentialsFileHelpNotificationProvider : EditorNotifications.Provider(), DumbAware { + override fun getKey(): Key = KEY + + override fun createNotificationPanel(file: VirtualFile, fileEditor: FileEditor, project: Project): CredentialFileNotificationPanel? { + // Check if editor is for the config/credential file + if (!isCredentialsFile(file)) return null + + return CredentialFileNotificationPanel(project) + } + + private fun isCredentialsFile(file: VirtualFile): Boolean = try { + val filePath = file.toNioPath().toAbsolutePath() + ProfileFileLocation.configurationFilePath().toAbsolutePath() == filePath || ProfileFileLocation.credentialsFilePath().toAbsolutePath() == filePath + } catch (e: Exception) { + false + } + + class CredentialFileNotificationPanel(project: Project) : EditorNotificationPanel() { + init { + createActionLabel(message("general.save")) { + FileDocumentManager.getInstance().saveAllDocuments() + AwsTelemetry.saveCredentials(project = project) + } + + createActionLabel(message("general.help")) { + HelpManager.getInstance().invokeHelp(HelpIds.SETUP_CREDENTIALS.id) + AwsTelemetry.help(project = project, name = HelpIds.SETUP_CREDENTIALS.id) + } + + text(message("credentials.file.notification")) + } + } + + companion object { + /** + * Key used to store the notification panel in an editor + */ + val KEY = Key.create("software.aws.toolkits.jetbrains.core.credentials.editor.notification.provider") + } +} 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 index 8b31aca35a..dd28f5bc93 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandler.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandler.kt @@ -4,7 +4,7 @@ package software.aws.toolkits.jetbrains.core.credentials import com.intellij.notification.NotificationAction -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import software.aws.toolkits.core.credentials.CredentialIdentifier import software.aws.toolkits.core.region.AwsRegion @@ -21,18 +21,18 @@ interface CredentialsRegionHandler { fun determineSelectedRegion(identifier: CredentialIdentifier, selectedRegion: AwsRegion?): AwsRegion? companion object { - fun getInstance(project: Project): CredentialsRegionHandler = ServiceManager.getService(project, CredentialsRegionHandler::class.java) + fun getInstance(project: Project): CredentialsRegionHandler = project.service() } } -internal open class DefaultCredentialsRegionHandler(private val project: Project) : CredentialsRegionHandler { - private val regionProvider by lazy { AwsRegionProvider.getInstance() } - private val settings by lazy { AwsSettings.getInstance() } - +internal class DefaultCredentialsRegionHandler(private val project: Project) : CredentialsRegionHandler { override fun determineSelectedRegion(identifier: CredentialIdentifier, selectedRegion: AwsRegion?): AwsRegion? { + val settings = AwsSettings.getInstance() if (settings.useDefaultCredentialRegion == UseAwsCredentialRegion.Never) { return selectedRegion } + + val regionProvider = AwsRegionProvider.getInstance() val defaultCredentialRegion = identifier.defaultRegionId?.let { regionProvider[it] } ?: return selectedRegion when { selectedRegion == defaultCredentialRegion -> return defaultCredentialRegion @@ -49,15 +49,15 @@ internal open class DefaultCredentialsRegionHandler(private val project: Project 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.createSimpleExpiring(message("settings.credentials.prompt_for_default_region_switch.yes")) { + AwsConnectionManager.getInstance(project).changeRegion(defaultCredentialRegion) }, - NotificationAction.create(message("settings.credentials.prompt_for_default_region_switch.always")) { event, _ -> - settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Always - ChangeRegionAction(defaultCredentialRegion).actionPerformed(event) + NotificationAction.createSimpleExpiring(message("settings.credentials.prompt_for_default_region_switch.always")) { + AwsSettings.getInstance().useDefaultCredentialRegion = UseAwsCredentialRegion.Always + AwsConnectionManager.getInstance(project).changeRegion(defaultCredentialRegion) }, - NotificationAction.createSimple(message("settings.credentials.prompt_for_default_region_switch.never")) { - settings.useDefaultCredentialRegion = UseAwsCredentialRegion.Never + NotificationAction.createSimpleExpiring(message("settings.credentials.prompt_for_default_region_switch.never")) { + AwsSettings.getInstance().useDefaultCredentialRegion = UseAwsCredentialRegion.Never } ) ) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManager.kt index 6322ab1ab2..64012ceb7b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManager.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManager.kt @@ -7,10 +7,9 @@ import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.openapi.project.Project -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope import software.aws.toolkits.jetbrains.core.credentials.profiles.DEFAULT_PROFILE_ID import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider @@ -22,8 +21,11 @@ data class ConnectionSettingsState( ) @State(name = "accountSettings", storages = [Storage("aws.xml")]) -class DefaultAwsConnectionManager(private val project: Project) : AwsConnectionManager(project), +class DefaultAwsConnectionManager(project: Project) : + AwsConnectionManager(project), PersistentStateComponent { + private val coroutineScope = disposableCoroutineScope(this) + override fun getState(): ConnectionSettingsState = ConnectionSettingsState( activeProfile = selectedCredentialIdentifier?.id, activeRegion = selectedRegion?.id, @@ -46,8 +48,8 @@ class DefaultAwsConnectionManager(private val project: Project) : AwsConnectionM state.recentlyUsedProfiles.reversed() .forEach { recentlyUsedProfiles.add(it) } - // Load all the initial state on BG thread, so e don't block the UI or loading of other components - GlobalScope.launch(Dispatchers.Default) { + // Load all the initial state on BG thread, so we don't block the UI or loading of other components + coroutineScope.launch { val credentialId = state.activeProfile ?: DEFAULT_PROFILE_ID val credentials = tryOrNull { CredentialManager.getInstance().getCredentialIdentifierById(credentialId) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManager.kt new file mode 100644 index 0000000000..863754ee3c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManager.kt @@ -0,0 +1,237 @@ +// Copyright 2022 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.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.util.Disposer +import software.aws.toolkits.core.credentials.SsoSessionIdentifier +import software.aws.toolkits.core.credentials.ToolkitCredentialsChangeListener +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileSsoSessionIdentifier +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener + +// TODO: unify with CredentialManager +@State(name = "authManager", storages = [Storage("aws.xml")]) +class DefaultToolkitAuthManager : ToolkitAuthManager, PersistentStateComponent, Disposable { + private var state = ToolkitAuthManagerState() + private val connections = linkedSetOf() + private val transientConnections = let { + val factoryConnections = mutableListOf() + ToolkitStartupAuthFactory.EP_NAME.forEachExtensionSafe { factory -> + factoryConnections.addAll( + factory.buildConnections().also { connections -> + LOG.info { "Found transient connections from $factory: ${connections.map { it.toString() }}" } + } + ) + } + + factoryConnections.toMutableSet() + } + + init { + // initial load then subscribe to bus for future changes + CredentialManager.getInstance().getSsoSessionIdentifiers().forEach { + createConnectionFromIdentifier(it) + } + + ApplicationManager.getApplication().messageBus + .connect(this) + .subscribe( + CredentialManager.CREDENTIALS_CHANGED, + object : ToolkitCredentialsChangeListener { + override fun ssoSessionAdded(identifier: SsoSessionIdentifier) { + createConnectionFromIdentifier(identifier) + } + + override fun ssoSessionModified(identifier: SsoSessionIdentifier) { + transientConnections.removeAll { connection -> + (connection.id == identifier.id).also { + if (it && connection is Disposable) { + // don't invalidate because we kill the token we just retrieved + ApplicationManager.getApplication().messageBus.syncPublisher(BearerTokenProviderListener.TOPIC) + .onChange(connection.id) + Disposer.dispose(connection) + } + } + } + + ssoSessionAdded(identifier) + } + + override fun ssoSessionRemoved(identifier: SsoSessionIdentifier) { + transientConnections.removeAll { connection -> + (connection.id == identifier.id).also { + if (it && connection is Disposable) { + disposeAndNotify(connection) + } + } + } + } + } + ) + } + + override fun listConnections(): List = connections.toList() + transientConnections + + override fun tryCreateTransientSsoConnection(profile: AuthProfile, callback: (BearerSsoConnection) -> Unit): BearerSsoConnection { + val connection = (connectionFromProfile(profile) as BearerSsoConnection).also { + callback(it) + transientConnections.add(it) + } + + return connection + } + + override fun getOrCreateSsoConnection(profile: UserConfigSsoSessionProfile): BearerSsoConnection { + (transientConnections.firstOrNull { it.id == profile.id } as? BearerSsoConnection)?.let { + return it + } + + val connection = connectionFromProfile(profile) as BearerSsoConnection + transientConnections.add(connection) + + return connection + } + + private fun createConnectionFromIdentifier(identifier: SsoSessionIdentifier) { + (identifier as? ProfileSsoSessionIdentifier)?.let { + getOrCreateSsoConnection( + UserConfigSsoSessionProfile( + configSessionName = it.profileName, + ssoRegion = it.ssoRegion, + startUrl = it.startUrl, + scopes = it.scopes.toList() + ) + ) + } + } + + override fun createConnection(profile: AuthProfile): ToolkitConnection { + val connection = connectionFromProfile(profile) + connections.firstOrNull { it.id == connection.id }?.let { + LOG.warn { "$it already exists in connection list" } + if (connection is Disposable) { + Disposer.dispose(connection) + } + + return it + } + + connections.add(connection) + return connection + } + + private fun deleteConnection(predicate: (ToolkitConnection) -> Boolean) { + connections.removeAll { connection -> + predicate(connection).also { + if (it && connection is Disposable) { + disposeAndNotify(connection) + } + } + } + } + + private fun disposeAndNotify(connection: T) where T : ToolkitConnection, T : Disposable { + ApplicationManager.getApplication().messageBus.syncPublisher(BearerTokenProviderListener.TOPIC) + .invalidate(connection.id) + Disposer.dispose(connection) + } + + override fun deleteConnection(connection: ToolkitConnection) { + deleteConnection { it == connection } + } + + override fun deleteConnection(connectionId: String) { + deleteConnection { it.id == connectionId } + } + + override fun getConnection(connectionId: String) = listConnections().firstOrNull { it.id == connectionId } + + override fun getState(): ToolkitAuthManagerState? { + val data = connections.mapNotNull { + when (it) { + is ManagedBearerSsoConnection -> { + ManagedSsoProfile( + startUrl = it.startUrl, + ssoRegion = it.region, + scopes = it.scopes + ) + } + + else -> { + LOG.error { "Couldn't serialize $it" } + null + } + } + } + + state.ssoProfiles = data + + return state + } + + override fun loadState(state: ToolkitAuthManagerState) { + this.state = state + val newConnections = linkedSetOf(*state.ssoProfiles.toTypedArray()).filterNotNull().map { + connectionFromProfile(it) + } + + if (newConnections.size != state.ssoProfiles.size) { + LOG.warn { "Persisted state had duplicate profiles" } + } + + connections.clear() + connections.addAll(newConnections) + } + + override fun dispose() { + listConnections().forEach { + if (it is Disposable) { + Disposer.dispose(it) + } + } + } + + private fun connectionFromProfile(profile: AuthProfile): ToolkitConnection = when (profile) { + is ManagedSsoProfile -> { + LegacyManagedBearerSsoConnection( + startUrl = profile.startUrl, + region = profile.ssoRegion, + scopes = profile.scopes + ) + } + + is UserConfigSsoSessionProfile -> { + ProfileSsoManagedBearerSsoConnection( + startUrl = profile.startUrl, + region = profile.ssoRegion, + scopes = profile.scopes, + id = profile.id, + configSessionName = profile.configSessionName + ) + } + + is DetectedDiskSsoSessionProfile -> DetectedDiskSsoSessionConnection( + sessionProfileName = profile.profileName, + startUrl = profile.startUrl, + region = profile.ssoRegion + ) + } + + companion object { + private val LOG = getLogger() + } +} + +data class ToolkitAuthManagerState( + // TODO: can't figure out how to make deserializer work with polymorphic types + var ssoProfiles: List = emptyList() +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitConnectionManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitConnectionManager.kt new file mode 100644 index 0000000000..fef6392b51 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitConnectionManager.kt @@ -0,0 +1,139 @@ +// Copyright 2022 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.ActivityTracker +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.credentials.pinning.ConnectionPinningManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.FeatureWithPinnedConnection +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener + +// TODO: unify with AwsConnectionManager +@State(name = "connectionManager", storages = [Storage("aws.xml")]) +class DefaultToolkitConnectionManager : ToolkitConnectionManager, PersistentStateComponent { + init { + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun invalidate(providerId: String) { + if (activeConnection()?.id == providerId) { + switchConnection(null) + ActivityTracker.getInstance().inc() + } + } + } + ) + } + private val project: Project? + constructor(project: Project) { + this.project = project + } + constructor() { + this.project = null + } + + private var connection: ToolkitConnection? = null + + private val pinningManager: ConnectionPinningManager = ConnectionPinningManager.getInstance() + + private val defaultConnection: ToolkitConnection? + get() { + if (CredentialManager.getInstance().getCredentialIdentifiers().isNotEmpty() && project != null) { + return AwsConnectionManagerConnection(project) + } + + ToolkitAuthManager.getInstance().listConnections().firstOrNull()?.let { + return it + } + + return null + } + + @Synchronized + override fun activeConnection() = connection ?: defaultConnection + + @Synchronized + override fun activeConnectionForFeature(feature: FeatureWithPinnedConnection): ToolkitConnection? { + val pinnedConnection = pinningManager.getPinnedConnection(feature) + if (pinnedConnection != null) { + return pinnedConnection + } + + return connection?.let { + if (feature.supportsConnectionType(it)) { + return it + } + + null + } ?: defaultConnection?.let { + if (feature.supportsConnectionType(it)) { + return it + } + + null + } + } + + override fun getState() = ToolkitConnectionManagerState( + connection?.id + ) + + override fun loadState(state: ToolkitConnectionManagerState) { + state.activeConnectionId?.let { + val idSegments = it.split(";") + val activeConnectionIdWithRegion = + if (idSegments.size == 2) { + "${idSegments[0]};us-east-1;${idSegments[1]}" + } else { + it + } + connection = ToolkitAuthManager.getInstance().getConnection(activeConnectionIdWithRegion) + } + } + + @Synchronized + override fun switchConnection(newConnection: ToolkitConnection?) { + val oldConnection = this.connection + + if (oldConnection != newConnection) { + val application = ApplicationManager.getApplication() + this.connection = newConnection + + if (newConnection != null) { + val featuresToPin = mutableListOf() + FeatureWithPinnedConnection.EP_NAME.forEachExtensionSafe { + if (!pinningManager.isFeaturePinned(it) && + ( + ( + oldConnection == null && it.supportsConnectionType(newConnection) + ) || + ( + oldConnection != null && it.supportsConnectionType(oldConnection) != it.supportsConnectionType(newConnection) + ) + ) + ) { + featuresToPin.add(it) + } + } + + if (featuresToPin.isNotEmpty()) { + application.executeOnPooledThread { + pinningManager.pinFeatures(oldConnection, newConnection, featuresToPin) + application.messageBus.syncPublisher(ToolkitConnectionManagerListener.TOPIC).activeConnectionChanged(newConnection) + } + } + } + + application.messageBus.syncPublisher(ToolkitConnectionManagerListener.TOPIC).activeConnectionChanged(newConnection) + } + } +} + +data class ToolkitConnectionManagerState( + var activeConnectionId: String? = null +) 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 index edc322b5fa..b242fd39e9 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/InteractiveCredential.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/InteractiveCredential.kt @@ -18,5 +18,5 @@ interface InteractiveCredential : CredentialIdentifier { /** * Determines if user action is required at this time (e.g. may check expiry of cookies, etc) */ - suspend fun userActionRequired(): Boolean + 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 index 2ed444b989..0d2ae4751d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/MfaSupport.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/MfaSupport.kt @@ -4,11 +4,9 @@ package software.aws.toolkits.jetbrains.core.credentials import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.progress.ProcessCanceledException 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.jetbrains.utils.computeOnEdt import software.aws.toolkits.resources.message interface MfaRequiredInteractiveCredentials : InteractiveCredential { @@ -17,15 +15,13 @@ interface MfaRequiredInteractiveCredentials : InteractiveCredential { override val userAction: AnAction get() = RefreshConnectionAction(message("credentials.mfa.action")) - override suspend fun userActionRequired(): Boolean = true + override 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") - } +fun promptForMfaToken(name: String, mfaSerial: String): String = computeOnEdt { + Messages.showInputDialog( + message("credentials.mfa.message", mfaSerial), + message("credentials.mfa.title", name), + null + ) ?: throw ProcessCanceledException(IllegalStateException("MFA challenge is required")) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/PostValidateInteractiveCredential.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/PostValidateInteractiveCredential.kt new file mode 100644 index 0000000000..a5e1552454 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/PostValidateInteractiveCredential.kt @@ -0,0 +1,11 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +/** + * Marker interface for credentials that may require interactivity after [AwsConnectionManager] attempts validation + */ +interface PostValidateInteractiveCredential { + fun handleValidationException(e: Exception): ConnectionState.RequiresUserAction? +} 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 index c59c30a634..636c2f551e 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/RefreshConnectionAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/RefreshConnectionAction.kt @@ -7,10 +7,15 @@ import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.project.DumbAware +import kotlinx.coroutines.launch import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry -class RefreshConnectionAction(text: String = message("settings.refresh.description")) : AnAction(text, null, AllIcons.Actions.Refresh), DumbAware { +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) { @@ -21,7 +26,9 @@ class RefreshConnectionAction(text: String = message("settings.refresh.descripti override fun actionPerformed(e: AnActionEvent) { val project = e.project ?: return - AwsResourceCache.getInstance(project).clear() + AwsTelemetry.refreshExplorer(project) + val scope = projectCoroutineScope(project) + scope.launch { AwsResourceCache.getInstance().clear() } AwsConnectionManager.getInstance(project).refreshConnectionState() } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/RefreshExplorerCredentials.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/RefreshExplorerCredentials.kt new file mode 100644 index 0000000000..f47eb8cce9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/RefreshExplorerCredentials.kt @@ -0,0 +1,19 @@ +// Copyright 2023 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.project.Project +import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileCredentialsIdentifier + +class RefreshExplorerCredentials(val project: Project) : ChangeConnectionSettingIfValid { + + override fun changeConnection(profile: ProfileCredentialsIdentifier) { + super.changeConnection(profile) + AwsConnectionManager.getInstance(project).changeCredentialProvider(profile) + } +} + +interface ChangeConnectionSettingIfValid { + fun changeConnection(profile: ProfileCredentialsIdentifier) {} +} 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 index 77bdb8f641..65edacb42c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/SsoSupport.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/SsoSupport.kt @@ -3,17 +3,9 @@ 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.jetbrains.core.credentials.sso.DiskCache +import software.aws.toolkits.jetbrains.core.credentials.sso.SsoCache import software.aws.toolkits.resources.message /** @@ -21,32 +13,6 @@ import software.aws.toolkits.resources.message */ 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 @@ -56,5 +22,5 @@ interface SsoRequiredInteractiveCredentials : InteractiveCredential { override val userAction: AnAction get() = RefreshConnectionAction(message("credentials.sso.action")) - override suspend fun userActionRequired(): Boolean = ssoCache.loadAccessToken(ssoUrl) == null + override fun userActionRequired(): Boolean = ssoCache.loadAccessToken(ssoUrl) == null } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitAuthManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitAuthManager.kt new file mode 100644 index 0000000000..002d762d7c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitAuthManager.kt @@ -0,0 +1,298 @@ +// Copyright 2022 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.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.MessageDialogBuilder +import org.jetbrains.annotations.VisibleForTesting +import software.amazon.awssdk.services.ssooidc.model.SsoOidcException +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.credentials.pinning.FeatureWithPinnedConnection +import software.aws.toolkits.jetbrains.core.credentials.profiles.SsoSessionConstants.SSO_SESSION_SECTION_NAME +import software.aws.toolkits.jetbrains.core.credentials.sono.isSono +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.utils.computeOnEdt +import software.aws.toolkits.jetbrains.utils.runUnderProgressIfNeeded +import software.aws.toolkits.resources.message + +sealed interface ToolkitConnection { + val id: String + val label: String + + fun getConnectionSettings(): ClientConnectionSettings<*> +} + +interface AwsCredentialConnection : ToolkitConnection { + override fun getConnectionSettings(): ConnectionSettings +} + +interface AwsBearerTokenConnection : ToolkitConnection { + val startUrl: String + val region: String + + override fun getConnectionSettings(): TokenConnectionSettings +} + +interface BearerSsoConnection : AwsBearerTokenConnection { + val scopes: List +} + +sealed interface AuthProfile + +data class ManagedSsoProfile( + var ssoRegion: String = "", + var startUrl: String = "", + var scopes: List = emptyList() +) : AuthProfile + +data class UserConfigSsoSessionProfile( + var configSessionName: String = "", + var ssoRegion: String = "", + var startUrl: String = "", + var scopes: List = emptyList() +) : AuthProfile { + val id + get() = "$SSO_SESSION_SECTION_NAME:$configSessionName" +} + +data class DetectedDiskSsoSessionProfile( + var profileName: String = "", + var startUrl: String = "", + var ssoRegion: String = "" +) : AuthProfile + +/** + * Used to contribute connections to [ToolkitAuthManager] on service initialization + */ +interface ToolkitStartupAuthFactory { + fun buildConnections(): List + + companion object { + val EP_NAME = ExtensionPointName.create("aws.toolkit.startupAuthFactory") + } +} + +interface ToolkitAuthManager { + fun listConnections(): List + + fun createConnection(profile: AuthProfile): ToolkitConnection + + /** + * Creates a connection that is not visible to the rest of the toolkit unless authentication succeeds + * @return [BearerSsoConnection] on success + */ + fun tryCreateTransientSsoConnection(profile: AuthProfile, callback: (BearerSsoConnection) -> Unit): BearerSsoConnection + fun getOrCreateSsoConnection(profile: UserConfigSsoSessionProfile): BearerSsoConnection + + fun deleteConnection(connection: ToolkitConnection) + fun deleteConnection(connectionId: String) + + fun getConnection(connectionId: String): ToolkitConnection? + + companion object { + fun getInstance() = service() + } +} + +interface ToolkitConnectionManager : Disposable { + fun activeConnection(): ToolkitConnection? + + fun activeConnectionForFeature(feature: FeatureWithPinnedConnection): ToolkitConnection? + + fun switchConnection(newConnection: ToolkitConnection?) + + override fun dispose() {} + + companion object { + fun getInstance(project: Project?) = project?.let { it.service() } ?: service() + } +} + +/** + * Individual service should subscribe [ToolkitConnectionManagerListener.TOPIC] to fire their service activation / UX update + */ +@Deprecated("Connections created through this function are not written to the user's ~/.aws/config file") +fun loginSso(project: Project?, startUrl: String, region: String, requestedScopes: List): BearerTokenProvider { + val connectionId = ToolkitBearerTokenProvider.ssoIdentifier(startUrl, region) + + val manager = ToolkitAuthManager.getInstance() + val allScopes = requestedScopes.toMutableSet() + return manager.getConnection(connectionId)?.let { connection -> + val logger = getLogger() + // requested Builder ID, but one already exists + // TBD: do we do this for regular SSO too? + if (connection.isSono() && connection is BearerSsoConnection && requestedScopes.all { it in connection.scopes }) { + val signOut = computeOnEdt { + MessageDialogBuilder.yesNo( + message("toolkit.login.aws_builder_id.already_connected.title"), + message("toolkit.login.aws_builder_id.already_connected.message") + ) + .yesText(message("toolkit.login.aws_builder_id.already_connected.reconnect")) + .noText(message("toolkit.login.aws_builder_id.already_connected.cancel")) + .ask(project) + } + + if (signOut) { + logger.info { + "Forcing reauth on ${connection.id} since user requested Builder ID while already connected to Builder ID" + } + + logoutFromSsoConnection(project, connection as AwsBearerTokenConnection) + return@let null + } + } + + // There is an existing connection we can use + if (connection is BearerSsoConnection && !requestedScopes.all { it in connection.scopes }) { + allScopes.addAll(connection.scopes) + + logger.info { + """ + Forcing reauth on ${connection.id} since requested scopes ($requestedScopes) + are not a complete subset of current scopes (${connection.scopes}) + """.trimIndent() + } + logoutFromSsoConnection(project, connection as AwsBearerTokenConnection) + // can't reuse since requested scopes are not in current connection. forcing reauth + return@let null + } + + // For the case when the existing connection is in invalid state, we need to re-auth + if (connection is AwsBearerTokenConnection) { + return reauthConnection(project, connection) + } + + null + } ?: run { + // No existing connection, start from scratch + val connection = manager.createConnection( + ManagedSsoProfile( + region, + startUrl, + allScopes.toList() + ) + ) + + try { + reauthConnection(project, connection) + } catch (e: Exception) { + manager.deleteConnection(connection) + throw e + } + } +} + +@VisibleForTesting +internal fun reauthConnection(project: Project?, connection: ToolkitConnection): BearerTokenProvider { + val provider = reauthConnectionIfNeeded(project, connection) + + ToolkitConnectionManager.getInstance(project).switchConnection(connection) + + return provider +} + +fun logoutFromSsoConnection(project: Project?, connection: AwsBearerTokenConnection, callback: () -> Unit = {}) { + try { + ApplicationManager.getApplication().messageBus.syncPublisher(BearerTokenProviderListener.TOPIC).invalidate(connection.id) + ToolkitAuthManager.getInstance().deleteConnection(connection.id) + project?.let { ToolkitConnectionManager.getInstance(it).switchConnection(null) } + } finally { + callback() + } +} + +fun lazyGetUnauthedBearerConnections() = + ToolkitAuthManager.getInstance().listConnections().filterIsInstance().filter { + it.lazyIsUnauthedBearerConnection() + } + +fun AwsBearerTokenConnection.lazyIsUnauthedBearerConnection(): Boolean { + val provider = (getConnectionSettings().tokenProvider.delegate as? BearerTokenProvider) + + if (provider != null) { + if (provider.currentToken() == null) { + // provider is unauthed if no token + return true + } + + // or token can't be used directly without interaction + return provider.state() != BearerTokenAuthState.AUTHORIZED + } + + // not a bearer token provider + return false +} + +fun reauthConnectionIfNeeded(project: Project?, connection: ToolkitConnection): BearerTokenProvider { + val tokenProvider = (connection.getConnectionSettings() as TokenConnectionSettings).tokenProvider.delegate as BearerTokenProvider + + return reauthProviderIfNeeded(project, tokenProvider, connection.isSono()) +} + +fun reauthProviderIfNeeded(project: Project?, tokenProvider: BearerTokenProvider, isBuilderId: Boolean): BearerTokenProvider { + maybeReauthProviderIfNeeded(project, tokenProvider, isBuilderId) { + val title = if (isBuilderId) { + message("credentials.sono.login.pending") + } else { + message("credentials.sso.login.pending") + } + + runUnderProgressIfNeeded(project, title, true) { + tokenProvider.reauthenticate() + } + } + + return tokenProvider +} + +// Return true if need to re-auth, false otherwise +fun maybeReauthProviderIfNeeded( + project: Project?, + tokenProvider: BearerTokenProvider, + isBuilderId: Boolean, + onReauthRequired: (SsoOidcException?) -> Any +): Boolean { + val state = tokenProvider.state() + when (state) { + BearerTokenAuthState.NOT_AUTHENTICATED -> { + getLogger().info { "Token provider NOT_AUTHENTICATED, requesting login" } + onReauthRequired(null) + return true + } + + BearerTokenAuthState.NEEDS_REFRESH -> { + try { + val title = if (isBuilderId) { + message("credentials.sono.login.refreshing") + } else { + message("credentials.sso.login.refreshing") + } + + return runUnderProgressIfNeeded(project, title, true) { + tokenProvider.resolveToken() + BearerTokenProviderListener.notifyCredUpdate(tokenProvider.id) + return@runUnderProgressIfNeeded false + } + } catch (e: SsoOidcException) { + getLogger().warn(e) { "Redriving bearer token login flow since token could not be refreshed" } + onReauthRequired(e) + return true + } + } + + BearerTokenAuthState.AUTHORIZED -> { return false } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitConnectionImpls.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitConnectionImpls.kt new file mode 100644 index 0000000000..1608a8ee28 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitConnectionImpls.kt @@ -0,0 +1,116 @@ +// Copyright 2022 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.Disposable +import com.intellij.openapi.util.Disposer +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.sso.DiskCache +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.InteractiveBearerTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.ProfileSdkTokenProviderWrapper +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider + +/** + * An SSO bearer connection created through a `sso-session` declaration in a user's ~/.aws/config + */ +class ProfileSsoManagedBearerSsoConnection( + id: String, + val configSessionName: String, + startUrl: String, + region: String, + scopes: List, + cache: DiskCache = diskCache, +) : ManagedBearerSsoConnection( + startUrl, + region, + scopes, + cache, + id, + ToolkitBearerTokenProvider.diskSessionDisplayName(configSessionName) +) + +/** + * An SSO bearer connection created through [loginSso] + */ +class LegacyManagedBearerSsoConnection( + startUrl: String, + region: String, + scopes: List, + cache: DiskCache = diskCache, +) : ManagedBearerSsoConnection( + startUrl, + region, + scopes, + cache, + ToolkitBearerTokenProvider.ssoIdentifier(startUrl, region), + ToolkitBearerTokenProvider.ssoDisplayName(startUrl) +) + +sealed class ManagedBearerSsoConnection( + override val startUrl: String, + override val region: String, + override val scopes: List, + cache: DiskCache = diskCache, + override val id: String, + override val label: String +) : BearerSsoConnection, Disposable { + + private val provider = + tokenConnection( + InteractiveBearerTokenProvider( + startUrl, + region, + scopes, + id, + cache + ), + region + ) + + override fun getConnectionSettings(): TokenConnectionSettings = provider + + override fun dispose() { + disposeProviderIfRequired(provider) + } +} + +class DetectedDiskSsoSessionConnection( + val sessionProfileName: String, + override val startUrl: String, + override val region: String, + displayNameOverride: String? = null +) : AwsBearerTokenConnection, Disposable { + override val id = ToolkitBearerTokenProvider.diskSessionIdentifier(sessionProfileName) + override val label = displayNameOverride ?: ToolkitBearerTokenProvider.diskSessionDisplayName(sessionProfileName) + + private val provider = + tokenConnection( + ProfileSdkTokenProviderWrapper( + sessionName = sessionProfileName, + region = region + ), + region + ) + + override fun getConnectionSettings(): TokenConnectionSettings = provider + + override fun dispose() { + disposeProviderIfRequired(provider) + } +} + +private fun tokenConnection(provider: BearerTokenProvider, region: String) = + TokenConnectionSettings( + ToolkitBearerTokenProvider(provider), + AwsRegionProvider.getInstance().get(region) ?: error("Partition data is missing for $region") + ) + +private fun disposeProviderIfRequired(settings: TokenConnectionSettings) { + val delegate = settings.tokenProvider.delegate + if (delegate is Disposable) { + Disposer.dispose(delegate) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitConnectionManagerListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitConnectionManagerListener.kt new file mode 100644 index 0000000000..dd122c9e15 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitConnectionManagerListener.kt @@ -0,0 +1,17 @@ +// Copyright 2022 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.util.messages.Topic +import java.util.EventListener + +// TODO: unify with [ConnectionSettingsStateChangeNotifier] +interface ToolkitConnectionManagerListener : EventListener { + fun activeConnectionChanged(newConnection: ToolkitConnection?) + + companion object { + @Topic.AppLevel + val TOPIC = Topic.create("ToolkitConnectionManagerListener active connection change", ToolkitConnectionManagerListener::class.java) + } +} 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 index 595e6d712d..3fb09cb266 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitCredentialProcessProvider.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ToolkitCredentialProcessProvider.kt @@ -9,6 +9,7 @@ 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.CommandLineTokenizer import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.ProcessOutput import com.intellij.execution.util.ExecUtil @@ -19,15 +20,20 @@ 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.SdkAutoCloseable import software.amazon.awssdk.utils.cache.CachedSupplier import software.amazon.awssdk.utils.cache.RefreshResult import software.aws.toolkits.resources.message import java.time.Instant +import java.util.Enumeration +/** + * Similar to the SDKs ProcessCredentialsProvider, but ties in the env var system of the IDE such as getting $PATH + */ class ToolkitCredentialProcessProvider internal constructor( private val command: String, private val parser: CredentialProcessOutputParser -) : AwsCredentialsProvider { +) : AwsCredentialsProvider, SdkAutoCloseable { constructor(command: String) : this(command, DefaultCredentialProcessOutputParser) private val entrypoint by lazy { @@ -35,7 +41,8 @@ class ToolkitCredentialProcessProvider internal constructor( } private val cmd by lazy { if (SystemInfo.isWindows) { - GeneralCommandLine("cmd", "/C", command) + @Suppress("UNCHECKED_CAST") + GeneralCommandLine("cmd", "/C", *(CommandLineTokenizer(command) as Enumeration).toList().toTypedArray()) } else { GeneralCommandLine("sh", "-c", command) } @@ -74,7 +81,11 @@ class ToolkitCredentialProcessProvider internal constructor( throw RuntimeException(msg) } - internal companion object { + override fun close() { + processCredentialCache.close() + } + + private companion object { private const val DEFAULT_TIMEOUT = 30000 } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/CredentialsHelpAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/CredentialsHelpAction.kt new file mode 100644 index 0000000000..653e85bc1b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/CredentialsHelpAction.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.actions + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import java.net.URI + +class CredentialsHelpAction : DumbAwareAction(AllIcons.General.ContextHelp) { + override fun actionPerformed(e: AnActionEvent) { + // TODO: update + runInEdt { + BrowserUtil.browse(URI(CodeWhispererConstants.CODEWHISPERER_LOGIN_HELP_URI)) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/ExplorerNewConnectionAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/ExplorerNewConnectionAction.kt new file mode 100644 index 0000000000..a3068fc227 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/ExplorerNewConnectionAction.kt @@ -0,0 +1,24 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel +import software.aws.toolkits.telemetry.UiTelemetry + +class ExplorerNewConnectionAction : DumbAwareAction(AllIcons.General.Add) { + override fun displayTextInToolbar() = true + + override fun actionPerformed(e: AnActionEvent) { + e.project?.let { + runInEdt { + GettingStartedPanel.openPanel(it) + UiTelemetry.click(it, "devtools_connectToAws") + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/MoreConnectionActionsAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/MoreConnectionActionsAction.kt new file mode 100644 index 0000000000..40c165cbe3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/MoreConnectionActionsAction.kt @@ -0,0 +1,57 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManagerConnection +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsMenuBuilder.Companion.connectionSettingsMenuBuilder +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.lazyGetUnauthedBearerConnections +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.resources.message + +class MoreConnectionActionsAction : DumbAwareAction(AllIcons.Actions.MoreHorizontal) { + override fun update(e: AnActionEvent) { + e.presentation.icon = if (lazyGetUnauthedBearerConnections().isEmpty()) AllIcons.Actions.MoreHorizontal else AllIcons.General.Warning + } + + fun buildActions(project: Project?): ActionGroup { + val actionManager = ActionManager.getInstance() + val baseActions = actionManager.getAction("aws.toolkit.toolwindow.credentials.rightGroup.more.group") as ActionGroup + val identityActions = connectionSettingsMenuBuilder().apply { withIndividualIdentityActions(project) }.build() + + return DefaultActionGroup( + buildList { + add(identityActions) + + project?.let { project -> + if (ToolkitConnectionManager.getInstance(project).activeConnection() is AwsConnectionManagerConnection) { + add(actionManager.getAction("aws.settings.upsertCredentials")) + } + } + + add(baseActions) + } + ) + } + + override fun actionPerformed(e: AnActionEvent) { + val actions = buildActions(e.project) + + JBPopupFactory.getInstance().createActionGroupPopup( + message("settings.title"), + actions, + e.dataContext, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true + ).showInBestPositionFor(e.dataContext) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/NewConnectionAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/NewConnectionAction.kt new file mode 100644 index 0000000000..bf8666d924 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/NewConnectionAction.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel +import software.aws.toolkits.telemetry.UiTelemetry + +class NewConnectionAction : DumbAwareAction() { + override fun actionPerformed(e: AnActionEvent) { + e.project?.let { + runInEdt { + GettingStartedPanel.openPanel(it, connectionInitiatedFromExplorer = true) + UiTelemetry.click(e.project, "auth_gettingstarted_explorermenu") + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/SsoLogoutAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/SsoLogoutAction.kt new file mode 100644 index 0000000000..2ed474f247 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/SsoLogoutAction.kt @@ -0,0 +1,30 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.ui.MessageDialogBuilder +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ProfileSsoManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.logoutFromSsoConnection +import software.aws.toolkits.jetbrains.core.explorer.refreshDevToolTree +import software.aws.toolkits.jetbrains.core.gettingstarted.deleteSsoConnectionCW +import software.aws.toolkits.resources.message + +class SsoLogoutAction(private val value: AwsBearerTokenConnection) : DumbAwareAction(message("credentials.individual_identity.signout")) { + override fun actionPerformed(e: AnActionEvent) { + if (value is ProfileSsoManagedBearerSsoConnection) { + val confirmDeletion = MessageDialogBuilder.okCancel( + message("gettingstarted.auth.idc.sign.out.confirmation.title"), + message("gettingstarted.auth.idc.sign.out.confirmation") + ).yesText(message("general.confirm")).ask(e.project) + if (confirmDeletion) { + deleteSsoConnectionCW(value) + } + } + logoutFromSsoConnection(e.project, value) + e.project?.refreshDevToolTree() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/metadataservice/ContainerCredentialProviderFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/metadataservice/ContainerCredentialProviderFactory.kt new file mode 100644 index 0000000000..1597b51149 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/metadataservice/ContainerCredentialProviderFactory.kt @@ -0,0 +1,71 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.metadataservice + +import com.intellij.openapi.extensions.ExtensionNotApplicableException +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.credentials.ContainerCredentialsProvider +import software.amazon.awssdk.core.SdkSystemSetting +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.credentials.CredentialProviderFactory +import software.aws.toolkits.core.credentials.CredentialSourceId +import software.aws.toolkits.core.credentials.CredentialType +import software.aws.toolkits.core.credentials.CredentialsChangeEvent +import software.aws.toolkits.core.credentials.CredentialsChangeListener +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.caws.CawsConstants + +class ContainerCredentialProviderFactory : CredentialProviderFactory { + init { + if (System.getenv(CawsConstants.CAWS_ENV_ID_VAR) != null) { + throw ExtensionNotApplicableException.INSTANCE + } + } + + override val id = "ContainerCredentialProviderFactory" + override val credentialSourceId: CredentialSourceId = CredentialSourceId.Ecs + + private val containerCredIdentifier by lazy { + credentialId() + } + + override fun setUp(credentialLoadCallback: CredentialsChangeListener) { + // deviates from SDK behavior by treating the empty value as unset + val credSettings = arrayOf(SdkSystemSetting.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, SdkSystemSetting.AWS_CONTAINER_CREDENTIALS_FULL_URI) + if (credSettings.none { it.stringValue.orElse("").isNotBlank() }) { + getLogger().debug { + "Skipping container credential provider since container credentials environment variables were not available" + } + + return + } + + credentialLoadCallback( + CredentialsChangeEvent( + added = listOf(containerCredIdentifier), + modified = emptyList(), + removed = emptyList() + ) + ) + } + + override fun createAwsCredentialProvider(providerId: CredentialIdentifier, region: AwsRegion): AwsCredentialsProvider = + ContainerCredentialsProvider.builder() + .asyncCredentialUpdateEnabled(false) + .build() + + companion object { + const val FACTORY_ID = "ContainerCredentialProviderFactory" + + fun credentialId() = object : CredentialIdentifier { + override val id: String = "containerRoleCredential" + override val displayName = "ecs:containerRole" + override val factoryId = FACTORY_ID + override val credentialType = CredentialType.EcsMetadata + override val defaultRegionId = System.getenv(SdkSystemSetting.AWS_REGION.environmentVariable()) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/metadataservice/InstanceRoleCredentialProviderFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/metadataservice/InstanceRoleCredentialProviderFactory.kt new file mode 100644 index 0000000000..88e84cef5d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/metadataservice/InstanceRoleCredentialProviderFactory.kt @@ -0,0 +1,88 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.metadataservice + +import com.intellij.openapi.extensions.ExtensionNotApplicableException +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider +import software.amazon.awssdk.core.SdkSystemSetting +import software.amazon.awssdk.regions.internal.util.EC2MetadataUtils +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.credentials.CredentialProviderFactory +import software.aws.toolkits.core.credentials.CredentialSourceId +import software.aws.toolkits.core.credentials.CredentialType +import software.aws.toolkits.core.credentials.CredentialsChangeEvent +import software.aws.toolkits.core.credentials.CredentialsChangeListener +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.caws.CawsConstants + +class InstanceRoleCredentialProviderFactory : CredentialProviderFactory { + init { + if (System.getenv(CawsConstants.CAWS_ENV_ID_VAR) != null) { + throw ExtensionNotApplicableException.INSTANCE + } + } + + override val id = FACTORY_ID + override val credentialSourceId: CredentialSourceId = CredentialSourceId.Ec2 + + private val instanceRoleCredIdentifier: CredentialIdentifier by lazy { + object : CredentialIdentifier { + override val id: String = "ec2InstanceRoleCredential" + override val displayName = "ec2:instanceProfile" + override val factoryId = FACTORY_ID + override val credentialType = CredentialType.Ec2Metadata + override val defaultRegionId = try { + EC2MetadataUtils.getEC2InstanceRegion() + } catch (e: Exception) { + LOG.warn(e) { "Failed to query instance region from ec2 instance metadata" } + null + } + } + } + + private val provider = InstanceProfileCredentialsProvider.builder() + .asyncCredentialUpdateEnabled(false) + .build() + + override fun setUp(credentialLoadCallback: CredentialsChangeListener) { + if (SdkSystemSetting.AWS_EC2_METADATA_DISABLED.booleanValue.orElse(false)) { + LOG.debug { "EC2 metadata provider disabled by system setting" } + return + } + + val endpoint = SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.stringValue.orElse("") + if (endpoint.isBlank()) { + LOG.debug { "Skipping instance role credential provider since endpoint was blank" } + return + } + + try { + provider.resolveCredentials() + } catch (e: Exception) { + provider.close() + LOG.debug { "Instance role credential provider failed to resolve credentials" } + return + } + + credentialLoadCallback( + CredentialsChangeEvent( + added = listOf(instanceRoleCredIdentifier), + modified = emptyList(), + removed = emptyList() + ) + ) + } + + override fun createAwsCredentialProvider(providerId: CredentialIdentifier, region: AwsRegion): AwsCredentialsProvider = + provider + + companion object { + const val FACTORY_ID = "InstanceRoleCredentialProviderFactory" + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/CodeCatalystConnection.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/CodeCatalystConnection.kt new file mode 100644 index 0000000000..77eb5d8776 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/CodeCatalystConnection.kt @@ -0,0 +1,42 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.pinning + +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.BearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.CODECATALYST_SCOPES +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL + +class CodeCatalystConnection : FeatureWithPinnedConnection { + override val featureId: String = "aws.codecatalyst" + override val featureName: String = "CodeCatalyst" + override fun supportsConnectionType(connection: ToolkitConnection): Boolean { + if (connection is AwsBearerTokenConnection) { + if (connection is ManagedBearerSsoConnection && connection.startUrl != SONO_URL) { + LOG.debug { "Rejecting ${connection.id} since it's not a AWS Builder ID connection" } + // doesn't support arbitrary SSO + return false + } + + if (connection is BearerSsoConnection && !CODECATALYST_SCOPES.all { it in connection.scopes }) { + LOG.debug { "Rejecting ${connection.id} since it's missing a required scope" } + return false + } + + return true + } + + // only supports bearer connections + return false + } + + companion object { + private val LOG = getLogger() + fun getInstance() = FeatureWithPinnedConnection.EP_NAME.findExtensionOrFail(CodeCatalystConnection::class.java) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/CodeWhispererConnection.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/CodeWhispererConnection.kt new file mode 100644 index 0000000000..e9940147a5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/CodeWhispererConnection.kt @@ -0,0 +1,30 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.pinning + +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.BearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.CODEWHISPERER_SCOPES + +class CodeWhispererConnection : FeatureWithPinnedConnection { + override val featureId = "aws.codewhisperer" + override val featureName = "CodeWhisperer" + + override fun supportsConnectionType(connection: ToolkitConnection): Boolean { + if (connection is AwsBearerTokenConnection) { + if (connection is BearerSsoConnection) { + return CODEWHISPERER_SCOPES.all { it in connection.scopes } + } + + return true + } + + return false + } + + companion object { + fun getInstance() = FeatureWithPinnedConnection.EP_NAME.findExtensionOrFail(CodeWhispererConnection::class.java) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/ConnectionPinningManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/ConnectionPinningManager.kt new file mode 100644 index 0000000000..c68e008b8c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/ConnectionPinningManager.kt @@ -0,0 +1,153 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.pinning + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.extensions.ExtensionPointName +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import java.util.concurrent.ConcurrentHashMap + +interface FeatureWithPinnedConnection { + val featureId: String + val featureName: String + + fun supportsConnectionType(connection: ToolkitConnection): Boolean + + companion object { + val EP_NAME = ExtensionPointName("aws.toolkit.connection.pinned.feature") + } +} + +interface ConnectionPinningManager { + fun isFeaturePinned(feature: FeatureWithPinnedConnection): Boolean + fun getPinnedConnection(feature: FeatureWithPinnedConnection): ToolkitConnection? + fun setPinnedConnection(feature: FeatureWithPinnedConnection, newConnection: ToolkitConnection?) + + fun pinFeatures(oldConnection: ToolkitConnection?, newConnection: ToolkitConnection, features: List) + + companion object { + fun getInstance(): ConnectionPinningManager = service() + } +} + +@State(name = "connectionPinningManager", storages = [Storage("aws.xml")]) +class DefaultConnectionPinningManager : + ConnectionPinningManager, + PersistentStateComponent, + Disposable { + + private val pinnedConnections = ConcurrentHashMap() + + init { + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun invalidate(providerId: String) { + pinnedConnections.entries.removeIf { (_, v) -> v.id == providerId } + } + } + ) + } + + override fun isFeaturePinned(feature: FeatureWithPinnedConnection) = getPinnedConnection(feature) != null + + override fun getPinnedConnection(feature: FeatureWithPinnedConnection): ToolkitConnection? = + pinnedConnections[feature.featureId].let { connection -> + if (connection == null) { + null + } else { + // fetch connection again in case it is no longer valid + val connectionInstance = ToolkitAuthManager.getInstance().getConnection(connection.id) + if (connectionInstance == null || !feature.supportsConnectionType(connectionInstance)) { + null + } else { + connection + } + } + } + + override fun setPinnedConnection(feature: FeatureWithPinnedConnection, newConnection: ToolkitConnection?) { + if (newConnection == null) { + pinnedConnections.remove(feature.featureId) + } else { + pinnedConnections[feature.featureId] = newConnection + } + + ApplicationManager.getApplication().messageBus.syncPublisher(ConnectionPinningManagerListener.TOPIC).pinnedConnectionChanged(feature, newConnection) + } + + override fun pinFeatures(oldConnection: ToolkitConnection?, newConnection: ToolkitConnection, features: List) { + // pin to newConnection if the feature is supported, otherwise stay with old connection + val newConnectionFeatures = mutableListOf() + val oldConnectionFeatures = mutableListOf() + features.forEach { + if (it.supportsConnectionType(newConnection)) { + newConnectionFeatures.add(it) + } else if (oldConnection != null && it.supportsConnectionType(oldConnection)) { + oldConnectionFeatures.add(it) + } else { + LOG.error { "Feature '$it' does not support either old: '$oldConnection' or new: '$newConnection'" } + } + } + + val pinConnections = { featuresToPin: List, connectionToPin: ToolkitConnection -> + val featuresString = if (featuresToPin.size == 1) { + featuresToPin.first().featureName + } else { + "${featuresToPin.dropLast(1).joinToString(",") { it.featureName }} and ${featuresToPin.last().featureName}" + } + + featuresToPin.forEach { + setPinnedConnection(it, connectionToPin) + } + notifyInfo(message("credentials.switch.notification.title", featuresString, connectionToPin.label)) + } + + if (newConnectionFeatures.isNotEmpty()) { + pinConnections(newConnectionFeatures, newConnection) + } + + if (oldConnectionFeatures.isNotEmpty()) { + if (oldConnection != null) { + pinConnections(oldConnectionFeatures, oldConnection) + } + } + } + + override fun getState() = ConnectionPinningManagerState( + pinnedConnections.entries.associate { (k, v) -> k to v.id } + ) + + override fun loadState(state: ConnectionPinningManagerState) { + val authManager = ToolkitAuthManager.getInstance() + + pinnedConnections.clear() + pinnedConnections.putAll( + state.pinnedConnections.entries.mapNotNull { (k, v) -> + authManager.getConnection(v)?.let { k to it } + } + ) + } + + override fun dispose() {} + + companion object { + private val LOG = getLogger() + } +} + +data class ConnectionPinningManagerState( + var pinnedConnections: Map = emptyMap() +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/ConnectionPinningManagerListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/ConnectionPinningManagerListener.kt new file mode 100644 index 0000000000..8b13a18910 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/ConnectionPinningManagerListener.kt @@ -0,0 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.pinning + +import com.intellij.util.messages.Topic +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import java.util.EventListener + +interface ConnectionPinningManagerListener : EventListener { + fun pinnedConnectionChanged(feature: FeatureWithPinnedConnection, newConnection: ToolkitConnection?) + + companion object { + @Topic.AppLevel + val TOPIC = Topic.create("Feature pinned active connection change", ConnectionPinningManagerListener::class.java) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/QConnection.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/QConnection.kt new file mode 100644 index 0000000000..2c79ec0317 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/pinning/QConnection.kt @@ -0,0 +1,35 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.pinning + +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.BearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES +import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES_UNAVAILABLE_BUILDER_ID +import software.aws.toolkits.jetbrains.core.credentials.sono.isSono + +class QConnection : FeatureWithPinnedConnection { + override val featureId = "aws.q" + override val featureName = "Amazon Q" + + override fun supportsConnectionType(connection: ToolkitConnection): Boolean { + if (connection is AwsBearerTokenConnection) { + if (connection is BearerSsoConnection) { + if (connection.isSono()) { + return (Q_SCOPES - Q_SCOPES_UNAVAILABLE_BUILDER_ID).all { it in connection.scopes } + } + return Q_SCOPES.all { it in connection.scopes } + } + + return true + } + + return false + } + + companion object { + fun getInstance() = FeatureWithPinnedConnection.EP_NAME.findExtensionOrFail(QConnection::class.java) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/CredentialSourceType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/CredentialSourceType.kt new file mode 100644 index 0000000000..716816212e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/CredentialSourceType.kt @@ -0,0 +1,21 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.profiles + +enum class CredentialSourceType { + EC2_INSTANCE_METADATA, ECS_CONTAINER, ENVIRONMENT; + + companion object { + fun parse(value: String): CredentialSourceType { + if (value.equals("Ec2InstanceMetadata", ignoreCase = true)) { + return EC2_INSTANCE_METADATA + } else if (value.equals("EcsContainer", ignoreCase = true)) { + return ECS_CONTAINER + } else if (value.equals("Environment", ignoreCase = true)) { + return ENVIRONMENT + } + throw IllegalArgumentException("'$value' is not a valid credential_source") + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/Ec2MetadataConfigProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/Ec2MetadataConfigProvider.kt new file mode 100644 index 0000000000..97c593c460 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/Ec2MetadataConfigProvider.kt @@ -0,0 +1,63 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.profiles + +import software.amazon.awssdk.core.SdkSystemSetting +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.profiles.ProfileProperty + +/** + * Retrieves the EC2 metadata endpoint based on profile file, env var, and Java system properties + * + * https://github.com/aws/aws-sdk-java-v2/blob/5fb447594313ab1ab9b9c0ead0ed7cb906b06e93/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/internal/Ec2MetadataConfigProviderEndpointResolutionTest.java + */ +object Ec2MetadataConfigProvider { + /** + * Default IPv4 endpoint for the Amazon EC2 Instance Metadata Service. + */ + private const val EC2_METADATA_SERVICE_URL_IPV4 = "http://169.254.169.254" + + /** + * Default IPv6 endpoint for the Amazon EC2 Instance Metadata Service. + */ + private const val EC2_METADATA_SERVICE_URL_IPV6 = "http://[fd00:ec2::254]" + + private enum class EndpointMode { + IPV4, IPV6; + + companion object { + fun fromValue(s: String?): EndpointMode = s?.let { _ -> + values().find { it.name.equals(s, ignoreCase = true) } + } ?: throw IllegalArgumentException("Unrecognized value for endpoint mode: '$s'") + } + } + + fun Profile.getEc2MedataEndpoint(): String = this.getEndpointOverride() ?: when (this.getEndpointMode()) { + EndpointMode.IPV4 -> EC2_METADATA_SERVICE_URL_IPV4 + EndpointMode.IPV6 -> EC2_METADATA_SERVICE_URL_IPV6 + } + + private fun Profile.getEndpointMode(): EndpointMode { + val endpointMode = SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE.nonDefaultStringValue + val endpointModeString = if (endpointMode.isPresent) { + endpointMode.get() + } else { + configFileEndpointMode(this) ?: SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE.defaultValue() + } + return EndpointMode.fromValue(endpointModeString) + } + + private fun Profile.getEndpointOverride(): String? { + val endpointOverride = SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.nonDefaultStringValue + return if (endpointOverride.isPresent) { + endpointOverride.get() + } else { + configFileEndpointOverride(this) + } + } + + private fun configFileEndpointMode(profile: Profile): String? = profile.property(ProfileProperty.EC2_METADATA_SERVICE_ENDPOINT_MODE).orElse(null) + + private fun configFileEndpointOverride(profile: Profile): String? = profile.property(ProfileProperty.EC2_METADATA_SERVICE_ENDPOINT).orElse(null) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileAssumeRoleProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileAssumeRoleProvider.kt new file mode 100644 index 0000000000..581fa7da29 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileAssumeRoleProvider.kt @@ -0,0 +1,84 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.profiles + +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.auth.credentials.AwsCredentials +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.profiles.ProfileProperty +import software.amazon.awssdk.regions.Region +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.amazon.awssdk.utils.SdkAutoCloseable +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.credentials.promptForMfaToken +import java.util.function.Supplier + +class ProfileAssumeRoleProvider(@get:TestOnly internal val parentProvider: AwsCredentialsProvider, region: AwsRegion, profile: Profile) : + AwsCredentialsProvider, SdkAutoCloseable { + private val stsClient: StsClient + private val credentialsProvider: StsAssumeRoleCredentialsProvider + + init { + val roleArn = profile.requiredProperty(ProfileProperty.ROLE_ARN) + val roleSessionName = profile.property(ProfileProperty.ROLE_SESSION_NAME).orElseGet { "aws-toolkit-jetbrains-${System.currentTimeMillis()}" } + val externalId = profile.property(ProfileProperty.EXTERNAL_ID).orElse(null) + val mfaSerial = profile.property(ProfileProperty.MFA_SERIAL).orElse(null) + + // https://docs.aws.amazon.com/sdkref/latest/guide/setting-global-duration_seconds.html + val durationSecs = profile.property(ProfileProperty.DURATION_SECONDS).map { it.toIntOrNull() }.orElse(null) ?: 3600 + + stsClient = AwsClientManager.getInstance().createUnmanagedClient(parentProvider, Region.of(region.id)) + + credentialsProvider = StsAssumeRoleCredentialsProvider.builder() + .stsClient(stsClient) + .refreshRequest( + Supplier { + createAssumeRoleRequest( + profile.name(), + mfaSerial, + roleArn, + roleSessionName, + externalId, + durationSecs + ) + } + ) + .build() + } + + private fun createAssumeRoleRequest( + profileName: String, + mfaSerial: String?, + roleArn: String, + roleSessionName: String?, + externalId: String?, + durationSeconds: Int + ): AssumeRoleRequest { + val requestBuilder = AssumeRoleRequest.builder() + .roleArn(roleArn) + .roleSessionName(roleSessionName) + .externalId(externalId) + .durationSeconds(durationSeconds) + + mfaSerial?.let { _ -> + requestBuilder + .serialNumber(mfaSerial) + .tokenCode(promptForMfaToken(profileName, mfaSerial)) + } + + return requestBuilder.build() + } + + override fun resolveCredentials(): AwsCredentials = credentialsProvider.resolveCredentials() + + override fun close() { + credentialsProvider.close() + (parentProvider as? SdkAutoCloseable)?.close() + stsClient.close() + } +} 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 f092d583d2..3eed227bc0 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 @@ -4,77 +4,162 @@ package software.aws.toolkits.jetbrains.core.credentials.profiles import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.util.registry.Registry -import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.util.messages.Topic import software.amazon.awssdk.auth.credentials.AwsBasicCredentials import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.credentials.AwsCredentialsProviderChain import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.amazon.awssdk.auth.credentials.ContainerCredentialsProvider +import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider -import software.amazon.awssdk.http.SdkHttpClient +import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider 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.amazon.awssdk.services.ssooidc.model.SsoOidcException 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.CredentialSourceId +import software.aws.toolkits.core.credentials.CredentialType import software.aws.toolkits.core.credentials.CredentialsChangeEvent import software.aws.toolkits.core.credentials.CredentialsChangeListener -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.credentials.SsoSessionBackedCredentialIdentifier +import software.aws.toolkits.core.credentials.SsoSessionIdentifier 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.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.credentials.ChangeConnectionSettingIfValid +import software.aws.toolkits.jetbrains.core.credentials.ConnectionState +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.credentials.InteractiveCredential import software.aws.toolkits.jetbrains.core.credentials.MfaRequiredInteractiveCredentials -import software.aws.toolkits.jetbrains.core.credentials.SsoPrompt +import software.aws.toolkits.jetbrains.core.credentials.PostValidateInteractiveCredential +import software.aws.toolkits.jetbrains.core.credentials.RefreshConnectionAction import software.aws.toolkits.jetbrains.core.credentials.SsoRequiredInteractiveCredentials +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager import software.aws.toolkits.jetbrains.core.credentials.ToolkitCredentialProcessProvider +import software.aws.toolkits.jetbrains.core.credentials.UserConfigSsoSessionProfile import software.aws.toolkits.jetbrains.core.credentials.diskCache -import software.aws.toolkits.jetbrains.core.credentials.promptForMfaToken +import software.aws.toolkits.jetbrains.core.credentials.profiles.Ec2MetadataConfigProvider.getEc2MedataEndpoint +import software.aws.toolkits.jetbrains.core.credentials.profiles.SsoSessionConstants.PROFILE_SSO_SESSION_PROPERTY +import software.aws.toolkits.jetbrains.core.credentials.profiles.SsoSessionConstants.SSO_SESSION_SECTION_NAME +import software.aws.toolkits.jetbrains.core.credentials.reauthConnectionIfNeeded +import software.aws.toolkits.jetbrains.core.credentials.sso.SsoCache +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.jetbrains.settings.ProfilesNotification import software.aws.toolkits.jetbrains.utils.createNotificationExpiringAction import software.aws.toolkits.jetbrains.utils.createShowMoreInfoDialogAction import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.jetbrains.utils.notifyInfo import software.aws.toolkits.resources.message -import java.util.function.Supplier +const val DEFAULT_PROFILE_NAME = "default" const val DEFAULT_PROFILE_ID = "profile:default" private const val PROFILE_FACTORY_ID = "ProfileCredentialProviderFactory" -private open class ProfileCredentialsIdentifier(val profileName: String, override val defaultRegionId: String?) : CredentialIdentifierBase() { +open class ProfileCredentialsIdentifier internal constructor(val profileName: String, override val defaultRegionId: String?, credentialType: CredentialType?) : + CredentialIdentifierBase(credentialType) { override val id = "profile:$profileName" override val displayName = message("credentials.profile.name", profileName) override val factoryId = PROFILE_FACTORY_ID override val shortName: String = profileName } -private class ProfileCredentialsIdentifierMfa(profileName: String, defaultRegionId: String?) : - ProfileCredentialsIdentifier(profileName, defaultRegionId), MfaRequiredInteractiveCredentials +private class ProfileCredentialsIdentifierMfa(profileName: String, defaultRegionId: String?, credentialType: CredentialType?) : + ProfileCredentialsIdentifier(profileName, defaultRegionId, credentialType), MfaRequiredInteractiveCredentials -private class ProfileCredentialsIdentifierSso( +private class ProfileCredentialsIdentifierLegacySso( profileName: String, defaultRegionId: String?, override val ssoCache: SsoCache, - override val ssoUrl: String -) : ProfileCredentialsIdentifier(profileName, defaultRegionId), + override val ssoUrl: String, + credentialType: CredentialType? +) : ProfileCredentialsIdentifier(profileName, defaultRegionId, credentialType), SsoRequiredInteractiveCredentials -class ProfileCredentialProviderFactory : CredentialProviderFactory { +class ProfileCredentialsIdentifierSso internal constructor( + profileName: String, + val ssoSessionName: String, + defaultRegionId: String?, + credentialType: CredentialType? +) : ProfileCredentialsIdentifier(profileName, defaultRegionId, credentialType), PostValidateInteractiveCredential, SsoSessionBackedCredentialIdentifier { + override val sessionIdentifier = "$SSO_SESSION_SECTION_NAME:$ssoSessionName" + + override fun handleValidationException(e: Exception): ConnectionState.RequiresUserAction? { + // in the new SSO flow, we must attempt validation before knowing if user action is truly required + if (findUpException(e) || findUpException(e)) { + return ConnectionState.RequiresUserAction( + object : InteractiveCredential, CredentialIdentifier by this { + override val userActionDisplayMessage = message("credentials.sso.display", displayName) + override val userActionShortDisplayMessage = message("credentials.sso.display.short") + override val userAction = object : AnAction(message("credentials.sso.login.session", ssoSessionName)), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + val session = CredentialManager.getInstance() + .getSsoSessionIdentifiers() + .first { it.id == sessionIdentifier } + val connection = ToolkitAuthManager.getInstance().getOrCreateSsoConnection( + UserConfigSsoSessionProfile( + configSessionName = ssoSessionName, + ssoRegion = session.ssoRegion, + startUrl = session.startUrl, + scopes = session.scopes.toList() + ) + ) + reauthConnectionIfNeeded(e.project, connection) + RefreshConnectionAction().actionPerformed(e) + } + } + + override fun userActionRequired() = true + } + ) + } + + return null + } + + // true exception could be further up the chain + private inline fun findUpException(e: Throwable?): Boolean { + // inline fun can't use recursion + var throwable = e + while (throwable != null) { + if (throwable is T) { + return true + } + throwable = throwable.cause + } + + return false + } +} + +private class NeverShowAgain : DumbAwareAction(message("settings.never_show_again")) { + override fun actionPerformed(e: AnActionEvent) { + AwsSettings.getInstance().profilesNotification = ProfilesNotification.Never + } +} + +data class ProfileSsoSessionIdentifier( + val profileName: String, + override val startUrl: String, + override val ssoRegion: String, + override val scopes: Set +) : SsoSessionIdentifier { + override val id = "$SSO_SESSION_SECTION_NAME:$profileName" +} + +class ProfileCredentialProviderFactory(private val ssoCache: SsoCache = diskCache) : CredentialProviderFactory { private val profileHolder = ProfileHolder() override val id = PROFILE_FACTORY_ID + override val credentialSourceId: CredentialSourceId = CredentialSourceId.SharedCredentials override fun setUp(credentialLoadCallback: CredentialsChangeListener) { // Load the initial data, then start the background watcher @@ -88,9 +173,12 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory { private fun loadProfiles(credentialLoadCallback: CredentialsChangeListener, initialLoad: Boolean) { val profilesAdded = mutableListOf() val profilesModified = mutableListOf() - val profilesRemoved = mutableListOf() + val ssoAdded = mutableListOf() + val ssoModified = mutableListOf() + + val previousConfig = profileHolder.snapshot() + val currentConfig = profileHolder.snapshot() - val previousProfilesSnapshot = profileHolder.snapshot() val newProfiles = try { validateAndGetProfiles() } catch (e: Exception) { @@ -100,25 +188,74 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory { } newProfiles.validProfiles.forEach { - val previousProfile = previousProfilesSnapshot.remove(it.key) + val previousProfile = currentConfig.profiles.remove(it.key) if (previousProfile == null) { // It was not in the snapshot, so it must be new profilesAdded.add(it.value.asId(newProfiles.validProfiles)) } else { - // If the profile was modified, notify people, else do nothing + // If the profile was modified, notify listeners, else do nothing if (previousProfile != it.value) { profilesModified.add(it.value.asId(newProfiles.validProfiles)) } } } + newProfiles.validSsoSessions.forEach { + val previousProfile = currentConfig.ssoSessions.remove(it.key) + if (previousProfile == null) { + // It was not in the snapshot, so it must be new + ssoAdded.add(it.value.asSsoSessionId()) + } else { + // If the profile was modified, notify listeners, else do nothing + if (previousProfile != it.value) { + ssoModified.add(it.value.asSsoSessionId()) + } + } + } + + // any profiles with a modified 'source_profile' need to be marked as well + newProfiles.validProfiles.forEach { (_, profile) -> + val profileId = profile.asId(newProfiles.validProfiles) + if (profileId in profilesModified) { + // already marked; skip + return@forEach + } + for (source in profile.traverseCredentialChain(newProfiles.validProfiles)) { + if (source != profile && source.asId(newProfiles.validProfiles) in profilesModified) { + profilesModified.add(profileId) + break + } + } + } + + // any profiles with a modified 'sso_session' need to be marked as well + newProfiles.validProfiles.forEach { (_, profile) -> + val profileId = profile.asId(newProfiles.validProfiles) + if (profileId in profilesModified) { + // already marked; skip + return@forEach + } + + val sessionProperty = profile.property(SsoSessionConstants.PROFILE_SSO_SESSION_PROPERTY) + if (sessionProperty.isPresent) { + val session = sessionProperty.get() + if (ssoModified.any { it.profileName == session }) { + profilesModified.add(profileId) + } + } + } + // Any remaining profiles must have either become invalid or removed from the cred/config files - previousProfilesSnapshot.values.asSequence().map { it.asId(newProfiles.validProfiles) }.toCollection(profilesRemoved) + val profilesRemoved = currentConfig.profiles.values.map { it.asId(previousConfig.profiles) } + val ssoRemoved = currentConfig.ssoSessions.values.map { it.asSsoSessionId() } - profileHolder.update(newProfiles.validProfiles) - credentialLoadCallback(CredentialsChangeEvent(profilesAdded, profilesModified, profilesRemoved)) + profileHolder.updateState(newProfiles.validProfiles, newProfiles.validSsoSessions) + credentialLoadCallback(CredentialsChangeEvent(profilesAdded, profilesModified, profilesRemoved, ssoAdded, ssoModified, ssoRemoved)) notifyUserOfResult(newProfiles, initialLoad) + if (profilesAdded.isNotEmpty() && newProfiles.validProfiles.size == 1) { + ApplicationManager.getApplication().messageBus.syncPublisher(NEW_PROFILE_ADDED).changeConnection(profilesAdded.first()) + } } private fun notifyUserOfLoadFailure(e: Exception) { @@ -128,11 +265,18 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory { ": $it" } ?: "" - notifyError( - title = message("credentials.profile.refresh_ok_title"), - content = "$loadingFailureMessage$detail", - action = createNotificationExpiringAction(ActionManager.getInstance().getAction("aws.settings.upsertCredentials")) - ) + LOG.warn(e) { loadingFailureMessage } + + if (AwsSettings.getInstance().profilesNotification != ProfilesNotification.Never) { + notifyError( + title = message("credentials.profile.refresh_ok_title"), + content = "$loadingFailureMessage$detail", + notificationActions = listOf( + createNotificationExpiringAction(ActionManager.getInstance().getAction("aws.settings.upsertCredentials")), + createNotificationExpiringAction(NeverShowAgain()) + ) + ) + } } private fun notifyUserOfResult(newProfiles: Profiles, initialLoad: Boolean) { @@ -143,10 +287,13 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory { // All provides were valid if (newProfiles.invalidProfiles.isEmpty()) { // Don't report we load creds on start to avoid spam - if (!initialLoad) { + if (!initialLoad && AwsSettings.getInstance().profilesNotification == ProfilesNotification.Always) { notifyInfo( title = message("credentials.profile.refresh_ok_title"), - content = refreshBaseMessage + content = refreshBaseMessage, + notificationActions = listOf( + createNotificationExpiringAction(NeverShowAgain()) + ) ) return @@ -155,39 +302,38 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory { // Some profiles failed to load if (newProfiles.invalidProfiles.isNotEmpty()) { - val message = newProfiles.invalidProfiles.values.joinToString("\n") { it.message ?: it::class.java.name } + val message = newProfiles.invalidProfiles.values.joinToString("\n") val errorDialogTitle = message("credentials.profile.failed_load") val numErrorMessage = message("credentials.profile.refresh_errors", newProfiles.invalidProfiles.size) - notifyInfo( - title = refreshTitle, - content = "$refreshBaseMessage $numErrorMessage", - notificationActions = listOf( - createShowMoreInfoDialogAction(message("credentials.invalid.more_info"), errorDialogTitle, numErrorMessage, message), - createNotificationExpiringAction(ActionManager.getInstance().getAction("aws.settings.upsertCredentials")) + if (AwsSettings.getInstance().profilesNotification != ProfilesNotification.Never) { + notifyInfo( + title = refreshTitle, + content = "$refreshBaseMessage $numErrorMessage", + notificationActions = listOf( + createNotificationExpiringAction(ActionManager.getInstance().getAction("aws.settings.upsertCredentials")), + createNotificationExpiringAction(NeverShowAgain()), + createShowMoreInfoDialogAction(message("credentials.invalid.more_info"), errorDialogTitle, numErrorMessage, message) + ) ) - ) + } } } - override fun createAwsCredentialProvider( - providerId: CredentialIdentifier, - region: AwsRegion, - sdkHttpClientSupplier: () -> SdkHttpClient - ): AwsCredentialsProvider { + override fun createAwsCredentialProvider(providerId: CredentialIdentifier, region: AwsRegion): AwsCredentialsProvider { val profileProviderId = providerId as? ProfileCredentialsIdentifier ?: throw IllegalStateException("ProfileCredentialProviderFactory can only handle ProfileCredentialsIdentifier, but got ${providerId::class}") 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) } - 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) + private fun createAwsCredentialProvider(profile: Profile, region: AwsRegion) = when { + profile.propertyExists(PROFILE_SSO_SESSION_PROPERTY) -> createSsoSessionProfileProvider(profile) + profile.propertyExists(ProfileProperty.SSO_START_URL) -> createLegacySsoProvider(profile) + profile.propertyExists(ProfileProperty.ROLE_ARN) -> createAssumeRoleProvider(profile, region) profile.propertyExists(ProfileProperty.AWS_SESSION_TOKEN) -> createStaticSessionProvider(profile) profile.propertyExists(ProfileProperty.AWS_ACCESS_KEY_ID) -> createBasicProvider(profile) profile.propertyExists(ProfileProperty.CREDENTIAL_PROCESS) -> createCredentialProcessProvider(profile) @@ -196,106 +342,53 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory { } } - 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 - ) + private fun createLegacySsoProvider(profile: Profile): AwsCredentialsProvider = ProfileLegacySsoProvider(ssoCache, profile) - val ssoOidcClient = ToolkitClientManager.createNewClient( - SsoOidcClient::class, - sdkHttpClient, - Region.of(ssoRegion), - AnonymousCredentialsProvider.create(), - AwsClientManager.userAgent - ) + private fun createSsoSessionProfileProvider(profile: Profile): AwsCredentialsProvider { + val ssoSessionName = profile.requiredProperty(SsoSessionConstants.PROFILE_SSO_SESSION_PROPERTY) + val ssoSession = profileHolder.getSsoSession(ssoSessionName) + ?: error("Profile ${profile.name()} refers to sso-session $ssoSessionName which appears to have been removed") - val ssoAccessTokenProvider = SsoAccessTokenProvider( - profile.requiredProperty(SSO_URL), - ssoRegion, - SsoPrompt, - diskCache, - ssoOidcClient - ) - - return SsoCredentialProvider( - profile.requiredProperty(SSO_ACCOUNT), - profile.requiredProperty(SSO_ROLE_NAME), - ssoClient, - ssoAccessTokenProvider - ) + return ProfileSsoSessionProvider(ssoSession, profile) } - 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) + private fun createAssumeRoleProvider(profile: Profile, region: AwsRegion): AwsCredentialsProvider { + val sourceProfileName = profile.property(ProfileProperty.SOURCE_PROFILE) + val credentialSource = profile.property(ProfileProperty.CREDENTIAL_SOURCE) - // 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, - sdkHttpClient, - Region.of(region.id), - parentCredentialProvider, - AwsClientManager.userAgent - ) - - val roleArn = profile.requiredProperty(ProfileProperty.ROLE_ARN) - val roleSessionName = profile.property(ProfileProperty.ROLE_SESSION_NAME) - .orElseGet { "aws-toolkit-jetbrains-${System.currentTimeMillis()}" } - val externalId = profile.property(ProfileProperty.EXTERNAL_ID) - .orElse(null) - val mfaSerial = profile.property(ProfileProperty.MFA_SERIAL) - .orElse(null) - - val assumeRoleCredentialsProvider = StsAssumeRoleCredentialsProvider.builder() - .stsClient(stsClient) - .refreshRequest(Supplier { - createAssumeRoleRequest( - profile.name(), - mfaSerial, - roleArn, - roleSessionName, - externalId - ) - }) - .build() + val parentCredentialProvider = when { + sourceProfileName.isPresent -> { + val sourceProfile = profileHolder.getProfile(sourceProfileName.get()) + ?: throw IllegalStateException("Profile $sourceProfileName looks to have been removed") + createAwsCredentialProvider(sourceProfile, region) + } + credentialSource.isPresent -> { + // Can we parse the credential_source + credentialSourceCredentialProvider(CredentialSourceType.parse(credentialSource.get()), profile) + } + else -> { + throw IllegalArgumentException(message("credentials.profile.assume_role.missing_source", profile.name())) + } + } - // TODO: Do we still need this wrapper? - return CorrectThreadCredentialsProvider(assumeRoleCredentialsProvider) + return ProfileAssumeRoleProvider(parentCredentialProvider, region, profile) } - private fun createAssumeRoleRequest( - profileName: String, - mfaSerial: String?, - roleArn: String, - roleSessionName: String?, - externalId: String? - ): AssumeRoleRequest { - val requestBuilder = AssumeRoleRequest.builder() - .roleArn(roleArn) - .roleSessionName(roleSessionName) - .externalId(externalId) - - mfaSerial?.let { _ -> - requestBuilder - .serialNumber(mfaSerial) - .tokenCode(promptForMfaToken(profileName, mfaSerial)) + private fun credentialSourceCredentialProvider(credentialSource: CredentialSourceType, profile: Profile): AwsCredentialsProvider = + when (credentialSource) { + CredentialSourceType.ECS_CONTAINER -> ContainerCredentialsProvider.builder().build() + CredentialSourceType.EC2_INSTANCE_METADATA -> { + // The IMDS credentials provider should source the endpoint config properties from the currently active profile + InstanceProfileCredentialsProvider.builder() + .endpoint(profile.getEc2MedataEndpoint()) + .build() + } + CredentialSourceType.ENVIRONMENT -> AwsCredentialsProviderChain.builder() + .addCredentialsProvider(SystemPropertyCredentialsProvider.create()) + .addCredentialsProvider(EnvironmentVariableCredentialsProvider.create()) + .build() } - return requestBuilder.build() - } - private fun createBasicProvider(profile: Profile) = StaticCredentialsProvider.create( AwsBasicCredentials.create( profile.requiredProperty(ProfileProperty.AWS_ACCESS_KEY_ID), @@ -317,31 +410,89 @@ class ProfileCredentialProviderFactory : CredentialProviderFactory { private fun Profile.asId(profiles: Map): ProfileCredentialsIdentifier { val name = this.name() val defaultRegion = this.properties()[ProfileProperty.REGION] + val requestedProfileType = this.toCredentialType() return when { - this.requiresMfa(profiles) -> ProfileCredentialsIdentifierMfa(name, defaultRegion) - this.requiresSso(profiles) -> ProfileCredentialsIdentifierSso(name, defaultRegion, - diskCache, this.requiredProperty(SSO_URL)) - else -> ProfileCredentialsIdentifier(name, defaultRegion) + this.requiresMfa(profiles) -> ProfileCredentialsIdentifierMfa(name, defaultRegion, requestedProfileType) + this.requiresLegacySso(profiles) -> ProfileCredentialsIdentifierLegacySso( + name, + defaultRegion, + ssoCache, + this.traverseCredentialChain(profiles).map { it.property(ProfileProperty.SSO_START_URL) }.first { it.isPresent }.get(), + requestedProfileType + ) + this.requiresSso() -> ProfileCredentialsIdentifierSso( + name, + requiredProperty(SsoSessionConstants.PROFILE_SSO_SESSION_PROPERTY), + defaultRegion, + requestedProfileType + ) + else -> ProfileCredentialsIdentifier(name, defaultRegion, requestedProfileType) } } + private fun Profile.asSsoSessionId() = ProfileSsoSessionIdentifier( + name(), + requiredProperty(ProfileProperty.SSO_START_URL), + requiredProperty(ProfileProperty.SSO_REGION), + ssoScopes() + ) + 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 fun Profile.requiresLegacySso(profiles: Map) = this.traverseCredentialChain(profiles) + .any { it.propertyExists(ProfileProperty.SSO_START_URL) } + + private fun Profile.requiresSso() = propertyExists(SsoSessionConstants.PROFILE_SSO_SESSION_PROPERTY) + + companion object { + private val LOG = getLogger() + + val NEW_PROFILE_ADDED: Topic = Topic.create( + "Change to newly added profile", + ChangeConnectionSettingIfValid::class.java + ) + } } -private class ProfileHolder { - private val profiles = mutableMapOf() +private fun Profile.toCredentialType(): CredentialType? = when { + this.propertyExists(ProfileProperty.SSO_START_URL) -> CredentialType.SsoProfile + this.propertyExists(SsoSessionConstants.PROFILE_SSO_SESSION_PROPERTY) -> CredentialType.SsoProfile + this.propertyExists(ProfileProperty.ROLE_ARN) -> { + if (this.propertyExists(ProfileProperty.MFA_SERIAL)) { + CredentialType.AssumeMfaRoleProfile + } else { + CredentialType.AssumeRoleProfile + } + } + this.propertyExists(ProfileProperty.AWS_SESSION_TOKEN) -> CredentialType.StaticSessionProfile + this.propertyExists(ProfileProperty.AWS_ACCESS_KEY_ID) -> CredentialType.StaticProfile + this.propertyExists(ProfileProperty.CREDENTIAL_PROCESS) -> CredentialType.CredentialProcessProfile + else -> null +} - fun snapshot() = profiles.toMutableMap() +private data class ProfileHolder( + val profiles: MutableMap = mutableMapOf(), + val ssoSessions: MutableMap = mutableMapOf() +) { + fun snapshot() = copy( + profiles = profiles.toMutableMap(), + ssoSessions = ssoSessions.toMutableMap() + ) - fun update(validProfiles: Map) { + /** + * Update the holder with the latest view of valid state + */ + fun updateState(validProfiles: Map, validSsoSessions: Map) { profiles.clear() profiles.putAll(validProfiles) + + ssoSessions.clear() + ssoSessions.putAll(validSsoSessions) } fun getProfile(profileName: String): Profile? = profiles[profileName] + + fun getSsoSession(sessionName: String): Profile? = ssoSessions[sessionName] } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileLegacySsoProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileLegacySsoProvider.kt new file mode 100644 index 0000000000..6c5f0d47a4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileLegacySsoProvider.kt @@ -0,0 +1,54 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.profiles + +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider +import software.amazon.awssdk.auth.credentials.AwsCredentials +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +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.utils.SdkAutoCloseable +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.credentials.sso.SsoAccessTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.sso.SsoCache +import software.aws.toolkits.jetbrains.core.credentials.sso.SsoCredentialProvider + +class ProfileLegacySsoProvider(ssoCache: SsoCache, profile: Profile) : AwsCredentialsProvider, SdkAutoCloseable { + private val ssoClient: SsoClient + private val ssoOidcClient: SsoOidcClient + private val credentialsProvider: SsoCredentialProvider + + init { + val ssoRegion = profile.requiredProperty(ProfileProperty.SSO_REGION) + val clientManager = AwsClientManager.getInstance() + + ssoClient = clientManager.createUnmanagedClient(AnonymousCredentialsProvider.create(), Region.of(ssoRegion)) + ssoOidcClient = clientManager.createUnmanagedClient(AnonymousCredentialsProvider.create(), Region.of(ssoRegion)) + + val ssoAccessTokenProvider = SsoAccessTokenProvider( + profile.requiredProperty(ProfileProperty.SSO_START_URL), + ssoRegion, + ssoCache, + ssoOidcClient + ) + + credentialsProvider = SsoCredentialProvider( + profile.requiredProperty(ProfileProperty.SSO_ACCOUNT_ID), + profile.requiredProperty(ProfileProperty.SSO_ROLE_NAME), + ssoClient, + ssoAccessTokenProvider + ) + } + + override fun resolveCredentials(): AwsCredentials = credentialsProvider.resolveCredentials() + + override fun close() { + credentialsProvider.close() + ssoClient.close() + ssoOidcClient.close() + } +} 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 ffe0a258d7..169e1f23ee 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,27 +3,32 @@ package software.aws.toolkits.jetbrains.core.credentials.profiles -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.jetbrains.core.credentials.profiles.SsoSessionConstants.PROFILE_SSO_SESSION_PROPERTY +import software.aws.toolkits.jetbrains.core.credentials.profiles.SsoSessionConstants.SSO_SESSION_SECTION_NAME import software.aws.toolkits.resources.message -data class Profiles(val validProfiles: Map, val invalidProfiles: Map) +data class Profiles( + val validProfiles: Map, + val invalidProfiles: Map, + val validSsoSessions: Map, + val invalidSsoSessions: Map +) /** * Reads the AWS shared credentials files and produces what profiles are valid and if not why it is not */ fun validateAndGetProfiles(): Profiles { - val allProfiles: Map = ProfileFile.defaultProfileFile().profiles() + val profileFile = ProfileFile.defaultProfileFile() + val allProfiles = profileFile.profiles().orEmpty() + val ssoSessions = profileFile.ssoSessions() val validProfiles = mutableMapOf() val invalidProfiles = mutableMapOf() + val validSsoSessions = mutableMapOf() + val invalidSsoSessions = mutableMapOf() allProfiles.values.forEach { try { @@ -34,15 +39,25 @@ fun validateAndGetProfiles(): Profiles { } } - return Profiles(validProfiles, invalidProfiles) + ssoSessions.values.forEach { + try { + validateSsoSession(it) + validSsoSessions[it.name()] = it + } catch (e: Exception) { + invalidSsoSessions[it.name()] = e + } + } + + return Profiles(validProfiles, invalidProfiles, validSsoSessions, invalidSsoSessions) } private fun validateProfile(profile: Profile, allProfiles: Map) { when { - profile.propertyExists(SSO_URL) && Registry.`is`(SSO_EXPERIMENTAL_REGISTRY_KEY) -> validateSsoProfile(profile) + profile.propertyExists(ProfileProperty.SSO_START_URL) -> validateLegacySsoProfile(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) + profile.propertyExists(PROFILE_SSO_SESSION_PROPERTY) -> validateSsoProfile(profile) profile.propertyExists(ProfileProperty.CREDENTIAL_PROCESS) -> { // NO-OP Always valid } @@ -52,15 +67,25 @@ private fun validateProfile(profile: Profile, allProfiles: Map) } } -fun validateSsoProfile(profile: Profile) { - profile.requiredProperty(SSO_ACCOUNT) - profile.requiredProperty(SSO_REGION) - profile.requiredProperty(SSO_ROLE_NAME) +fun validateLegacySsoProfile(profile: Profile) { + profile.requiredProperty(ProfileProperty.SSO_ACCOUNT_ID) + profile.requiredProperty(ProfileProperty.SSO_REGION) + profile.requiredProperty(ProfileProperty.SSO_ROLE_NAME) } private fun validateAssumeRoleProfile(profile: Profile, allProfiles: Map) { val rootProfile = profile.traverseCredentialChain(allProfiles).last() - validateProfile(rootProfile, allProfiles) + val credentialSource = rootProfile.property(ProfileProperty.CREDENTIAL_SOURCE) + + if (credentialSource.isPresent) { + try { + CredentialSourceType.parse(credentialSource.get()) + } catch (e: Exception) { + throw IllegalArgumentException(message("credentials.profile.assume_role.invalid_credential_source", rootProfile.name())) + } + } else { + validateProfile(rootProfile, allProfiles) + } } private fun validateStaticSessionProfile(profile: Profile) { @@ -73,3 +98,28 @@ private fun validateBasicProfile(profile: Profile) { profile.requiredProperty(ProfileProperty.AWS_ACCESS_KEY_ID) profile.requiredProperty(ProfileProperty.AWS_SECRET_ACCESS_KEY) } + +private fun validateSsoProfile(profile: Profile) { + val ssoSessionName = profile.requiredProperty(PROFILE_SSO_SESSION_PROPERTY) + profile.requiredProperty(ProfileProperty.SSO_ACCOUNT_ID) + profile.requiredProperty(ProfileProperty.SSO_ROLE_NAME) + + val sessionSection = ProfileFile.defaultProfileFile().getSection(SSO_SESSION_SECTION_NAME, ssoSessionName).orElse(null) + ?: error(message("credentials.ssoSession.validation_error", profile.name(), ssoSessionName)) + + validateSsoSession(sessionSection) +} + +private fun validateSsoSession(profile: Profile) { + profile.requiredProperty(ProfileProperty.SSO_START_URL) + profile.requiredProperty(ProfileProperty.SSO_REGION) +} + +internal fun ProfileFile.ssoSessions(): Map { + // we could also manually parse the file to avoid reflection, but the SDK encodes a lot of logic that we don't want to try to duplicate + val rawProfilesField = javaClass.declaredFields.first { it.name == "profilesAndSectionsMap" }.apply { + isAccessible = true + } + val rawProfiles = rawProfilesField.get(this) as Map> + return rawProfiles.get(SSO_SESSION_SECTION_NAME).orEmpty() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileSsoSessionProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileSsoSessionProvider.kt new file mode 100644 index 0000000000..56ca28b035 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileSsoSessionProvider.kt @@ -0,0 +1,61 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.profiles + +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider +import software.amazon.awssdk.auth.credentials.AwsCredentials +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +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.utils.SdkAutoCloseable +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager +import software.aws.toolkits.jetbrains.core.credentials.UserConfigSsoSessionProfile +import software.aws.toolkits.jetbrains.core.credentials.sso.SsoCredentialProvider + +class ProfileSsoSessionProvider(ssoSession: Profile, profile: Profile) : AwsCredentialsProvider, SdkAutoCloseable { + private val ssoClient: SsoClient + private val ssoOidcClient: SsoOidcClient + private val credentialsProvider: SsoCredentialProvider + init { + val clientManager = AwsClientManager.getInstance() + + val ssoRegion = ssoSession.requiredProperty(ProfileProperty.SSO_REGION) + val startUrl = ssoSession.requiredProperty(ProfileProperty.SSO_START_URL) + val scopes = ssoSession.ssoScopes() + + val accountId = profile.requiredProperty(ProfileProperty.SSO_ACCOUNT_ID) + val roleName = profile.requiredProperty(ProfileProperty.SSO_ROLE_NAME) + + ssoClient = clientManager.createUnmanagedClient(AnonymousCredentialsProvider.create(), Region.of(ssoRegion)) + ssoOidcClient = clientManager.createUnmanagedClient(AnonymousCredentialsProvider.create(), Region.of(ssoRegion)) + + val authProfile = UserConfigSsoSessionProfile( + configSessionName = ssoSession.name(), + ssoRegion = ssoRegion, + startUrl = startUrl, + scopes = scopes.toList() + ) + + val ssoAccessTokenProvider = ToolkitAuthManager.getInstance().getOrCreateSsoConnection(authProfile).getConnectionSettings().tokenProvider + + credentialsProvider = SsoCredentialProvider( + accountId, + roleName, + ssoClient, + ssoAccessTokenProvider + ) + } + + override fun resolveCredentials(): AwsCredentials = credentialsProvider.resolveCredentials() + + override fun close() { + credentialsProvider.close() + ssoClient.close() + ssoOidcClient.close() + } +} 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 index f4994b7443..42691121a8 100644 --- 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 @@ -6,6 +6,7 @@ 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.jetbrains.core.credentials.sono.IDENTITY_CENTER_ROLE_ACCESS_SCOPE import software.aws.toolkits.resources.message fun Profile.traverseCredentialChain(profiles: Map): Sequence = sequence { @@ -21,17 +22,30 @@ fun Profile.traverseCredentialChain(profiles: Map): Sequence Unit) + fun forceRefresh() {} companion object { fun getInstance() = service() @@ -79,6 +80,10 @@ class DefaultProfileWatcher : AsyncFileListener, Disposable, ProfileWatcher { } } + override fun forceRefresh() { + listeners.forEach { it() } + } + override fun addListener(listener: () -> Unit) { listeners.add(listener) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/SsoSessionConstants.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/SsoSessionConstants.kt new file mode 100644 index 0000000000..1512d5fa85 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/profiles/SsoSessionConstants.kt @@ -0,0 +1,10 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.profiles + +object SsoSessionConstants { + const val PROFILE_SSO_SESSION_PROPERTY = "sso_session" + const val SSO_SESSION_SECTION_NAME = "sso-session" + const val SSO_REGISTRATION_SCOPES: String = "sso_registration_scopes" +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoConstants.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoConstants.kt new file mode 100644 index 0000000000..f15f5abba3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoConstants.kt @@ -0,0 +1,36 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sono + +import software.aws.toolkits.jetbrains.core.credentials.ManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection + +const val SONO_REGION = "us-east-1" +const val SONO_URL = "https://view.awsapps.com/start" + +const val IDENTITY_CENTER_ROLE_ACCESS_SCOPE = "sso:account:access" + +val CODEWHISPERER_SCOPES = listOf( + "codewhisperer:completions", + "codewhisperer:analysis", +) + +val Q_SCOPES = listOf( + "codewhisperer:conversations", + "codewhisperer:transformations" +) + +val Q_SCOPES_UNAVAILABLE_BUILDER_ID = listOf( + "codewhisperer:transformations" +) + +val CODECATALYST_SCOPES = listOf( + "codecatalyst:read_write" +) + +fun ToolkitConnection?.isSono() = if (this == null) { + false +} else { + this is ManagedBearerSsoConnection && this.startUrl == SONO_URL +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoCredentialManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoCredentialManager.kt new file mode 100644 index 0000000000..ea802debe7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoCredentialManager.kt @@ -0,0 +1,102 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sono + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider +import software.aws.toolkits.core.telemetry.DefaultMetricEvent +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.loginSso +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeCatalystConnection +import software.aws.toolkits.jetbrains.core.credentials.reauthProviderIfNeeded +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.services.caws.CawsResources +import software.aws.toolkits.jetbrains.utils.runUnderProgressIfNeeded +import software.aws.toolkits.resources.message + +class SonoCredentialManager { + private val project: Project? + constructor(project: Project) { + this.project = project + } + + constructor() { + this.project = null + } + + internal fun provider() = ( + ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeCatalystConnection.getInstance()) + as? AwsBearerTokenConnection + ) + ?.getConnectionSettings() + ?.tokenProvider?.delegate as? BearerTokenProvider + + fun getConnectionSettings(passiveOnly: Boolean = false): TokenConnectionSettings? { + val provider = provider() + if (provider == null) { + if (passiveOnly) { + return null + } + return getSettingsAndPromptAuth() + } + + return when (provider.state()) { + BearerTokenAuthState.NOT_AUTHENTICATED -> null + BearerTokenAuthState.AUTHORIZED -> TokenConnectionSettings(ToolkitBearerTokenProvider(provider), SSO_REGION) + else -> { + if (passiveOnly) { + null + } else { + tryOrNull { + getSettingsAndPromptAuth() + } + } + } + } + } + + fun getSettingsAndPromptAuth() = getProviderAndPromptAuth().asConnectionSettings() + + fun getProviderAndPromptAuth(): BearerTokenProvider { + val provider = provider() + return when (provider?.state()) { + null -> runUnderProgressIfNeeded(null, message("credentials.sono.login.pending"), true) { + loginSso(project, SONO_URL, SONO_REGION, CODECATALYST_SCOPES) + } + + else -> reauthProviderIfNeeded(project, provider, isBuilderId = true) + } + } + + fun hasPreviouslyConnected() = provider()?.state()?.let { it != BearerTokenAuthState.NOT_AUTHENTICATED } ?: false + + private fun BearerTokenProvider.asConnectionSettings() = TokenConnectionSettings(ToolkitBearerTokenProvider(this), SSO_REGION) + + companion object { + fun getInstance(project: Project? = null) = project?.let { it.service() } ?: service() + + fun loginSono(project: Project?) { + SonoCredentialManager.getInstance(project).getProviderAndPromptAuth() + } + + private val SSO_REGION by lazy { + with(AwsRegionProvider.getInstance()) { + get(SONO_REGION) ?: throw RuntimeException("AwsRegionProvider was unable to provide SSO_REGION for AWS Builder ID") + } + } + } +} + +fun lazilyGetUserId() = tryOrNull { + SonoCredentialManager.getInstance().getConnectionSettings(passiveOnly = true)?.let { + AwsResourceCache.getInstance().getResourceNow(CawsResources.ID, it) + } +} ?: DefaultMetricEvent.METADATA_NA diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoDiskProfileAuthFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoDiskProfileAuthFactory.kt new file mode 100644 index 0000000000..16b5249df4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoDiskProfileAuthFactory.kt @@ -0,0 +1,18 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sono + +import software.aws.toolkits.jetbrains.core.credentials.DetectedDiskSsoSessionConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitStartupAuthFactory +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.resources.message + +class SonoDiskProfileAuthFactory : ToolkitStartupAuthFactory { + override fun buildConnections(): List = + listOf( + DetectedDiskSsoSessionConnection("codecatalyst", SONO_URL, SONO_REGION, message("aws_builder_id.service_name")) + ).filter { (it.getConnectionSettings().tokenProvider.delegate as BearerTokenProvider).state() != BearerTokenAuthState.NOT_AUTHENTICATED } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoLogoutAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoLogoutAction.kt new file mode 100644 index 0000000000..450bfeb25e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sono/SonoLogoutAction.kt @@ -0,0 +1,23 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sono + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener + +class SonoLogoutAction : DumbAwareAction() { + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = provider(e)?.supportsLogout() ?: false + } + + override fun actionPerformed(e: AnActionEvent) { + val provider = provider(e) ?: return + + ApplicationManager.getApplication().messageBus.syncPublisher(BearerTokenProviderListener.TOPIC).invalidate(provider.id) + } + + private fun provider(e: AnActionEvent) = SonoCredentialManager.getInstance(e.project).provider() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/AccessToken.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/AccessToken.kt new file mode 100644 index 0000000000..f531fe7c00 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/AccessToken.kt @@ -0,0 +1,43 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sso + +import com.fasterxml.jackson.annotation.JsonInclude +import software.amazon.awssdk.auth.token.credentials.SdkToken +import software.amazon.awssdk.services.sso.SsoClient +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.aws.toolkits.core.utils.SensitiveField +import software.aws.toolkits.core.utils.redactedString +import java.time.Instant +import java.util.Optional + +/** + * Access token returned from [SsoOidcClient.createToken] used to retrieve AWS Credentials from [SsoClient.getRoleCredentials]. + */ +data class AccessToken( + val startUrl: String, + val region: String, + @SensitiveField + val accessToken: String, + @SensitiveField + @JsonInclude(JsonInclude.Include.NON_NULL) + val refreshToken: String? = null, + val expiresAt: Instant, + val createdAt: Instant = Instant.EPOCH +) : SdkToken { + override fun token() = accessToken + + override fun expirationTime() = Optional.of(expiresAt) + + override fun toString() = redactedString(this) +} + +// diverging from SDK/CLI impl here since they do: sha1sum(sessionName ?: startUrl) +// which isn't good enough for us +// only used in scoped case +data class AccessTokenCacheKey( + val connectionId: String, + val startUrl: String, + val scopes: List +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/Authorization.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/Authorization.kt new file mode 100644 index 0000000000..5ffc99b208 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/Authorization.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.jetbrains.core.credentials.sso + +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.aws.toolkits.core.utils.SensitiveField +import software.aws.toolkits.core.utils.redactedString +import java.time.Instant + +/** + * Returned by [SsoOidcClient.startDeviceAuthorization] that contains the required data to construct the user visible SSO login flow. + */ +data class Authorization( + @SensitiveField + val deviceCode: String, + val userCode: String, + val verificationUri: String, + val verificationUriComplete: String, + val expiresAt: Instant, + val pollInterval: Long, + val createdAt: Instant +) { + override fun toString(): String = redactedString(this) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/ClientRegistration.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/ClientRegistration.kt new file mode 100644 index 0000000000..b7c536cf3e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/ClientRegistration.kt @@ -0,0 +1,35 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sso + +import com.fasterxml.jackson.annotation.JsonInclude +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.aws.toolkits.core.utils.SensitiveField +import software.aws.toolkits.core.utils.redactedString +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( + @SensitiveField + val clientId: String, + @SensitiveField + val clientSecret: String, + val expiresAt: Instant, + @JsonInclude(JsonInclude.Include.NON_EMPTY) + val scopes: List = emptyList() +) { + override fun toString(): String = redactedString(this) +} + +// only applicable in scoped registration path +// based on internal development branch @da780a4,L2574-2586 +data class ClientRegistrationCacheKey( + val startUrl: String, + val scopes: List, + val region: String, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/DiskCache.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/DiskCache.kt new file mode 100644 index 0000000000..5a2ae7ad08 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/DiskCache.kt @@ -0,0 +1,249 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.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.SerializationFeature +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.databind.node.ObjectNode +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.createParentDirectories +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.getLogger +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.tryDirOp +import software.aws.toolkits.core.utils.tryFileOp +import software.aws.toolkits.core.utils.tryOrNull +import java.io.InputStream +import java.io.OutputStream +import java.nio.file.Path +import java.nio.file.Paths +import java.security.MessageDigest +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter.ISO_INSTANT +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)) + } + + // only used for computing cache key names + private val cacheNameMapper = jacksonObjectMapper() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + + override fun loadClientRegistration(ssoRegion: String): ClientRegistration? { + LOG.debug { "loadClientRegistration for $ssoRegion" } + val inputStream = clientRegistrationCache(ssoRegion).tryInputStreamIfExists() ?: return null + return loadClientRegistration(inputStream) + } + + override fun saveClientRegistration(ssoRegion: String, registration: ClientRegistration) { + LOG.debug { "saveClientRegistration for $ssoRegion" } + val registrationCache = clientRegistrationCache(ssoRegion) + writeKey(registrationCache) { + objectMapper.writeValue(it, registration) + } + } + + override fun invalidateClientRegistration(ssoRegion: String) { + LOG.debug { "invalidateClientRegistration for $ssoRegion" } + clientRegistrationCache(ssoRegion).tryDeleteIfExists() + } + + override fun loadClientRegistration(cacheKey: ClientRegistrationCacheKey): ClientRegistration? { + LOG.debug { "loadClientRegistration for $cacheKey" } + val inputStream = clientRegistrationCache(cacheKey).tryInputStreamIfExists() + ?: clientRegistrationCacheBackwardCompatible(cacheKey).tryInputStreamIfExists() + ?: return null + + return loadClientRegistration(inputStream) + } + + override fun saveClientRegistration(cacheKey: ClientRegistrationCacheKey, registration: ClientRegistration) { + LOG.debug { "saveClientRegistration for $cacheKey" } + val registrationCache = clientRegistrationCache(cacheKey) + writeKey(registrationCache) { + objectMapper.writeValue(it, registration) + } + } + + override fun invalidateClientRegistration(cacheKey: ClientRegistrationCacheKey) { + if (clientRegistrationCache(cacheKey).exists()) { + LOG.debug { "invalidateClientRegistration for $cacheKey" } + clientRegistrationCache(cacheKey).tryDeleteIfExists() + } else { + LOG.debug { "invalidateClientRegistration (backwards compat) for $cacheKey" } + clientRegistrationCacheBackwardCompatible(cacheKey).tryDeleteIfExists() + } + } + + override fun loadAccessToken(ssoUrl: String): AccessToken? { + LOG.debug { "loadAccessToken for $ssoUrl" } + val cacheFile = accessTokenCache(ssoUrl) + val inputStream = cacheFile.tryInputStreamIfExists() ?: return null + + return loadAccessToken(inputStream) + } + + override fun saveAccessToken(ssoUrl: String, accessToken: AccessToken) { + LOG.debug { "saveAccessToken for $ssoUrl" } + val accessTokenCache = accessTokenCache(ssoUrl) + writeKey(accessTokenCache) { + objectMapper.writeValue(it, accessToken) + } + } + + override fun invalidateAccessToken(ssoUrl: String) { + LOG.debug { "invalidateAccessToken for $ssoUrl" } + accessTokenCache(ssoUrl).tryDeleteIfExists() + } + + override fun loadAccessToken(cacheKey: AccessTokenCacheKey): AccessToken? { + LOG.debug { "loadAccessToken for $cacheKey" } + val cacheFile = accessTokenCache(cacheKey) + val inputStream = cacheFile.tryInputStreamIfExists() ?: return null + + return loadAccessToken(inputStream) + } + + override fun saveAccessToken(cacheKey: AccessTokenCacheKey, accessToken: AccessToken) { + LOG.debug { "saveAccessToken for $cacheKey" } + val accessTokenCache = accessTokenCache(cacheKey) + writeKey(accessTokenCache) { + objectMapper.writeValue(it, accessToken) + } + } + + override fun invalidateAccessToken(cacheKey: AccessTokenCacheKey) { + LOG.debug { "invalidateAccessToken for $cacheKey" } + accessTokenCache(cacheKey).tryDeleteIfExists() + } + + private fun clientRegistrationCache(ssoRegion: String): Path = cacheDir.resolve("aws-toolkit-jetbrains-client-id-$ssoRegion.json") + + private fun clientRegistrationCache(cacheKey: ClientRegistrationCacheKey): Path = + cacheNameMapper.valueToTree(cacheKey).apply { + // session is omitted to keep the key deterministic since we attach an epoch + put("tool", "aws-toolkit-jetbrains") + }.let { + val sha = sha1(cacheNameMapper.writeValueAsString(it)) + + cacheDir.resolve("$sha.json") + } + + // Can be removed when 2022.3 is no longer supported + private fun clientRegistrationCacheBackwardCompatible(cacheKey: ClientRegistrationCacheKey): Path = + cacheNameMapper.valueToTree(cacheKey).apply { + // session is omitted to keep the key deterministic since we attach an epoch + put("tool", "aws-toolkit-jetbrains") + + // remove the region field to generate a key for the old key schema + remove("region") + }.let { + val sha = sha1(cacheNameMapper.writeValueAsString(it)) + cacheDir.resolve("$sha.json") + } + + private fun accessTokenCache(ssoUrl: String): Path { + val fileName = "${sha1(ssoUrl)}.json" + return cacheDir.resolve(fileName) + } + + private fun accessTokenCache(cacheKey: AccessTokenCacheKey): Path { + val fileName = "${sha1(cacheNameMapper.writeValueAsString(cacheKey.copy(scopes = cacheKey.scopes.sorted())))}.json" + return cacheDir.resolve(fileName) + } + + private fun loadClientRegistration(inputStream: InputStream) = + tryOrNull { + val clientRegistration = objectMapper.readValue(inputStream) + if (clientRegistration.expiresAt.isNotExpired()) { + clientRegistration + } else { + null + } + } + + private fun loadAccessToken(inputStream: InputStream) = tryOrNull { + val accessToken = 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 (!accessToken.isDefinitelyExpired()) { + accessToken + } else { + null + } + } + + private fun Path.tryDeleteIfExists(): Boolean = tryFileOp(LOG) { deleteIfExists() } + + private fun Path.tryInputStreamIfExists(): InputStream? = tryFileOp(LOG) { inputStreamIfExists() } + + private fun sha1(string: String): String { + val digest = MessageDigest.getInstance("SHA-1") + return digest.digest(string.toByteArray(Charsets.UTF_8)).toHexString() + } + + private fun writeKey(path: Path, consumer: (OutputStream) -> Unit) { + LOG.debug { "writing to $path" } + path.tryDirOp(LOG) { createParentDirectories() } + + path.tryFileOp(LOG) { + touch(restrictToOwner = true) + outputStream().use(consumer) + } + } + + // 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(EXPIRATION_THRESHOLD)) + + private fun AccessToken.isDefinitelyExpired(): Boolean = refreshToken == null && !expiresAt.isNotExpired() + + 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) } + } + } + + companion object { + val EXPIRATION_THRESHOLD = Duration.ofMinutes(15) + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoAccessTokenProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoAccessTokenProvider.kt new file mode 100644 index 0000000000..932c0d4137 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoAccessTokenProvider.kt @@ -0,0 +1,267 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sso + +import com.intellij.openapi.components.service +import com.intellij.openapi.progress.ProgressManager +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.amazon.awssdk.services.ssooidc.model.AuthorizationPendingException +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.SlowDownException +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.utils.assertIsNonDispatchThread +import software.aws.toolkits.jetbrains.utils.sleepWithCancellation +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry +import software.aws.toolkits.telemetry.CredentialSourceId +import software.aws.toolkits.telemetry.Result +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 cache: SsoCache, + private val client: SsoOidcClient, + private val scopes: List = emptyList(), + private val clock: Clock = Clock.systemUTC() +) : SdkTokenProvider { + + @TestOnly + var authorizationCreationTime = Instant.now(clock) + + private val clientRegistrationCacheKey by lazy { + ClientRegistrationCacheKey( + startUrl = ssoUrl, + scopes = scopes, + region = ssoRegion + ) + } + internal val accessTokenCacheKey by lazy { + AccessTokenCacheKey( + connectionId = ssoRegion, + startUrl = ssoUrl, + scopes = scopes + ) + } + + override fun resolveToken() = accessToken() + + fun accessToken(): AccessToken { + assertIsNonDispatchThread() + + loadAccessToken()?.let { + return it + } + + val token = pollForToken() + + saveAccessToken(token) + + return token + } + + private fun registerClient(): ClientRegistration { + loadClientRegistration()?.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.scopes(scopes) + it.clientName("AWS Toolkit for JetBrains") + } + + val registeredClient = ClientRegistration( + registerResponse.clientId(), + registerResponse.clientSecret(), + Instant.ofEpochSecond(registerResponse.clientSecretExpiresAt()) + ) + + saveClientRegistration(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) { + invalidateClientRegistration() + throw e + } + + authorizationCreationTime = Instant.now(clock) + + return Authorization( + authorizationResponse.deviceCode(), + authorizationResponse.userCode(), + authorizationResponse.verificationUri(), + authorizationResponse.verificationUriComplete(), + Instant.now(clock).plusSeconds(authorizationResponse.expiresIn().toLong()), + authorizationResponse.interval()?.toLong() + ?: DEFAULT_INTERVAL_SECS, + authorizationCreationTime + ) + } + + private fun pollForToken(): AccessToken { + val onPendingToken = service().getProvider(ssoUrl) + val progressIndicator = ProgressManager.getInstance().progressIndicator + val registration = registerClient() + val authorization = authorizeClient(registration) + + progressIndicator?.text2 = message("aws.sso.signing.device.waiting", authorization.userCode) + 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(DEVICE_GRANT_TYPE) + it.deviceCode(authorization.deviceCode) + } + + onPendingToken.tokenRetrieved() + + return tokenResponse.toAccessToken(authorization.createdAt) + } 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 + } + + sleepWithCancellation(backOffTime, progressIndicator) + } + } + + fun refreshToken(currentToken: AccessToken): AccessToken { + if (currentToken.refreshToken == null) { + val tokenCreationTime = currentToken.createdAt + + if (tokenCreationTime != Instant.EPOCH) { + val sessionDuration = Duration.between(Instant.now(clock), tokenCreationTime) + val credentialSourceId = if (currentToken.startUrl == SONO_URL) CredentialSourceId.AwsId else CredentialSourceId.IamIdentityCenter + AwsTelemetry.refreshCredentials( + project = null, + Result.Failed, + sessionDuration = sessionDuration.toHours().toInt(), + credentialSourceId = credentialSourceId, + reason = "Null refresh token" + ) + } + + throw InvalidRequestException.builder().message("Requested token refresh, but refresh token was null").build() + } + + val registration = loadClientRegistration() ?: throw InvalidClientException.builder().message("Unable to load client registration").build() + + val newToken = client.createToken { + it.clientId(registration.clientId) + it.clientSecret(registration.clientSecret) + it.grantType(REFRESH_GRANT_TYPE) + it.refreshToken(currentToken.refreshToken) + } + + val token = newToken.toAccessToken(currentToken.createdAt) + saveAccessToken(token) + + return token + } + + fun invalidate() { + if (scopes.isEmpty()) { + cache.invalidateAccessToken(ssoUrl) + } else { + cache.invalidateAccessToken(accessTokenCacheKey) + } + } + + private fun loadClientRegistration(): ClientRegistration? = if (scopes.isEmpty()) { + cache.loadClientRegistration(ssoRegion)?.let { + return it + } + } else { + cache.loadClientRegistration(clientRegistrationCacheKey)?.let { + return it + } + } + + private fun saveClientRegistration(registration: ClientRegistration) { + if (scopes.isEmpty()) { + cache.saveClientRegistration(ssoRegion, registration) + } else { + cache.saveClientRegistration(clientRegistrationCacheKey, registration) + } + } + + private fun invalidateClientRegistration() { + if (scopes.isEmpty()) { + cache.invalidateClientRegistration(ssoRegion) + } else { + cache.invalidateClientRegistration(clientRegistrationCacheKey) + } + } + + private fun loadAccessToken(): AccessToken? = if (scopes.isEmpty()) { + cache.loadAccessToken(ssoUrl)?.let { + return it + } + } else { + cache.loadAccessToken(accessTokenCacheKey)?.let { + return it + } + } + + private fun saveAccessToken(token: AccessToken) { + if (scopes.isEmpty()) { + cache.saveAccessToken(ssoUrl, token) + } else { + cache.saveAccessToken(accessTokenCacheKey, token) + } + } + + private fun CreateTokenResponse.toAccessToken(creationTime: Instant): AccessToken { + val expirationTime = Instant.now(clock).plusSeconds(expiresIn().toLong()) + + return AccessToken( + startUrl = ssoUrl, + region = ssoRegion, + accessToken = accessToken(), + refreshToken = refreshToken(), + expiresAt = expirationTime, + createdAt = creationTime + ) + } + + private companion object { + const val CLIENT_REGISTRATION_TYPE = "public" + const val DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" + const val REFRESH_GRANT_TYPE = "refresh_token" + + // 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/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoCache.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoCache.kt new file mode 100644 index 0000000000..8e18da09eb --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoCache.kt @@ -0,0 +1,22 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.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) + + fun loadClientRegistration(cacheKey: ClientRegistrationCacheKey): ClientRegistration? + fun saveClientRegistration(cacheKey: ClientRegistrationCacheKey, registration: ClientRegistration) + fun invalidateClientRegistration(cacheKey: ClientRegistrationCacheKey) + + fun loadAccessToken(cacheKey: AccessTokenCacheKey): AccessToken? + fun saveAccessToken(cacheKey: AccessTokenCacheKey, accessToken: AccessToken) + fun invalidateAccessToken(cacheKey: AccessTokenCacheKey) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoCredentialProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoCredentialProvider.kt new file mode 100644 index 0000000000..201ccf3749 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoCredentialProvider.kt @@ -0,0 +1,77 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +@file:Suppress("BannedImports") + +package software.aws.toolkits.jetbrains.core.credentials.sso + +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.auth.token.credentials.SdkTokenProvider +import software.amazon.awssdk.services.sso.SsoClient +import software.amazon.awssdk.services.sso.model.UnauthorizedException +import software.amazon.awssdk.utils.SdkAutoCloseable +import software.amazon.awssdk.utils.cache.CachedSupplier +import software.amazon.awssdk.utils.cache.RefreshResult +import software.aws.toolkits.jetbrains.utils.assertIsNonDispatchThread +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: SdkTokenProvider +) : AwsCredentialsProvider, SdkAutoCloseable { + private val sessionCache: CachedSupplier = CachedSupplier.builder(this::refreshCredentials).build() + + override fun resolveCredentials(): AwsCredentials = sessionCache.get().credentials + + private fun refreshCredentials(): RefreshResult { + assertIsNonDispatchThread() + + val roleCredentials = try { + val accessToken = ssoAccessTokenProvider.resolveToken() + + ssoClient.getRoleCredentials { + it.accessToken(accessToken.token()) + it.accountId(ssoAccount) + it.roleName(ssoRole) + } + } catch (e: UnauthorizedException) { + // OIDC access token was rejected, invalidate the cache if applicable and throw + if (ssoAccessTokenProvider is SsoAccessTokenProvider) { + 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() + } + + override fun close() { + sessionCache.close() + } + + private data class SsoCredentialsHolder(val credentials: AwsSessionCredentials, val expirationTime: Instant) +} diff --git a/core/src/software/aws/toolkits/core/credentials/sso/SsoLoginCallback.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoLoginCallback.kt similarity index 75% rename from core/src/software/aws/toolkits/core/credentials/sso/SsoLoginCallback.kt rename to jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoLoginCallback.kt index d8fd7c4fa2..d7a642c9bc 100644 --- a/core/src/software/aws/toolkits/core/credentials/sso/SsoLoginCallback.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoLoginCallback.kt @@ -1,7 +1,7 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package software.aws.toolkits.core.credentials.sso +package software.aws.toolkits.jetbrains.core.credentials.sso /** * Callback interface to allow for UI elements to react to the different stages of the SSO login flow @@ -10,7 +10,7 @@ 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) + fun tokenPending(authorization: Authorization) /** * Called when the user successfully logs into the SSO service. diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoLoginCallbackProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoLoginCallbackProvider.kt new file mode 100644 index 0000000000..bf9c9865f3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/SsoLoginCallbackProvider.kt @@ -0,0 +1,75 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sso + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.progress.ProcessCanceledException +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.ConfirmUserCodeLoginDialog +import software.aws.toolkits.jetbrains.utils.computeOnEdt +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry +import software.aws.toolkits.telemetry.CredentialType +import software.aws.toolkits.telemetry.Result + +interface SsoLoginCallbackProvider { + fun getProvider(ssoUrl: String): SsoLoginCallback +} + +class DefaultSsoLoginCallbackProvider : SsoLoginCallbackProvider { + override fun getProvider(ssoUrl: String): SsoLoginCallback = when (ssoUrl) { + SONO_URL -> BearerTokenPrompt + else -> SsoPrompt + } +} + +object SsoPrompt : SsoLoginCallback { + override fun tokenPending(authorization: Authorization) { + computeOnEdt { + val result = ConfirmUserCodeLoginDialog( + authorization.userCode, + message("credentials.sso.login.title"), + CredentialType.SsoProfile + ).showAndGet() + + if (result) { + AwsTelemetry.loginWithBrowser(project = null, Result.Succeeded, CredentialType.SsoProfile) + BrowserUtil.browse(authorization.verificationUriComplete) + } else { + AwsTelemetry.loginWithBrowser(project = null, Result.Cancelled, CredentialType.SsoProfile) + throw ProcessCanceledException(IllegalStateException(message("credentials.sso.login.cancelled"))) + } + } + } + + override fun tokenRetrieved() {} + + override fun tokenRetrievalFailure(e: Exception) { + e.notifyError(message("credentials.sso.login.failed")) + } +} + +object BearerTokenPrompt : SsoLoginCallback { + override fun tokenPending(authorization: Authorization) { + computeOnEdt { + val codeCopied = ConfirmUserCodeLoginDialog( + authorization.userCode, + message("credentials.sono.login"), + CredentialType.BearerToken + ).showAndGet() + + if (codeCopied) { + AwsTelemetry.loginWithBrowser(project = null, Result.Succeeded, CredentialType.BearerToken) + BrowserUtil.browse(authorization.verificationUriComplete) + } else { + AwsTelemetry.loginWithBrowser(project = null, Result.Cancelled, CredentialType.BearerToken) + } + } + } + + override fun tokenRetrieved() {} + + override fun tokenRetrievalFailure(e: Exception) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/BearerTokenProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/BearerTokenProvider.kt new file mode 100644 index 0000000000..f8d266be93 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/BearerTokenProvider.kt @@ -0,0 +1,255 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sso.bearer + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.util.containers.orNull +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider +import software.amazon.awssdk.auth.token.credentials.SdkToken +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.core.retry.conditions.OrRetryCondition +import software.amazon.awssdk.core.retry.conditions.RetryOnExceptionsCondition +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.amazon.awssdk.services.ssooidc.SsoOidcTokenProvider +import software.amazon.awssdk.services.ssooidc.internal.OnDiskTokenManager +import software.amazon.awssdk.services.ssooidc.model.InvalidGrantException +import software.amazon.awssdk.utils.SdkAutoCloseable +import software.amazon.awssdk.utils.cache.CachedSupplier +import software.amazon.awssdk.utils.cache.NonBlocking +import software.amazon.awssdk.utils.cache.RefreshResult +import software.aws.toolkits.core.ToolkitClientCustomizer +import software.aws.toolkits.core.clients.nullDefaultProfileFile +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProviderDelegate +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.credentials.diskCache +import software.aws.toolkits.jetbrains.core.credentials.sso.AccessToken +import software.aws.toolkits.jetbrains.core.credentials.sso.DiskCache +import software.aws.toolkits.jetbrains.core.credentials.sso.SsoAccessTokenProvider +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference + +internal interface BearerTokenLogoutSupport + +interface BearerTokenProvider : SdkTokenProvider, SdkAutoCloseable, ToolkitBearerTokenProviderDelegate { + /** + * @return The best available [SdkToken] to the provider without making network calls or prompting for user input + */ + fun currentToken(): AccessToken? + + fun refresh(): AccessToken + + /** + * @return The authentication state of [currentToken] + */ + fun state(): BearerTokenAuthState = state(currentToken()) + + /** + * Request provider to interactively request user input to obtain a new [AccessToken] + */ + open fun reauthenticate() { + throw UnsupportedOperationException("Provider is not interactive and cannot reauthenticate") + } + + open fun supportsLogout() = this is BearerTokenLogoutSupport + + open fun invalidate() { + throw UnsupportedOperationException("Provider is not interactive and cannot be invalidated") + } + + companion object { + internal fun tokenExpired(accessToken: AccessToken) = Instant.now().isAfter(accessToken.expiresAt) + + internal fun state(accessToken: AccessToken?) = when { + accessToken == null -> BearerTokenAuthState.NOT_AUTHENTICATED + tokenExpired(accessToken) -> { + if (accessToken.refreshToken != null) { + BearerTokenAuthState.NEEDS_REFRESH + } else { + // token is invalid if there is no refresh token + BearerTokenAuthState.NOT_AUTHENTICATED + } + } + else -> BearerTokenAuthState.AUTHORIZED + } + } +} + +class InteractiveBearerTokenProvider( + startUrl: String, + region: String, + scopes: List, + id: String, + cache: DiskCache = diskCache +) : BearerTokenProvider, BearerTokenLogoutSupport, Disposable { + override val id = id + override val displayName = ToolkitBearerTokenProvider.ssoDisplayName(startUrl) + + private val ssoOidcClient: SsoOidcClient = buildUnmanagedSsoOidcClient(region) + private val accessTokenProvider = + SsoAccessTokenProvider( + startUrl, + region, + cache, + ssoOidcClient, + scopes = scopes + ) + + private val supplier = CachedSupplier.builder { refreshToken() }.prefetchStrategy(NonBlocking("AWS SSO bearer token refresher")).build() + private val lastToken = AtomicReference() + init { + lastToken.set(cache.loadAccessToken(accessTokenProvider.accessTokenCacheKey)) + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun invalidate(providerId: String) { + if (id == providerId) { + invalidate() + } + } + } + ) + } + + private fun refreshToken(): RefreshResult { + val lastToken = lastToken.get() ?: error("Token refresh started before session initialized") + val token = if (Duration.between(Instant.now(), lastToken.expiresAt) > Duration.ofMinutes(30)) { + lastToken + } else { + refresh() + } + + return RefreshResult.builder(token) + .staleTime(token.expiresAt.minus(DEFAULT_STALE_DURATION)) + .prefetchTime(token.expiresAt.minus(DEFAULT_PREFETCH_DURATION)) + .build() + } + + override fun resolveToken() = supplier.get() + + override fun close() { + ssoOidcClient.close() + supplier.close() + } + + override fun dispose() { + close() + } + + override fun currentToken() = lastToken.get() + + /** + * Only use if you know what you're doing. + */ + override fun refresh(): AccessToken { + val lastToken = lastToken.get() ?: error("Token refresh started before session initialized") + return accessTokenProvider.refreshToken(lastToken).also { + this.lastToken.set(it) + } + } + + override fun invalidate() { + accessTokenProvider.invalidate() + lastToken.set(null) + BearerTokenProviderListener.notifyCredUpdate(id) + } + + override fun reauthenticate() { + // we probably don't need to invalidate this, but we might as well since we need to login again anyways + invalidate() + accessTokenProvider.accessToken().also { + lastToken.set(it) + BearerTokenProviderListener.notifyCredUpdate(id) + } + } +} + +public enum class BearerTokenAuthState { + AUTHORIZED, + NEEDS_REFRESH, + NOT_AUTHENTICATED +} + +class ProfileSdkTokenProviderWrapper(private val sessionName: String, region: String) : BearerTokenProvider, Disposable { + override val id = ToolkitBearerTokenProvider.diskSessionIdentifier(sessionName) + override val displayName = ToolkitBearerTokenProvider.diskSessionDisplayName(sessionName) + + private val sdkTokenManager = OnDiskTokenManager.create(sessionName) + private val ssoOidcClient = lazy { buildUnmanagedSsoOidcClient(region) } + private val tokenProvider = lazy { + SsoOidcTokenProvider.builder() + .ssoOidcClient(ssoOidcClient.value) + .sessionName(sessionName) + .staleTime(DEFAULT_STALE_DURATION) + .prefetchTime(DEFAULT_PREFETCH_DURATION) + .build() + } + + override fun resolveToken(): SdkToken = tokenProvider.value.resolveToken() + + override fun currentToken(): AccessToken? = sdkTokenManager.loadToken().orNull()?.let { + AccessToken( + startUrl = it.startUrl(), + region = it.region(), + accessToken = it.token(), + refreshToken = it.refreshToken(), + expiresAt = it.expirationTime().orElseThrow() + ) + } + + override fun refresh(): AccessToken { + error("Not yet implemented") + } + + override fun close() { + sdkTokenManager.close() + if (ssoOidcClient.isInitialized()) { + ssoOidcClient.value.close() + } + if (tokenProvider.isInitialized()) { + tokenProvider.value.close() + } + } + + override fun dispose() { + close() + } +} + +internal val DEFAULT_STALE_DURATION = Duration.ofMinutes(15) +internal val DEFAULT_PREFETCH_DURATION = Duration.ofMinutes(20) + +val ssoOidcClientConfigurationBuilder: (ClientOverrideConfiguration.Builder) -> ClientOverrideConfiguration.Builder = { configuration -> + configuration.nullDefaultProfileFile() + + // Get the existing RetryPolicy + val existingRetryPolicy = configuration.retryPolicy() + + // Add InvalidGrantException to the RetryOnExceptionsCondition + val updatedRetryPolicy = existingRetryPolicy.toBuilder() + .retryCondition( + OrRetryCondition.create( + existingRetryPolicy.retryCondition(), + RetryOnExceptionsCondition.create(setOf(InvalidGrantException::class.java)), + ) + ).build() + + // Update the RetryPolicy in the configuration + configuration.retryPolicy(updatedRetryPolicy) +} + +fun buildUnmanagedSsoOidcClient(region: String): SsoOidcClient = + AwsClientManager.getInstance() + .createUnmanagedClient( + AnonymousCredentialsProvider.create(), + Region.of(region), + clientCustomizer = ToolkitClientCustomizer { _, _, _, _, configuration -> + ssoOidcClientConfigurationBuilder(configuration) + } + ) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/BearerTokenProviderListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/BearerTokenProviderListener.kt new file mode 100644 index 0000000000..e9c4ad8e9e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/BearerTokenProviderListener.kt @@ -0,0 +1,22 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sso.bearer + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.util.messages.Topic +import java.util.EventListener + +interface BearerTokenProviderListener : EventListener { + fun onChange(providerId: String) {} + fun invalidate(providerId: String) {} + + companion object { + @Topic.AppLevel + val TOPIC = Topic.create("AWS SSO bearer token provider status change", BearerTokenProviderListener::class.java) + + fun notifyCredUpdate(providerId: String) { + ApplicationManager.getApplication().messageBus.syncPublisher(BearerTokenProviderListener.TOPIC).onChange(providerId) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/ConfirmUserCodeLoginDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/ConfirmUserCodeLoginDialog.kt new file mode 100644 index 0000000000..a392d6e48c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/sso/bearer/ConfirmUserCodeLoginDialog.kt @@ -0,0 +1,77 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials.sso.bearer + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.impl.ActionButton +import com.intellij.openapi.editor.colors.EditorColorsUtil +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.gridLayout.HorizontalAlign +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.components.BorderLayoutPanel +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry +import software.aws.toolkits.telemetry.CredentialType +import software.aws.toolkits.telemetry.Result +import java.awt.datatransfer.StringSelection +import javax.swing.JComponent + +class ConfirmUserCodeLoginDialog( + private val authCode: String, + private val dialogTitle: String, + private val credentialType: CredentialType +) : DialogWrapper(null) { + + private val pane = panel { + row { + label(message("aws.sso.signing.device.code.copy.dialog.text")) + } + + row { + cell( + BorderLayoutPanel(5, 0).apply { + val action = CopyUserCodeForLogin(authCode) + addToCenter( + JBLabel(authCode).apply { + tryOrNull { + JBFont.create(JBFont.decode(EditorColorsUtil.getGlobalOrDefaultColorScheme().consoleFontName)).biggerOn(9f).asBold() + }?.let { + font = it + } + setCopyable(true) + } + ) + addToRight(ActionButton(action, action.templatePresentation.clone(), ActionPlaces.UNKNOWN, ActionToolbar.NAVBAR_MINIMUM_BUTTON_SIZE)) + } + ).horizontalAlign(HorizontalAlign.CENTER) + } + } + + override fun createCenterPanel(): JComponent? = pane + + init { + title = dialogTitle + setOKButtonText(message("aws.sso.signing.device.code")) + super.init() + } + + override fun doCancelAction() { + super.doCancelAction() + AwsTelemetry.loginWithBrowser(project = null, Result.Cancelled, credentialType) + } +} + +class CopyUserCodeForLogin(private val authCode: String) : AnAction(message("aws.sso.signing.device.code.copy"), "", AllIcons.Actions.Copy) { + override fun actionPerformed(e: AnActionEvent) { + CopyPasteManager.getInstance().setContents(StringSelection(authCode)) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/DockerUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/DockerUtils.kt new file mode 100644 index 0000000000..944ff0f970 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/DockerUtils.kt @@ -0,0 +1,32 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.docker + +import com.intellij.docker.DockerCloudConfiguration +import com.intellij.docker.DockerCloudType +import com.intellij.docker.DockerServerRuntimesManager +import com.intellij.docker.agent.DockerAgent +import com.intellij.docker.registry.DockerRepositoryModel +import com.intellij.openapi.project.Project +import com.intellij.remoteServer.configuration.RemoteServer +import com.intellij.remoteServer.impl.configuration.RemoteServerImpl +import kotlinx.coroutines.future.await +import java.util.concurrent.CompletableFuture + +private fun defaultDockerConnection() = RemoteServerImpl("DockerConnection", DockerCloudType.getInstance(), DockerCloudConfiguration.createDefault()) + +suspend fun getDockerServerRuntimeFacade(project: Project, server: RemoteServer? = null): DockerRuntimeFacade { + val connectionConfig = server ?: defaultDockerConnection() + val runtime = DockerServerRuntimesManager.getInstance(project).getOrCreateConnection(connectionConfig).await() + + return object : DockerRuntimeFacade { + override val agent: DockerAgent + get() = runtime.agent + + override suspend fun pushImage(imageId: String, config: DockerRepositoryModel): CompletableFuture { + val imageRuntime = runtime.runtimesManager.images[imageId] + return imageRuntime?.push(project, config) ?: error("couldn't map tag to appropriate docker runtime") + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/DockerfileParser.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/DockerfileParser.kt new file mode 100644 index 0000000000..9ec8bb3030 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/DockerfileParser.kt @@ -0,0 +1,96 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.docker + +import com.intellij.docker.dockerFile.parser.psi.DockerFileAddOrCopyCommand +import com.intellij.docker.dockerFile.parser.psi.DockerFileCmdCommand +import com.intellij.docker.dockerFile.parser.psi.DockerFileExposeCommand +import com.intellij.docker.dockerFile.parser.psi.DockerFileFromCommand +import com.intellij.docker.dockerFile.parser.psi.DockerFileWorkdirCommand +import com.intellij.docker.dockerFile.parser.psi.DockerPsiCommand +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiManager +import com.intellij.psi.impl.source.tree.LeafPsiElement +import java.io.File + +class DockerfileParser(private val project: Project) { + fun parse(virtualFile: VirtualFile): DockerfileDetails? { + val psiFile = PsiManager.getInstance(project).findFile(virtualFile)!! + val contextDirectory = virtualFile.parent.path + + val lastFromCommand = psiFile.children.filterIsInstance().lastOrNull() ?: return null + val commandsAfterLastFrom = psiFile.children.dropWhile { it != lastFromCommand } + if (commandsAfterLastFrom.isEmpty()) { + return null + } + + val command = commandsAfterLastFrom.filterIsInstance().lastOrNull()?.text?.substringAfter("CMD ") + val portMappings = commandsAfterLastFrom.filterIsInstance().mapNotNull { + it.listChildren().find { child -> (child as? LeafPsiElement)?.elementType?.toString() == "INTEGER_LITERAL" }?.text?.toIntOrNull() + } + + val copyDirectives = groupByWorkDir(commandsAfterLastFrom).flatMap { (workDir, commands) -> + commands.filterIsInstance() + .filter { it.copyKeyword != null } + .mapNotNull { cmd -> cmd.fileOrUrlList.takeIf { it.size == 2 }?.let { it.first().text to it.last().text } } + .map { (rawLocal, rawRemote) -> + val local = if (rawLocal.startsWith("/") || rawLocal.startsWith(File.separatorChar)) { + rawLocal + } else { + "${contextDirectory.normalizeDirectory(true)}$rawLocal" + } + val remote = if (rawRemote.startsWith("/") || workDir == null) { + rawRemote + } else { + "${workDir.normalizeDirectory()}$rawRemote" + } + CopyDirective(local, remote) + } + } + + return DockerfileDetails(command, portMappings, copyDirectives) + } + + private fun String.normalizeDirectory(matchPlatform: Boolean = false): String { + val ch = if (matchPlatform) File.separatorChar else '/' + return "${trimEnd(ch)}$ch" + } + + private fun groupByWorkDir(commands: List): List>> { + val list = mutableListOf>>() + var workDir: String? = null + val elements = mutableListOf() + commands.forEach { + when (it) { + is DockerFileWorkdirCommand -> { + if (elements.isNotEmpty()) { + list.add(workDir to elements.toList()) + elements.clear() + } + workDir = it.fileOrUrlList.first().text + } + is DockerPsiCommand -> elements.add(it) + } + } + if (elements.isNotEmpty()) { + list.add(workDir to elements.toList()) + } + return list + } + + private fun PsiElement.listChildren(): List { + var child: PsiElement? = firstChild ?: return emptyList() + val children = mutableListOf() + while (child != null) { + children.add(child) + child = child.nextSibling + } + return children.toList() + } +} + +data class DockerfileDetails(val command: String?, val exposePorts: List, val copyDirectives: List) +data class CopyDirective(val from: String, val to: String) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/ToolkitDockerAdapter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/ToolkitDockerAdapter.kt new file mode 100644 index 0000000000..178ca101e4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/ToolkitDockerAdapter.kt @@ -0,0 +1,182 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.docker + +import com.intellij.docker.DockerDeploymentConfiguration +import com.intellij.docker.agent.DockerAgent +import com.intellij.docker.agent.DockerAgentDeploymentConfig +import com.intellij.docker.agent.DockerAgentProgressCallback +import com.intellij.docker.agent.DockerAgentSourceType +import com.intellij.docker.agent.progress.DockerResponseItem +import com.intellij.docker.registry.DockerAgentRepositoryConfigImpl +import com.intellij.docker.registry.DockerRepositoryModel +import com.intellij.docker.remote.run.runtime.DockerAgentDeploymentConfigImpl +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import org.apache.commons.io.FileUtils +import org.jetbrains.annotations.TestOnly +import org.jetbrains.concurrency.await +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.ecr.DockerfileEcrPushRequest +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import java.io.File +import java.io.ObjectInputStream +import java.time.Instant +import java.util.UUID +import java.util.concurrent.CompletableFuture +import com.intellij.docker.getSourceFile as getDockerfileSourceFile + +// it's a facade so imo we shouldn't be leaking impl details but i don't feel like rewriting tests +interface DockerRuntimeFacade { + val agent: DockerAgent + + suspend fun pushImage(imageId: String, config: DockerRepositoryModel): CompletableFuture +} + +class ToolkitDockerAdapter(protected val project: Project, val runtimeFacade: DockerRuntimeFacade) { + internal var agent = runtimeFacade.agent + @TestOnly + set + + @TestOnly + fun buildLocalImage(dockerfile: File): String? { + val tag = UUID.randomUUID().toString() + val config = object : DockerAgentDeploymentConfigImpl(tag, null) { + override fun getFile() = dockerfile + + override fun sourceType() = DockerAgentSourceType.FILE.toString() + } + + return runBlocking { buildImage(project, agent, config, dockerfile.absolutePath, tag) } + } + + suspend fun hackyBuildDockerfileWithUi(project: Project, pushRequest: DockerfileEcrPushRequest) = + hackyBuildDockerfileUnderIndicator(project, pushRequest) + + suspend fun pushImage(imageId: String, config: DockerRepositoryModel) = runtimeFacade.pushImage(imageId, config) + + fun getLocalImages(): List = + agent.getImages(null).flatMap { image -> + @Suppress("UselessCallOnNotNull") + if (image.imageRepoTags.isNullOrEmpty()) { + return@flatMap listOf(LocalImage(image.imageId, null)) + } + + image.imageRepoTags.map { localTag -> + val tag = localTag.takeUnless { it == NO_TAG_TAG } + LocalImage(image.imageId, tag) + } + }.toList() + + fun pullImage( + config: DockerRepositoryModel, + progressIndicator: ProgressIndicator? = null + ) = agent.pullImage( + DockerAgentRepositoryConfigImpl(config), + object : DockerAgentProgressCallback { + // based on RegistryRuntimeTask's impl + override fun step(status: String, current: Long, total: Long) { + LOG.debug { "step: status: $status, current: $current, total: $total" } + progressIndicator?.let { indicator -> + val indeterminate = total == 0L + indicator.text2 = "$status " + + (if (indeterminate) "" else "${FileUtils.byteCountToDisplaySize(current)} of ${FileUtils.byteCountToDisplaySize(total)}") + indicator.isIndeterminate = indeterminate + if (!indeterminate) { + indicator.fraction = current.toDouble() / total.toDouble() + } + } + } + + override fun succeeded(message: String) { + LOG.debug { "Pull from ECR succeeded: $message" } + notifyInfo( + project = project, + title = software.aws.toolkits.resources.message("ecr.pull.title"), + content = message + ) + } + + override fun failed(message: String) { + LOG.debug { "Pull from ECR failed: $message" } + notifyError( + project = project, + title = software.aws.toolkits.resources.message("ecr.pull.title"), + content = message + ) + } + } + ) + + // com.intellij.docker.agent.progress.DockerImageBuilder is probably the correct interface to use but need to figure out how to get an instance of it + private suspend fun hackyBuildDockerfileUnderIndicator(project: Project, pushRequest: DockerfileEcrPushRequest): String? { + val dockerConfig = (pushRequest.dockerBuildConfiguration.deploymentConfiguration as DockerDeploymentConfiguration) + val tag = dockerConfig.separateImageTags.firstOrNull() ?: Instant.now().toEpochMilli().toString() + // should never be null + val dockerfilePath = dockerConfig.sourceFilePath ?: throw RuntimeException("Docker run configuration started with invalid source file") + val config = object : DockerAgentDeploymentConfigImpl(tag, null) { + override fun getFile() = project.getDockerfileSourceFile(dockerfilePath) + + override fun sourceType() = DockerAgentSourceType.FILE.toString() + + override fun getCustomContextFolder() = + dockerConfig.contextFolderPath?.let { + File(it) + } ?: super.getCustomContextFolder() + }.withEnvs(dockerConfig.envVars.toTypedArray()) + .withBuildArgs(dockerConfig.buildArgs.toTypedArray()) + + return buildImage(project, agent, config, dockerfilePath, tag) + } + + private suspend fun buildImage( + project: Project, + agent: DockerAgent, + config: DockerAgentDeploymentConfig, + dockerfilePath: String, + tag: String + ): String? { + val queue = agent.createImageBuilder().asyncBuildImage(config).await() + val future = CompletableFuture() + object : Task.Backgroundable(project, message("dockerfile.building", dockerfilePath), true) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + while (true) { + val obj = queue.take() + if (obj.isEmpty()) { + break + } + val deserialized = ObjectInputStream(obj.inputStream()).readObject() + val message = (deserialized as? DockerResponseItem)?.stream + if (message != null && message.trim().isNotEmpty()) { + indicator.text2 = message + } + + LOG.debug { message ?: deserialized.toString() } + } + + future.complete(agent.getImages(null).firstOrNull { it.imageRepoTags.contains("$tag:latest") }?.imageId) + } + }.queue() + + return future.await() + } + + private companion object { + const val NO_TAG_TAG = ":" + + val LOG = getLogger() + } +} + +data class LocalImage( + val imageId: String, + val tag: String? +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/compatability/DockerRegistry.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/compatability/DockerRegistry.kt new file mode 100644 index 0000000000..d60c5525b5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/docker/compatability/DockerRegistry.kt @@ -0,0 +1,6 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.docker.compatability + +typealias DockerRegistry = com.intellij.docker.registry.DockerRegistryConfiguration diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/CloudDebugExecutable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/CloudDebugExecutable.kt deleted file mode 100644 index 032dca7d64..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/CloudDebugExecutable.kt +++ /dev/null @@ -1,45 +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.executables - -import com.intellij.openapi.util.SystemInfo -import com.intellij.util.text.SemVer - -import software.aws.toolkits.jetbrains.settings.ExecutableDetector -import java.nio.file.Path -import java.nio.file.Paths - -class CloudDebugExecutable : ExecutableType, AutoResolvable, Validatable { - - override val id: String = "cloud-debugCli" - override val displayName: String = "cloud-debug" - - override fun version(path: Path): SemVer = - ExecutableCommon.getVersion(path.toString(), CloudDebugVersionCache, this.displayName) - - override fun validate(path: Path) { - val version = this.version(path) - ExecutableCommon.checkSemVerVersion(version, MIN_VERSION, MAX_VERSION, this.displayName) - } - - override fun resolve(): Path? { - val pathArr = if (SystemInfo.isWindows) { - arrayOf("cloud-debug.exe") - } else { - arrayOf("cloud-debug") - } - val executablePath: String? = ExecutableDetector().find(arrayOf(ExecutableType.EXECUTABLE_DIRECTORY.toString()), pathArr) - - if (executablePath != null) return Paths.get(executablePath) - return null - } - - companion object { - // Based on how the manifest is constructed, a minor version bump will constitute a breaking change - // Min version and max version should be spaced 1 minor version (or 1 major version) apart. - // This will preserve backwards compatibility as we can always look at the min version's major/minor to find a working executable - val MAX_VERSION: SemVer = SemVer("2.0.0", 2, 0, 0) // exclusive - val MIN_VERSION: SemVer = SemVer("1.0.209", 1, 0, 209) // inclusive - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/CloudDebugVersionCache.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/CloudDebugVersionCache.kt deleted file mode 100644 index 974c6fe28e..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/CloudDebugVersionCache.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.core.executables - -import com.intellij.execution.process.CapturingProcessHandler -import com.intellij.util.text.SemVer -import com.intellij.util.text.nullize -import software.aws.toolkits.jetbrains.utils.FileInfoCache -import software.aws.toolkits.resources.message - -object CloudDebugVersionCache : FileInfoCache() { - override fun getFileInfo(path: String): SemVer { - val executableName = "cloud-debug" - val sanitizedPath = path.nullize(true) - ?: throw RuntimeException(message("executableCommon.cli_not_configured", executableName)) - val commandLine = ExecutableCommon.getCommandLine( - sanitizedPath, - executableName - ).withParameters("version") - val process = CapturingProcessHandler(commandLine).runProcess() - - if (process.exitCode != 0) { - val output = process.stderr.trimEnd() - throw IllegalStateException(output) - } else { - val output: String = process.stdout.trimEnd() - if (output.isEmpty()) { - throw IllegalStateException(message("executableCommon.empty_info", executableName)) - } - return SemVer.parseFromText(output) - ?: throw IllegalStateException(message("executableCommon.version_parse_error", - executableName, - output - )) - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableCommon.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableCommon.kt index a9eb9ff7bd..d481883c42 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableCommon.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableCommon.kt @@ -8,76 +8,109 @@ import com.intellij.openapi.util.SystemInfo import com.intellij.util.EnvironmentUtil import com.intellij.util.text.SemVer import com.intellij.util.text.nullize +import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable +import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata import software.aws.toolkits.jetbrains.utils.FileInfoCache import software.aws.toolkits.resources.message import java.time.Duration -class ExecutableCommon { - companion object { - fun getCommandLine(path: String, executableName: String): GeneralCommandLine { - val sanitizedPath = path.nullize(true) - ?: throw RuntimeException(message("executableCommon.cli_not_configured", executableName)) +object ExecutableCommon { + fun getCommandLine( + path: String, + executableName: String, + executableType: ExecutableType<*>? = null, + clientMetadata: ClientMetadata = ClientMetadata.DEFAULT_METADATA + ): GeneralCommandLine { + val sanitizedPath = path.nullize(true) + ?: throw RuntimeException(message("executableCommon.cli_not_configured", executableName)) - // we have some env-hacks that we want to do, so we're building our own environment using the same util as GeneralCommandLine - // GeneralCommandLine will apply some more env patches prior to process launch (see startProcess()) so this should be fine - val effectiveEnvironment = EnvironmentUtil.getEnvironmentMap().toMutableMap() - // apply hacks - effectiveEnvironment.apply { - // GitHub issue: https://github.com/aws/aws-toolkit-jetbrains/issues/645 - // strip out any AWS credentials in the parent environment - remove("AWS_ACCESS_KEY_ID") - remove("AWS_SECRET_ACCESS_KEY") - remove("AWS_SESSION_TOKEN") - // GitHub issue: https://github.com/aws/aws-toolkit-jetbrains/issues/577 - // coerce the locale to UTF-8 as specified in PEP 538 - // this is needed for Python 3.0 up to Python 3.7.0 (inclusive) - // we can remove this once our IDE minimum version has a fix for https://youtrack.jetbrains.com/issue/PY-30780 - // currently only seeing this on OS X, so only scoping to that - if (SystemInfo.isMac) { - // on other platforms this could be C.UTF-8 or C.UTF8 - this["LC_CTYPE"] = "UTF-8" - // we're not setting PYTHONIOENCODING because we might break SAM on py2.7 - } + // we have some env-hacks that we want to do, so we're building our own environment using the same util as GeneralCommandLine + // GeneralCommandLine will apply some more env patches prior to process launch (see startProcess()) so this should be fine + val effectiveEnvironment = EnvironmentUtil.getEnvironmentMap().toMutableMap() + // apply hacks + effectiveEnvironment.apply { + // GitHub issue: https://github.com/aws/aws-toolkit-jetbrains/issues/645 + // strip out any AWS credentials in the parent environment + remove("AWS_ACCESS_KEY_ID") + remove("AWS_SECRET_ACCESS_KEY") + remove("AWS_SESSION_TOKEN") + // GitHub issue: https://github.com/aws/aws-toolkit-jetbrains/issues/577 + // coerce the locale to UTF-8 as specified in PEP 538 + // this is needed for Python 3.0 up to Python 3.7.0 (inclusive) + // we can remove this once our IDE minimum version has a fix for https://youtrack.jetbrains.com/issue/PY-30780 + // currently only seeing this on OS X, so only scoping to that + if (SystemInfo.isMac) { + // on other platforms this could be C.UTF-8 or C.UTF8 + this["LC_CTYPE"] = "UTF-8" + // we're not setting PYTHONIOENCODING because we might break SAM on py2.7 } + if (executableType is SamExecutable) { + this["AWS_TOOLING_USER_AGENT"] = "${clientMetadata.productName}/${clientMetadata.productVersion}" + } + } - return GeneralCommandLine(sanitizedPath) - .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.NONE) - .withEnvironment(effectiveEnvironment) + return GeneralCommandLine(sanitizedPath) + .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.NONE) + .withEnvironment(effectiveEnvironment) + } + + /** + * Compare SemVer version to predefined bounds and throw an exception if out of range. + * Max version will always be evaluated exclusively, and min version will always be evaluated inclusively + */ + @JvmStatic + fun checkSemVerVersion(version: SemVer, min: SemVer, max: SemVer, executableName: String) { + val versionOutOfRangeMessage = message( + "executableCommon.version_wrong", + executableName, + min, + max, + version + ) + if (version >= max) { + throw RuntimeException("$versionOutOfRangeMessage ${message("executableCommon.version_too_high")}") + } else if (version < min) { + throw RuntimeException("$versionOutOfRangeMessage ${message("executableCommon.version_too_low", executableName)}") } + } - /** - * Compare SemVer version to predefined bounds and throw an exception if out of range. - * Max version will always be evaluated exclusively, and min version will always be evaluated inclusively - */ - @JvmStatic - fun checkSemVerVersion(version: SemVer, min: SemVer, max: SemVer, executableName: String) { - val versionOutOfRangeMessage = message( - "executableCommon.version_wrong", - executableName, - min, - max, - version - ) - if (version >= max) { - throw RuntimeException("$versionOutOfRangeMessage ${message("executableCommon.version_too_high")}") - } else if (version < min) { - throw RuntimeException("$versionOutOfRangeMessage ${message("executableCommon.version_too_low", executableName)}") + @JvmStatic + fun checkSemVerVersionForParallelValidVersions( + version: SemVer, + versionRange: List, + executableName: String + ) { + versionRange.forEach { + if (it.max > version && version >= it.min) { + return } } - /** - * @return Version of the executable, as whatever type is tracked by the FileInfoCache object - */ - @JvmStatic - fun getVersion(path: String, executableVersionCache: FileInfoCache, executableName: String): T { - val sanitizedPath = path.nullize(true) - ?: throw RuntimeException(message("executableCommon.cli_not_configured", executableName)) - return executableVersionCache.evaluateBlocking( - sanitizedPath, - DEFAULT_TIMEOUT.toMillis().toInt() - ).result - } + val versionRanges = versionRange.joinToString(separator = " / ", transform = { "${it.min} ≤ version < ${it.max}" }) + val versionOutOfRangeMessage = message( + "executableCommon.version_range_wrong", + executableName, + versionRanges, + version + ) + + throw RuntimeException(versionOutOfRangeMessage) + } - private val DEFAULT_TIMEOUT = Duration.ofSeconds(5) + /** + * @return Version of the executable, as whatever type is tracked by the FileInfoCache object + */ + @JvmStatic + fun getVersion(path: String, executableVersionCache: FileInfoCache, executableName: String): T { + val sanitizedPath = path.nullize(true) + ?: throw RuntimeException(message("executableCommon.cli_not_configured", executableName)) + return executableVersionCache.evaluateBlocking( + sanitizedPath, + DEFAULT_TIMEOUT.toMillis().toInt() + ).result } + + private val DEFAULT_TIMEOUT = Duration.ofSeconds(5) } + +data class ExecutableVersionRange(val min: SemVer, val max: SemVer) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableManager.kt index 100ecaab0d..cfff9f1931 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableManager.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/executables/ExecutableManager.kt @@ -6,17 +6,19 @@ package software.aws.toolkits.jetbrains.core.executables import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.openapi.application.ApplicationManager 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.components.service import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.startup.StartupActivity -import com.intellij.util.io.exists +import com.intellij.openapi.util.SystemInfo import com.intellij.util.io.lastModified +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.jetbrains.core.executables.ExecutableInstance.ExecutableWithPath +import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable import software.aws.toolkits.resources.message import java.nio.file.Path import java.nio.file.Paths @@ -45,7 +47,7 @@ interface ExecutableManager { companion object { @JvmStatic - fun getInstance(): ExecutableManager = ServiceManager.getService(ExecutableManager::class.java) + fun getInstance(): ExecutableManager = service() } } @@ -92,7 +94,9 @@ class DefaultExecutableManager : PersistentStateComponent, LOG.warn(it) { "Error thrown while updating executable cache" } null } - return ExecutableInstance.UnresolvedExecutable(message("executableCommon.missing_executable", type.displayName)) + return ExecutableInstance.UnresolvedExecutable( + message("executableCommon.missing_executable", type.displayName, message("executableCommon.not_installed")) + ) } // Check if the set executable was modified. If it was, start an update in the background. Overlapping @@ -177,7 +181,9 @@ class DefaultExecutableManager : PersistentStateComponent, private fun resolve(type: ExecutableType<*>): ExecutableInstance = try { (type as? AutoResolvable)?.resolve()?.let { validateAndSave(type, it, autoResolved = true) } - ?: ExecutableInstance.UnresolvedExecutable(message("executableCommon.missing_executable", type.displayName)) + ?: ExecutableInstance.UnresolvedExecutable( + message("executableCommon.missing_executable", type.displayName, message("executableCommon.not_installed")) + ) } catch (e: Exception) { ExecutableInstance.UnresolvedExecutable(message("aws.settings.executables.resolution_exception", type.displayName, e.asString)) } @@ -186,6 +192,18 @@ class DefaultExecutableManager : PersistentStateComponent, try { (type as? Validatable)?.validate(path) determineVersion(type, path, autoResolved) + } catch (e: IllegalStateException) { + val errorMessage = if (SystemInfo.isWindows && type is SamExecutable) { + message("sam.cli.version.upgrade.required.windows") + "\n" + e.asString + } else { + message("aws.settings.executables.executable_invalid", type.displayName, e.asString) + } + ExecutableInstance.InvalidExecutable( + path, + null, + autoResolved, + errorMessage + ) } catch (e: Exception) { val message = message("aws.settings.executables.executable_invalid", type.displayName, e.asString) LOG.warn(e) { message } @@ -218,7 +236,7 @@ class DefaultExecutableManager : PersistentStateComponent, } private fun determineVersion(type: ExecutableType<*>, path: Path, autoResolved: Boolean): ExecutableInstance = try { - ExecutableInstance.Executable(path, type.version(path).toString(), autoResolved) + ExecutableInstance.Executable(path, type.version(path).toString(), autoResolved, type) } catch (e: Exception) { ExecutableInstance.InvalidExecutable( path, @@ -255,11 +273,12 @@ sealed class ExecutableInstance { class Executable( override val executablePath: Path, override val version: String, - override val autoResolved: Boolean + override val autoResolved: Boolean, + private val executableType: ExecutableType<*> ) : ExecutableInstance(), ExecutableWithPath { // TODO get executable name as part of this fun getCommandLine(): GeneralCommandLine = - ExecutableCommon.getCommandLine(executablePath.toAbsolutePath().toString(), executablePath.fileName.toString()) + ExecutableCommon.getCommandLine(executablePath.toAbsolutePath().toString(), executablePath.fileName.toString(), executableType) } class InvalidExecutable( 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 de4be18095..1ae1cd2c95 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 @@ -33,24 +33,19 @@ interface ExecutableType { } interface AutoResolvable { - /** * Attempt to automatically resolve the path * * @return the resolved path or null if not found - * @throws if an exception occurred attempting to resolve the path, when success was expected + * @throws Exception if an exception occurred attempting to resolve the path */ fun resolve(): Path? } +@Deprecated("Should not be used, delete after ToolManager migration") interface Validatable { - /** - * Validate the executable at the given path, this may include version checks - * or any other validation required to ensure this executable is compatible with - * the toolkit. - * - * If validation fails throw exception, [Exception.message] is displayed to the user + * Validate the executable at the given path, beyond being a supported version to ensure this executable is compatible wit the toolkit. */ fun validate(path: Path) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionExtension.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionExtension.kt new file mode 100644 index 0000000000..502086fbac --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionExtension.kt @@ -0,0 +1,144 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.execution + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.RunConfigurationBase +import com.intellij.execution.target.TargetedCommandLineBuilder +import com.intellij.execution.target.value.TargetEnvironmentFunction +import com.intellij.execution.target.value.constant +import com.intellij.openapi.util.Key +import com.intellij.util.xmlb.XmlSerializer +import org.jdom.Element +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.credentials.mergeWithExistingEnvironmentVariables +import software.aws.toolkits.core.credentials.toEnvironmentVariables +import software.aws.toolkits.core.region.mergeWithExistingEnvironmentVariables +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.execution.AwsCredentialInjectionOptions.Companion.DEFAULT_OPTIONS +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry +import software.aws.toolkits.telemetry.Result.Failed +import software.aws.toolkits.telemetry.Result.Succeeded + +class AwsConnectionRunConfigurationExtension> { + fun addToTargetEnvironment(configuration: T, environment: MutableMap>, runtimeString: () -> String? = { null }) { + injectCredentials(configuration, runtimeString) { settings -> + val putFn = { map: Map -> + map.forEach { (k, v) -> environment[k] = constant(v) } + } + + settings.region.mergeWithExistingEnvironmentVariables(environment.keys, putFn) + settings.credentials.resolveCredentials().mergeWithExistingEnvironmentVariables(environment.keys, environment::remove, putFn) + } + } + + fun addEnvironmentVariables(configuration: T, environment: MutableMap, runtimeString: () -> String? = { null }) { + injectCredentials(configuration, runtimeString) { + it.region.mergeWithExistingEnvironmentVariables(environment) + it.credentials.resolveCredentials().mergeWithExistingEnvironmentVariables(environment) + } + } + + fun addToTargetCommandLineBuilder(configuration: T, cmdLine: TargetedCommandLineBuilder, runtimeString: () -> String? = { null }) { + injectCredentials(configuration, runtimeString) { + it.credentials.resolveCredentials().toEnvironmentVariables().forEach { key, value -> cmdLine.addEnvironmentVariable(key, value) } + it.region.toEnvironmentVariables().forEach { key, value -> cmdLine.addEnvironmentVariable(key, value) } + } + } + + private fun injectCredentials(configuration: T, runtimeString: () -> String?, environmentMutator: (ConnectionSettings) -> Unit) { + try { + val credentialConfiguration = credentialConfiguration(configuration) ?: return + if (credentialConfiguration == DEFAULT_OPTIONS) return + + val connection = getConnection(configuration, credentialConfiguration) + environmentMutator(connection) + AwsTelemetry.injectCredentials(configuration.project, result = Succeeded, runtimeString = tryOrNull { runtimeString() }) + } catch (e: Exception) { + AwsTelemetry.injectCredentials(configuration.project, result = Failed, runtimeString = tryOrNull { runtimeString() }) + LOG.error(e) { message("run_configuration_extension.inject_aws_connection_exception") } + } + } + + fun validateConfiguration(runConfiguration: T) { + val credentialConfiguration = runConfiguration.getCopyableUserData(AWS_CONNECTION_RUN_CONFIGURATION_KEY) ?: return + if (credentialConfiguration == DEFAULT_OPTIONS) return + + getConnection(runConfiguration, credentialConfiguration) + } + + fun readExternal(runConfiguration: T, element: Element) { + runConfiguration.putCopyableUserData( + AWS_CONNECTION_RUN_CONFIGURATION_KEY, + XmlSerializer.deserialize( + element, + AwsCredentialInjectionOptions::class.java + ) + ) + } + + fun writeExternal(runConfiguration: T, element: Element) { + runConfiguration.getCopyableUserData(AWS_CONNECTION_RUN_CONFIGURATION_KEY)?.let { + XmlSerializer.serializeInto(it, element) + } + } + + private fun credentialConfiguration(configuration: T) = configuration.getCopyableUserData(AWS_CONNECTION_RUN_CONFIGURATION_KEY) + + private fun getConnection(configuration: T, credentialConfiguration: AwsCredentialInjectionOptions): ConnectionSettings { + val regionProvider = AwsRegionProvider.getInstance() + val credentialManager = CredentialManager.getInstance() + + return if (credentialConfiguration.useCurrentConnection) { + AwsConnectionManager.getInstance(configuration.project).connectionSettings() ?: throw RuntimeException(message("configure.toolkit")) + } else { + val region = credentialConfiguration.region?.let { + regionProvider.allRegions()[it] + } ?: throw IllegalStateException(message("configure.validate.no_region_specified")) + + val credentialProviderId = credentialConfiguration.credential ?: throw IllegalStateException(message("aws.notification.credentials_missing")) + + val credentialProvider = credentialManager.getCredentialIdentifierById(credentialProviderId)?.let { + credentialManager.getAwsCredentialProvider(it, region) + } ?: throw RuntimeException(message("aws.notification.credentials_missing")) + + ConnectionSettings(credentialProvider, region) + } + } + + private companion object { + val LOG = getLogger>() + } +} + +fun > AwsConnectionRunConfigurationExtension.addEnvironmentVariables( + configuration: T, + cmdLine: GeneralCommandLine, + runtimeString: () -> String? = { null } +) = addEnvironmentVariables(configuration, cmdLine.environment, runtimeString) + +fun > connectionSettingsEditor(configuration: T): AwsConnectionExtensionSettingsEditor = + configuration.getProject().let { AwsConnectionExtensionSettingsEditor(it, false) } + +val AWS_CONNECTION_RUN_CONFIGURATION_KEY = + Key.create( + "aws.toolkit.runConfigurationConnection" + ) + +data class AwsCredentialInjectionOptions( + var useCurrentConnection: Boolean = false, + var region: String? = null, + var credential: String? = null +) { + companion object { + operator fun invoke(block: AwsCredentialInjectionOptions.() -> Unit) = AwsCredentialInjectionOptions().apply(block) + val DEFAULT_OPTIONS by lazy { AwsCredentialInjectionOptions() } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionExtensionSettingsEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionExtensionSettingsEditor.kt new file mode 100644 index 0000000000..e99ff97bad --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionExtensionSettingsEditor.kt @@ -0,0 +1,123 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.execution + +import com.intellij.execution.configurations.RunConfigurationBase +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.project.Project +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.ui.CredentialProviderSelector +import software.aws.toolkits.resources.message +import javax.swing.JComponent + +class AwsConnectionExtensionSettingsEditor>(private val project: Project, private val showHeader: Boolean) : SettingsEditor() { + internal val view = AwsConnectionExtensionSettingsPanel() + private val regionProvider = AwsRegionProvider.getInstance() + private val credentialManager = CredentialManager.getInstance() + + init { + view.manuallyConfiguredConnection.addActionListener { updateComponents() } + view.none.addActionListener { updateComponents() } + view.useCurrentConnection.addActionListener { updateComponents() } + } + + override fun resetEditorFrom(configuration: T) { + configuration.getCopyableUserData(AWS_CONNECTION_RUN_CONFIGURATION_KEY)?.let { config -> + view.region.isEnabled = false + view.credentialProvider.isEnabled = false + when { + config.useCurrentConnection -> { + view.useCurrentConnection.isSelected = true + } + config.region != null || config.credential != null -> { + view.manuallyConfiguredConnection.isSelected = true + populateRegionsIfRequired() + populateCredentialsIfRequired() + view.region.isEnabled = true + view.credentialProvider.isEnabled = true + + view.region.selectedRegion = config.region?.let { regionProvider[it] } + when (val credential = config.credential) { + is String -> view.credentialProvider.setSelectedCredential(credential) + else -> view.credentialProvider.selectedItem = null + } + } + else -> { + view.none.isSelected = true + } + } + } + } + + override fun createEditor(): JComponent = if (showHeader) { + panel { + collapsibleGroup(message("aws_connection.tab.label")) { + row { + cell(view.panel) + } + }.also { + it.expanded = true + } + } + } else { + view.panel + } + + public override fun applyEditorTo(configuration: T) { + configuration.putCopyableUserData( + AWS_CONNECTION_RUN_CONFIGURATION_KEY, + AwsCredentialInjectionOptions().also { + when { + view.useCurrentConnection.isSelected -> it.useCurrentConnection = true + view.manuallyConfiguredConnection.isSelected -> { + it.region = view.region.selectedRegion?.id + it.credential = view.credentialProvider.getSelectedCredentialsProvider() + } + } + } + ) + } + + private fun updateComponents() { + if (view.manuallyConfiguredConnection.isSelected) { + populateRegionsIfRequired() + populateCredentialsIfRequired() + if (view.region.selectedRegion == null || view.credentialProvider.selectedItem == null) { + AwsConnectionManager.getInstance(project).connectionSettings()?.run { + if (view.region.selectedRegion == null) { + view.region.selectedRegion = region + } + if (view.credentialProvider.selectedItem == null) { + view.credentialProvider.setSelectedCredential(credentials.id) + } + } + } + } + view.region.isEnabled = view.manuallyConfiguredConnection.isSelected + view.credentialProvider.isEnabled = view.manuallyConfiguredConnection.isSelected + } + + private fun populateCredentialsIfRequired() { + if (view.credentialProvider.itemCount == 0) { + view.credentialProvider.setCredentialsProviders(credentialManager.getCredentialIdentifiers()) + } + } + + private fun populateRegionsIfRequired() { + if (view.region.itemCount == 0) { + view.region.setRegions(regionProvider.allRegions().values.toList()) + } + } + + private fun CredentialProviderSelector.setSelectedCredential(id: String) { + when (val identifier = credentialManager.getCredentialIdentifierById(id)) { + is CredentialIdentifier -> setSelectedCredentialsProvider(identifier) + else -> setSelectedInvalidCredentialsProvider(id) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionExtensionSettingsPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionExtensionSettingsPanel.kt new file mode 100644 index 0000000000..14de5b1607 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionExtensionSettingsPanel.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.core.execution + +import software.aws.toolkits.jetbrains.ui.CredentialProviderSelector +import software.aws.toolkits.jetbrains.ui.RegionSelector +import javax.swing.JPanel +import javax.swing.JRadioButton + +class AwsConnectionExtensionSettingsPanel { + lateinit var panel: JPanel + lateinit var none: JRadioButton + lateinit var useCurrentConnection: JRadioButton + lateinit var manuallyConfiguredConnection: JRadioButton + lateinit var credentialProvider: CredentialProviderSelector + lateinit var region: RegionSelector +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionRunConfigurationExtensionSettingsPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionRunConfigurationExtensionSettingsPanel.form new file mode 100644 index 0000000000..afb798d241 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/AwsConnectionRunConfigurationExtensionSettingsPanel.form @@ -0,0 +1,85 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/JavaAwsConnectionExtension.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/JavaAwsConnectionExtension.kt new file mode 100644 index 0000000000..bd5646c7f8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/JavaAwsConnectionExtension.kt @@ -0,0 +1,77 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.execution + +import com.intellij.execution.RunConfigurationExtension +import com.intellij.execution.application.ApplicationConfiguration +import com.intellij.execution.configurations.JavaParameters +import com.intellij.execution.configurations.RunConfigurationBase +import com.intellij.execution.configurations.RunnerSettings +import com.intellij.execution.ui.SettingsEditorFragment +import com.intellij.openapi.externalSystem.service.execution.ExternalSystemRunConfiguration +import com.intellij.openapi.options.SettingsEditor +import com.intellij.openapi.projectRoots.JavaSdk +import com.intellij.openapi.roots.ModuleRootManager +import org.jdom.Element +import software.aws.toolkits.jetbrains.core.experiments.ToolkitExperiment +import software.aws.toolkits.jetbrains.core.experiments.isEnabled +import software.aws.toolkits.resources.message + +class JavaAwsConnectionExtension : RunConfigurationExtension() { + private val delegate = AwsConnectionRunConfigurationExtension>() + + /* + * This works with Maven and IntelliJ managed, but breaks for Gradle. In gradle the run config runs another run config (the actual Gradle one), which + * does not pass down the environment variables. The base that controls this is ExternalSystemRunConfiguration, so we should use that to be safe + * so that we don't encounter a similar situation with a different class based on it. + */ + override fun isApplicableFor(configuration: RunConfigurationBase<*>): Boolean = + JavaAwsConnectionExperiment.isEnabled() && configuration !is ExternalSystemRunConfiguration + + override fun > updateJavaParameters(configuration: T, params: JavaParameters, runnerSettings: RunnerSettings?) { + if (JavaAwsConnectionExperiment.isEnabled()) { + val environment = params.env + + delegate.addEnvironmentVariables(configuration, environment, runtimeString = { determineVersion(configuration) }) + } + } + + override fun getEditorTitle() = message("aws_connection.tab.label") + + override fun > createEditor(configuration: T): SettingsEditor? = connectionSettingsEditor(configuration) + + override fun > createFragments(configuration: T): List>? = + listOf( + SettingsEditorFragment.createWrapper( + serializationId, + editorTitle, + null, + AwsConnectionExtensionSettingsEditor(configuration.project, true), + ::isApplicableFor + ) + ) + + override fun validateConfiguration(configuration: RunConfigurationBase<*>, isExecution: Boolean) { + delegate.validateConfiguration(configuration) + } + + override fun readExternal(runConfiguration: RunConfigurationBase<*>, element: Element) = delegate.readExternal(runConfiguration, element) + + override fun writeExternal(runConfiguration: RunConfigurationBase<*>, element: Element) = delegate.writeExternal(runConfiguration, element) + + private fun determineVersion(configuration: T): String? = (configuration as? ApplicationConfiguration)?.let { + configuration.configurationModule?.module + }?.let { + ModuleRootManager.getInstance(it).sdk + }?.let { + JavaSdk.getInstance().getVersion(it)?.name + } +} + +object JavaAwsConnectionExperiment : ToolkitExperiment( + "javaRunConfigurationExtension", + { message("run_configuration_extension.feature.java.title") }, + { message("run_configuration_extension.feature.java.description") }, + default = true +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionCommandLineTargetEnvironmentProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionCommandLineTargetEnvironmentProvider.kt new file mode 100644 index 0000000000..dbb6d1cd78 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionCommandLineTargetEnvironmentProvider.kt @@ -0,0 +1,33 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.execution + +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry +import com.jetbrains.python.run.AbstractPythonRunConfiguration +import com.jetbrains.python.run.PythonExecution +import com.jetbrains.python.run.PythonRunParams +import com.jetbrains.python.run.target.HelpersAwareTargetEnvironmentRequest +import com.jetbrains.python.run.target.PythonCommandLineTargetEnvironmentProvider +import software.aws.toolkits.jetbrains.core.experiments.isEnabled + +class PythonAwsConnectionCommandLineTargetEnvironmentProvider : PythonCommandLineTargetEnvironmentProvider { + private val delegate = AwsConnectionRunConfigurationExtension>() + + override fun extendTargetEnvironment( + project: Project, + helpersAwareTargetRequest: HelpersAwareTargetEnvironmentRequest, + pythonExecution: PythonExecution, + runParams: PythonRunParams + ) { + if (!PythonAwsConnectionExperiment.isEnabled() && Registry.`is`("python.use.targets.api", true)) { + return + } + + val configuration = (runParams as? AbstractPythonRunConfiguration<*>) + ?: return + + delegate.addToTargetEnvironment(configuration, pythonExecution.envs, runtimeString = { configuration.getSdk()?.versionString }) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionExperiment.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionExperiment.kt new file mode 100644 index 0000000000..173cae19f7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionExperiment.kt @@ -0,0 +1,14 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.execution + +import software.aws.toolkits.jetbrains.core.experiments.ToolkitExperiment +import software.aws.toolkits.resources.message + +object PythonAwsConnectionExperiment : ToolkitExperiment( + "pythonRunConfigurationExtension", + { message("run_configuration_extension.feature.python.title") }, + { message("run_configuration_extension.feature.python.description") }, + default = true +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionExtension.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionExtension.kt new file mode 100644 index 0000000000..7a73307a10 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/execution/PythonAwsConnectionExtension.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.core.execution + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.RunnerSettings +import com.intellij.openapi.options.SettingsEditor +import com.jetbrains.python.run.AbstractPythonRunConfiguration +import com.jetbrains.python.run.PythonRunConfigurationExtension +import org.jdom.Element +import software.aws.toolkits.jetbrains.core.experiments.isEnabled +import software.aws.toolkits.resources.message + +class PythonAwsConnectionExtension : PythonRunConfigurationExtension() { + private val delegate = AwsConnectionRunConfigurationExtension>() + + override fun isApplicableFor(configuration: AbstractPythonRunConfiguration<*>): Boolean = isEnabled() + + override fun isEnabledFor(applicableConfiguration: AbstractPythonRunConfiguration<*>, runnerSettings: RunnerSettings?): Boolean = isEnabled() + + override fun patchCommandLine( + configuration: AbstractPythonRunConfiguration<*>, + runnerSettings: RunnerSettings?, + cmdLine: GeneralCommandLine, + runnerId: String + ) { + if (isEnabled()) { + delegate.addEnvironmentVariables(configuration, cmdLine, runtimeString = { configuration.getSdk()?.versionString }) + } + } + + override fun readExternal(runConfiguration: AbstractPythonRunConfiguration<*>, element: Element) = delegate.readExternal(runConfiguration, element) + + override fun writeExternal(runConfiguration: AbstractPythonRunConfiguration<*>, element: Element) = delegate.writeExternal(runConfiguration, element) + + override fun getEditorTitle() = message("aws_connection.tab.label") + + override fun

> createEditor(configuration: P): SettingsEditor

? = connectionSettingsEditor( + configuration + ) + + override fun validateConfiguration(configuration: AbstractPythonRunConfiguration<*>, isExecution: Boolean) { + delegate.validateConfiguration(configuration) + } + + private fun isEnabled() = PythonAwsConnectionExperiment.isEnabled() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ExperimentConfigurable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ExperimentConfigurable.kt new file mode 100644 index 0000000000..c1f30dee81 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ExperimentConfigurable.kt @@ -0,0 +1,28 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.experiments + +import com.intellij.icons.AllIcons +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.core.utils.htmlWrap +import software.aws.toolkits.resources.message + +class ExperimentConfigurable : BoundConfigurable(message("aws.toolkit.experimental.title")), SearchableConfigurable { + override fun getId() = "aws.experiments" + + override fun createPanel() = panel { + row { label(message("aws.toolkit.experimental.description").htmlWrap()).apply { component.icon = AllIcons.General.Warning } } + ToolkitExperimentManager.visibleExperiments().forEach { toolkitExperiment -> + row { + checkBox(toolkitExperiment.title()).bindSelected( + { toolkitExperiment.isEnabled() }, + { if (it) toolkitExperiment.setState(true) else toolkitExperiment.setState(false) } + ) + }.rowComment(toolkitExperiment.description()) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ExperimentsActionGroup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ExperimentsActionGroup.kt new file mode 100644 index 0000000000..4769571aa4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ExperimentsActionGroup.kt @@ -0,0 +1,25 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.experiments + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.resources.message + +class ExperimentsActionGroup : DefaultActionGroup(message("aws.toolkit.experimental.title"), true), DumbAware { + override fun getChildren(e: AnActionEvent?): Array = + ToolkitExperimentManager.visibleExperiments().map { EnableExperimentAction(it) }.toTypedArray() + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = ToolkitExperimentManager.visibleExperiments().isNotEmpty() + } +} + +class EnableExperimentAction(private val experiment: ToolkitExperiment) : ToggleAction(experiment.title, experiment.description, null), DumbAware { + override fun isSelected(e: AnActionEvent): Boolean = experiment.isEnabled() + override fun setSelected(e: AnActionEvent, state: Boolean) = experiment.setState(state) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ToolkitExperiment.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ToolkitExperiment.kt new file mode 100644 index 0000000000..7cab49ecab --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/experiments/ToolkitExperiment.kt @@ -0,0 +1,178 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.experiments + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.util.messages.Topic +import com.intellij.util.xmlb.annotations.Property +import software.aws.toolkits.core.utils.replace +import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.utils.createNotificationExpiringAction +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry +import software.aws.toolkits.telemetry.ExperimentState.Activated +import software.aws.toolkits.telemetry.ExperimentState.Deactivated +import java.time.Duration +import java.time.Instant + +/** + * Used to control the state of an experimental feature. + * + * Use the `aws.toolkit.experiment` extensionpoint to register experiments. This surfaces the configuration in the AWS Settings panel - and in sub-menus. + * + * @param hidden determines whether this experiment should surface in the settings/menus; hidden experiments can only be enabled by system property or manually modifying config in aws.xml + * @param default determines the default state of an experiment + * @param suggestionSnooze how long to wait between prompting a suggestion to enable the experiment (when using the experiment suggestion system ([ToolkitExperiment.suggest])) + * + * `ToolkitExperiment` implementations should be an `object` for example: + * + * ``` + * object MyExperiment : ToolkitExperiment(..) + * ``` + * + * This allows simple use at branch-points for the experiment via the available extension functions: + * + * ``` + * if (MyExperiment.isEnabled()) { + * // surface experience + * } + * ``` + */ +abstract class ToolkitExperiment( + internal val id: String, + internal val title: () -> String, + internal val description: () -> String, + internal val hidden: Boolean = false, + internal val default: Boolean = false, + internal val suggestionSnooze: Duration = Duration.ofDays(7) +) { + override fun equals(other: Any?) = (other as? ToolkitExperiment)?.id?.equals(id) == true + override fun hashCode() = id.hashCode() +} + +fun ToolkitExperiment.isEnabled(): Boolean = ToolkitExperimentManager.getInstance().isEnabled(this) +internal fun ToolkitExperiment.setState(enabled: Boolean) = ToolkitExperimentManager.getInstance().setState(this, enabled) + +/** + * Surface a notification suggesting that the given experiment be enabled. + */ +fun ToolkitExperiment.suggest() { + if (ToolkitExperimentManager.getInstance().shouldPrompt(this)) { + notifyInfo( + title = message("aws.toolkit.experimental.suggestion.title"), + content = message("aws.toolkit.experimental.suggestion.description", title(), description()), + notificationActions = listOf( + createNotificationExpiringAction(EnableExperiment(this)), + createNotificationExpiringAction(NeverShowAgain(this)) + ), + stripHtml = false + ) + } +} + +private class EnableExperiment(private val experiment: ToolkitExperiment) : + DumbAwareAction(message("aws.toolkit.experimental.enable")) { + override fun actionPerformed(e: AnActionEvent) { + experiment.setState(true) + } +} + +private class NeverShowAgain(private val experiment: ToolkitExperiment) : DumbAwareAction(message("settings.never_show_again")) { + override fun actionPerformed(e: AnActionEvent) { + ToolkitExperimentManager.getInstance().neverPrompt(experiment) + } +} + +@State(name = "experiments", storages = [Storage("aws.xml")]) +internal class ToolkitExperimentManager : PersistentStateComponent { + private val state = ExperimentState() + private val enabledState get() = state.value + + fun isEnabled(experiment: ToolkitExperiment): Boolean = + EP_NAME.extensionList.contains(experiment) && enabledState.getOrDefault(experiment.id, getDefault(experiment)) + + fun setState(experiment: ToolkitExperiment, enabled: Boolean) { + val previousState = isEnabled(experiment) + if (enabled == getDefault(experiment)) { + enabledState.remove(experiment.id) + } else { + enabledState[experiment.id] = enabled + } + if (enabled != previousState) { + ApplicationManager.getApplication().messageBus.syncPublisher(EXPERIMENT_CHANGED).enableSettingsStateChanged(experiment) + } + AwsTelemetry.experimentActivation( + experimentId = experiment.id, + experimentState = if (enabled) { + Activated + } else { + Deactivated + } + ) + } + + override fun getState(): ExperimentState = state + + override fun loadState(loadedState: ExperimentState) { + state.value.replace(loadedState.value) + state.nextSuggestion.replace(loadedState.nextSuggestion) + } + + private fun getDefault(experiment: ToolkitExperiment): Boolean { + val systemProperty = System.getProperty("aws.experiment.${experiment.id}") + return when { + systemProperty != null -> systemProperty.isBlank() || systemProperty.equals("true", ignoreCase = true) + AwsToolkit.isDeveloperMode() -> true + else -> experiment.default + } + } + + internal fun shouldPrompt(experiment: ToolkitExperiment, now: Instant = Instant.now()): Boolean { + if (experiment.isEnabled()) { + return false + } + val should = state.nextSuggestion[experiment.id]?.let { now.isAfter(Instant.ofEpochMilli(it)) } ?: true + if (should) { + state.nextSuggestion[experiment.id] = now.plus(experiment.suggestionSnooze).toEpochMilli() + } + return should + } + + internal fun neverPrompt(experiment: ToolkitExperiment) { + state.nextSuggestion[experiment.id] = Long.MAX_VALUE // This is ~240 years in the future, effectively "never". + } + + companion object { + internal val EP_NAME = ExtensionPointName.create("aws.toolkit.experiment") + internal val EXPERIMENT_CHANGED = + Topic.create("experiment service enable state changed", ToolkitExperimentStateChangedListener::class.java) + internal fun getInstance(): ToolkitExperimentManager = service() + internal fun visibleExperiments(): List = EP_NAME.extensionList.filterNot { it.hidden } + internal fun enabledExperiments(): List = EP_NAME.extensionList.filter { it.isEnabled() } + } +} + +internal class ExperimentState : BaseState() { + // This represents whether an experiment is enabled or not, don't want to rename it as that will + // cause problems with any experiments already out there in the wild who've been persisted + // as 'value' + @get:Property + val value by map() + + @get:Property + val nextSuggestion by map() +} + +fun interface ToolkitExperimentStateChangedListener { + fun enableSettingsStateChanged(toolkitExperiment: ToolkitExperiment) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt new file mode 100644 index 0000000000..bb3d3d1ed3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt @@ -0,0 +1,210 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer + +import com.intellij.ide.ActivityTracker +import com.intellij.ide.DataManager +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.ide.util.treeView.AbstractTreeStructure +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.Disposable +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataKey +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.openapi.ui.asSequence +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.ui.DoubleClickListener +import com.intellij.ui.GotItTooltip +import com.intellij.ui.PopupHandler +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.TreeUIHelper +import com.intellij.ui.tree.AsyncTreeModel +import com.intellij.ui.tree.StructureTreeModel +import com.intellij.ui.treeStructure.Tree +import com.intellij.util.concurrency.Invoker +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.tree.TreeUtil +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.ConnectionPinningManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.FeatureWithPinnedConnection +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.PinnedConnectionNode +import java.awt.Component +import java.awt.Point +import java.awt.event.MouseEvent +import javax.swing.JTree +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.TreePath + +abstract class AbstractExplorerTreeToolWindow( + treeStructure: AbstractTreeStructure, +) : SimpleToolWindowPanel(true, true), DataProvider, Disposable { + private val treeModel = StructureTreeModel(treeStructure, null, Invoker.forBackgroundPoolWithoutReadAction(this), this) + private val tree = Tree(AsyncTreeModel(treeModel, true, this)) + + init { + background = UIUtil.getTreeBackground() + + TreeUIHelper.getInstance().installTreeSpeedSearch(tree) + tree.isRootVisible = false + tree.autoscrolls = true + tree.cellRenderer = object : NodeRenderer() { + override fun customizeCellRenderer(tree: JTree, value: Any?, selected: Boolean, expanded: Boolean, leaf: Boolean, row: Int, hasFocus: Boolean) { + super.customizeCellRenderer(tree, value, selected, expanded, leaf, row, hasFocus) + if (value is DefaultMutableTreeNode && value.userObject is NodeDescriptor<*>) { + icon = (value.userObject as NodeDescriptor<*>).icon + } + } + } + + object : DoubleClickListener() { + override fun onDoubleClick(event: MouseEvent): Boolean { + val path = tree.getPathForLocation(event.x, event.y) + ((path?.lastPathComponent as? DefaultMutableTreeNode)?.userObject as? AbstractActionTreeNode)?.onDoubleClick(event) + return true + } + }.installOn(tree) + + tree.addMouseListener( + object : PopupHandler() { + override fun invokePopup(comp: Component?, x: Int, y: Int) { + val node = getSelectedNodesSameType>()?.get(0) ?: return + val actionManager = ActionManager.getInstance() + val totalActions = mutableListOf() + if (node is ActionGroupOnRightClick) { + val actionGroupName = node.actionGroupName() + + (actionGroupName.let { groupName -> actionManager.getAction(groupName) } as? ActionGroup)?.let { group -> + val context = comp?.let { DataManager.getInstance().getDataContext(it, x, y) } ?: return@let + val event = AnActionEvent.createFromDataContext(actionPlace, null, context) + totalActions.addAll(group.getChildren(event)) + } + } + + if (node is PinnedConnectionNode) { + totalActions.add(actionManager.getAction("aws.toolkit.connection.pinning.unpin")) + } + + val actionGroup = DefaultActionGroup(totalActions) + if (actionGroup.childrenCount > 0) { + val popupMenu = actionManager.createActionPopupMenu(actionPlace, actionGroup) + popupMenu.setTargetComponent(this@AbstractExplorerTreeToolWindow) + popupMenu.component.show(comp, x, y) + } + } + } + ) + + fun redraw() { + // redraw toolbars + ActivityTracker.getInstance().inc() + runInEdt { + redrawContent() + } + } + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun onChange(providerId: String) { + redraw() + } + } + ) + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + redraw() + } + } + ) + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + ConnectionPinningManagerListener.TOPIC, + object : ConnectionPinningManagerListener { + override fun pinnedConnectionChanged(feature: FeatureWithPinnedConnection, newConnection: ToolkitConnection?) { + redraw() + } + } + ) + + redrawContent() + + TreeUtil.expand(tree, 2) + } + + abstract val actionPlace: String + + override fun dispose() {} + + override fun getData(dataId: String): Any? = + when { + ExplorerTreeToolWindowDataKeys.SELECTED_NODES.`is`(dataId) -> getSelectedNodes>() + ExplorerTreeToolWindowDataKeys.REFRESH_CALLBACK.`is`(dataId) -> fun() { redrawContent() } + + else -> null + } + + private inline fun > getSelectedNodesSameType(): List? { + val selectedNodes = getSelectedNodes() + if (selectedNodes.isEmpty()) { + return null + } + + val firstClass = selectedNodes[0]::class.java + return if (selectedNodes.all { firstClass.isInstance(it) }) { + selectedNodes + } else { + null + } + } + + private inline fun > getSelectedNodes() = tree.selectionPaths?.let { treePaths -> + treePaths.map { it.lastPathComponent } + .filterIsInstance() + .map { it.userObject } + .filterIsInstance() + .toList() + }.orEmpty() + + fun redrawContent() { + setContent( + ScrollPaneFactory.createScrollPane(tree) + ) + // required for refresh + val state = TreeState.createOn(tree) + treeModel.invalidate() + state.applyTo(tree) + } + + fun showGotIt(node: String?, tooltip: GotItTooltip) { + TreeUtil.promiseExpand(tree, 2).onSuccess { + node ?: return@onSuccess + treeModel.invoker.invoke { + val path = treeModel.asSequence().firstOrNull { (it.userObject as? AbstractTreeNode<*>)?.value == node }?.path ?: return@invoke + runInEdt { + val pointToRight = tree.getPathBounds(TreePath(path))?.let { Point(it.x + it.width, it.y + it.height / 2) } ?: return@runInEdt + tooltip.withPosition(Balloon.Position.atRight).show(tree) { _, _ -> pointToRight } + } + } + } + } +} + +object ExplorerTreeToolWindowDataKeys { + val SELECTED_NODES = DataKey.create>>("aws.explorer.tree.selectedNodes") + val REFRESH_CALLBACK = DataKey.create<() -> Unit>("aws.explorer.tree.refreshCallback") +} 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 deleted file mode 100644 index b5eae11630..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerFactory.kt +++ /dev/null @@ -1,65 +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.explorer - -import com.intellij.icons.AllIcons -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.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.help.HelpIds -import software.aws.toolkits.jetbrains.ui.feedback.FeedbackDialog -import software.aws.toolkits.jetbrains.utils.actions.OpenBrowserAction -import software.aws.toolkits.resources.message - -@Suppress("unused") -class AwsExplorerFactory : ToolWindowFactory, DumbAware { - override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val explorer = ExplorerToolWindow.getInstance(project) - toolWindow.component.parent.add(explorer) - toolWindow.helpId = HelpIds.EXPLORER_WINDOW.id - if (toolWindow is ToolWindowEx) { - val actionManager = ActionManager.getInstance() - toolWindow.setTitleActions( - actionManager.getAction("aws.settings.refresh"), - Separator.create(), - FeedbackDialog.getAction(project) - ) - toolWindow.setAdditionalGearActions( - DefaultActionGroup().apply { - add( - OpenBrowserAction( - title = message("explorer.view_documentation"), - url = "https://docs.aws.amazon.com/console/toolkit-for-jetbrains" - ) - ) - add( - OpenBrowserAction( - title = message("explorer.view_source"), - icon = AllIcons.Vcs.Vendors.Github, - url = "https://github.com/aws/aws-toolkit-jetbrains" - ) - ) - add( - OpenBrowserAction( - title = message("explorer.create_new_issue"), - icon = AllIcons.Vcs.Vendors.Github, - url = "https://github.com/aws/aws-toolkit-jetbrains/issues/new/choose" - ) - ) - add(FeedbackDialog.getAction(project)) - add(actionManager.getAction("aws.settings.show")) - } - ) - } - } - - override fun init(toolWindow: ToolWindow) { - toolWindow.stripeTitle = message("explorer.label") - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerTreeStructure.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerTreeStructure.kt index c4ec62233a..a6cd8b110f 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerTreeStructure.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerTreeStructure.kt @@ -9,7 +9,7 @@ import com.intellij.openapi.project.Project import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerRootNode class AwsExplorerTreeStructure(project: Project) : AbstractTreeStructureBase(project) { - override fun getProviders(): List? = defaultTreeStructureProvider + AwsExplorerTreeStructureProvider.EP_NAME.extensionList + override fun getProviders(): List = defaultTreeStructureProvider + AwsExplorerTreeStructureProvider.EP_NAME.extensionList override fun getRootElement() = AwsExplorerRootNode(myProject) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerTreeStructureProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerTreeStructureProvider.kt index 8f8f226dee..2416cb9a32 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerTreeStructureProvider.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsExplorerTreeStructureProvider.kt @@ -9,17 +9,22 @@ import com.intellij.ide.util.treeView.AbstractTreeNode import com.intellij.openapi.extensions.ExtensionPointName import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode -interface AwsExplorerTreeStructureProvider : TreeStructureProvider { +abstract class AwsExplorerTreeStructureProvider : TreeStructureProvider { companion object { val EP_NAME = ExtensionPointName("aws.toolkit.explorer.treeStructure") } /** - * Runs after the [AwsExplorerNode.update] allowing for changes to the tree, like collapsing nodes + * Hides the ViewSettings since it is tied to the project view tree */ - override fun modify( + final override fun modify( parent: AbstractTreeNode<*>, children: MutableCollection>, settings: ViewSettings? - ): MutableCollection> + ): MutableCollection> = modify(parent, children) + + /** + * Runs after the [AwsExplorerNode.update] allowing for changes to the tree, like collapsing nodes + */ + abstract fun modify(parent: AbstractTreeNode<*>, children: MutableCollection>): MutableCollection> } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerFactory.kt new file mode 100644 index 0000000000..c9ea5d17bc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerFactory.kt @@ -0,0 +1,73 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.DumbAware +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.AwsToolkit +import software.aws.toolkits.jetbrains.core.experiments.ExperimentsActionGroup +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.utils.actions.OpenBrowserAction +import software.aws.toolkits.resources.message + +class AwsToolkitExplorerFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + toolWindow.helpId = HelpIds.EXPLORER_WINDOW.id + + if (toolWindow is ToolWindowEx) { + val actionManager = ActionManager.getInstance() + toolWindow.setTitleActions(listOf(actionManager.getAction("aws.toolkit.explorer.titleBar"))) + toolWindow.setAdditionalGearActions( + DefaultActionGroup().apply { + add( + OpenBrowserAction( + title = message("explorer.view_documentation"), + url = AwsToolkit.AWS_DOCS_URL + ) + ) + add( + OpenBrowserAction( + title = message("explorer.view_source"), + icon = AllIcons.Vcs.Vendors.Github, + url = AwsToolkit.GITHUB_URL + ) + ) + add( + OpenBrowserAction( + title = message("explorer.create_new_issue"), + icon = AllIcons.Vcs.Vendors.Github, + url = "${AwsToolkit.GITHUB_URL}/issues/new/choose" + ) + ) + add(actionManager.getAction("aws.toolkit.showFeedback")) + add(ExperimentsActionGroup()) + add(actionManager.getAction("aws.settings.show")) + } + ) + } + + val contentManager = toolWindow.contentManager + val content = contentManager.factory.createContent(AwsToolkitExplorerToolWindow.getInstance(project), null, false).also { + it.isCloseable = true + it.isPinnable = true + } + contentManager.addContent(content) + toolWindow.activate(null) + contentManager.setSelectedContent(content) + } + + override fun init(toolWindow: ToolWindow) { + toolWindow.stripeTitle = message("aws.notification.title") + } + + companion object { + const val TOOLWINDOW_ID = "aws.toolkit.explorer" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt new file mode 100644 index 0000000000..ec88711a76 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt @@ -0,0 +1,138 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer + +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.components.JBTabbedPane +import com.intellij.util.ui.components.BorderLayoutPanel +import software.aws.toolkits.jetbrains.core.credentials.CredsComboBoxActionGroup +import software.aws.toolkits.jetbrains.core.explorer.cwqTab.CodewhispererQToolWindow +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.DevToolsToolWindow +import software.aws.toolkits.resources.message +import java.awt.Component + +class AwsToolkitExplorerToolWindowState : BaseState() { + var selectedTab by string() +} + +@State(name = "explorerToolWindow", storages = [Storage("aws.xml")]) +class AwsToolkitExplorerToolWindow( + private val project: Project +) : SimpleToolWindowPanel(true, true), PersistentStateComponent { + private val tabPane = JBTabbedPane() + + private val tabComponents = mapOf Component>( + CODEWHISPERER_Q_TAB_ID to { CodewhispererQToolWindow.getInstance(project) }, + EXPLORER_TAB_ID to { ExplorerToolWindow.getInstance(project) }, + DEVTOOLS_TAB_ID to { DevToolsToolWindow.getInstance(project) } + + ) + + init { + runInEdt { + val content = BorderLayoutPanel() + setContent(content) + val group = CredsComboBoxActionGroup(project) + + toolbar = BorderLayoutPanel().apply { + addToCenter( + ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, group, true).apply { + layoutPolicy = ActionToolbar.AUTO_LAYOUT_POLICY + setTargetComponent(this@AwsToolkitExplorerToolWindow) + }.component + ) + + val actionManager = ActionManager.getInstance() + val rightActionGroup = DefaultActionGroup( + actionManager.getAction("aws.toolkit.toolwindow.credentials.rightGroup.more"), + actionManager.getAction("aws.toolkit.toolwindow.credentials.rightGroup.help") + ) + + addToRight( + ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, rightActionGroup, true).apply { + // revisit if these actions need the tool window as a data provider + setTargetComponent(component) + }.component + ) + } + + // main content + tabComponents.forEach { name, contentProvider -> + tabPane.addTab(name, contentProvider()) + } + content.addToCenter(tabPane) + + val toolkitToolWindowListener = ToolkitToolWindowListener(project) + val onTabChange = { + val index = tabPane.selectedIndex + if (index != -1) { + toolkitToolWindowListener.tabChanged(tabPane.getTitleAt(index)) + } + } + tabPane.model.addChangeListener { + onTabChange() + } + onTabChange() + } + } + + fun selectTab(tabName: String): Component? { + val index = tabPane.indexOfTab(tabName) + if (index == -1) { + return null + } + + val component = tabPane.getComponentAt(index) + if (component != null) { + tabPane.selectedComponent = tabPane.getComponentAt(index) + + return component + } + + return null + } + + fun getTabLabelComponent(tabName: String): Component? { + val index = tabPane.indexOfTab(tabName) + if (index == -1) { + return null + } + + return tabPane.getTabComponentAt(index) + } + + override fun getState() = AwsToolkitExplorerToolWindowState().apply { + val index = tabPane.selectedIndex + if (index != -1) { + selectedTab = tabPane.getTitleAt(tabPane.selectedIndex) + } + } + + override fun loadState(state: AwsToolkitExplorerToolWindowState) { + selectTab(message("aws.codewhispererq.tab.title")) + } + + companion object { + val EXPLORER_TAB_ID = message("explorer.toolwindow.title") + val DEVTOOLS_TAB_ID = message("aws.developer.tools.tab.title") + val CODEWHISPERER_Q_TAB_ID = message("aws.codewhispererq.tab.title") + + fun getInstance(project: Project) = project.service() + + fun toolWindow(project: Project) = ToolWindowManager.getInstance(project).getToolWindow(AwsToolkitExplorerFactory.TOOLWINDOW_ID) + ?: error("Can't find AwsToolkitExplorerToolWindow") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/DefaultAwsExplorerTreeStructureProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/DefaultAwsExplorerTreeStructureProvider.kt index db153c9477..e88334b7be 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/DefaultAwsExplorerTreeStructureProvider.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/DefaultAwsExplorerTreeStructureProvider.kt @@ -3,15 +3,10 @@ package software.aws.toolkits.jetbrains.core.explorer -import com.intellij.ide.projectView.ViewSettings import com.intellij.ide.util.treeView.AbstractTreeNode -class DefaultAwsExplorerTreeStructureProvider : AwsExplorerTreeStructureProvider { - override fun modify( - parent: AbstractTreeNode<*>, - children: MutableCollection>, - settings: ViewSettings? - ): MutableCollection> = - // By default sort the children in alphabetical order +class DefaultAwsExplorerTreeStructureProvider : AwsExplorerTreeStructureProvider() { + // By default sort the children in alphabetical order + override fun modify(parent: AbstractTreeNode<*>, children: MutableCollection>): MutableCollection> = children.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.toString() }).toMutableList() } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/DeleteResourceDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/DeleteResourceDialog.kt new file mode 100644 index 0000000000..d50769a18b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/DeleteResourceDialog.kt @@ -0,0 +1,57 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.resources.message +import javax.swing.JComponent +import javax.swing.event.DocumentEvent + +class DeleteResourceDialog( + project: Project, + private val resourceType: String, + private val resourceName: String, + private val comment: String = "" +) : DialogWrapper(project) { + private val deleteResourceConfirmation = JBTextField().apply { + emptyText.text = message("delete_resource.confirmation_text") + accessibleContext.accessibleName = message("general.delete_accessible_name") + } + + private val warningIcon = JBLabel(Messages.getWarningIcon()) + private val component by lazy { + panel { + row { + cell(warningIcon) + label(message("delete_resource.message", resourceType, resourceName)) + } + row { + cell(deleteResourceConfirmation).align(Align.FILL) + } + row { }.comment(comment).visible(this@DeleteResourceDialog.comment.isNotBlank()) + } + } + + init { + super.init() + title = message("delete_resource.title", resourceType, resourceName) + okAction.isEnabled = false + deleteResourceConfirmation.document.addDocumentListener( + object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + isOKActionEnabled = deleteResourceConfirmation.text == message("delete_resource.confirmation_text") + } + } + ) + } + + override fun createCenterPanel(): JComponent = component +} 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 2c39212b55..24334944b8 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,7 +1,6 @@ // 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 @@ -10,18 +9,19 @@ 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.Disposable 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 import com.intellij.openapi.actionSystem.ex.ActionManagerEx +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.ui.SimpleToolWindowPanel import com.intellij.ui.DoubleClickListener @@ -32,15 +32,26 @@ import com.intellij.ui.PopupHandler import com.intellij.ui.ScrollPaneFactory import com.intellij.ui.TreeUIHelper import com.intellij.ui.components.panels.NonOpaquePanel +import com.intellij.ui.tree.AsyncTreeModel +import com.intellij.ui.tree.StructureTreeModel import com.intellij.ui.treeStructure.Tree +import com.intellij.util.concurrency.Invoker import com.intellij.util.ui.GridBag import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.tree.TreeUtil +import org.jetbrains.concurrency.CancellablePromise +import software.aws.toolkits.jetbrains.ToolkitPlaces +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager -import software.aws.toolkits.jetbrains.core.credentials.ChangeAccountSettingsMode +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManagerConnection import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsStateChangeNotifier import software.aws.toolkits.jetbrains.core.credentials.ConnectionState -import software.aws.toolkits.jetbrains.core.credentials.SettingsSelectorComboBoxAction +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.sono.isSono 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 @@ -51,8 +62,12 @@ import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerResourceNo import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceRootNode 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.jetbrains.core.gettingstarted.editor.GettingStartedPanel +import software.aws.toolkits.jetbrains.core.gettingstarted.rolePopupFromConnection +import software.aws.toolkits.jetbrains.services.dynamic.explorer.DynamicResourceResourceTypeNode +import software.aws.toolkits.jetbrains.ui.CenteredInfoPanel +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.UiTelemetry import java.awt.Component import java.awt.GridBagConstraints import java.awt.GridBagLayout @@ -65,31 +80,32 @@ import javax.swing.text.SimpleAttributeSet import javax.swing.text.StyleConstants import javax.swing.tree.DefaultMutableTreeNode import javax.swing.tree.TreeModel +import kotlin.reflect.KClass -class ExplorerToolWindow(project: Project) : SimpleToolWindowPanel(true, true), ConnectionSettingsStateChangeNotifier { +class ExplorerToolWindow(private val project: Project) : + SimpleToolWindowPanel(true, true), + ConnectionSettingsStateChangeNotifier, + ToolkitConnectionManagerListener, + Disposable { private val actionManager = ActionManagerEx.getInstanceEx() private val treePanelWrapper = NonOpaquePanel() private val awsTreeModel = AwsExplorerTreeStructure(project) - private val structureTreeModel = StructureTreeModel(awsTreeModel, project) - private val awsTree = createTree(AsyncTreeModel(structureTreeModel, true, project)) + + // The 4 max threads is arbitrary, but we want > 1 so that we can load more than one node at a time + private val structureTreeModel = StructureTreeModel(awsTreeModel, null, Invoker.forBackgroundPoolWithReadAction(this), this) + private val awsTree = createTree(AsyncTreeModel(structureTreeModel, true, this)) private val awsTreePanel = ScrollPaneFactory.createScrollPane(awsTree) private val accountSettingsManager = AwsConnectionManager.getInstance(project) + private val connectionManager = ToolkitConnectionManager.getInstance(project) init { - val group = DefaultActionGroup( - SettingsSelectorComboBoxAction(project, ChangeAccountSettingsMode.CREDENTIALS), - SettingsSelectorComboBoxAction(project, ChangeAccountSettingsMode.REGIONS) - ) - - toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, group, true).apply { - layoutPolicy = WRAP_LAYOUT_POLICY - }.component - background = UIUtil.getTreeBackground() setContent(treePanelWrapper) - project.messageBus.connect().subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, this) - settingsStateChanged(accountSettingsManager.connectionState) + project.messageBus.connect(this).subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, this) + ApplicationManager.getApplication().messageBus.connect(this).subscribe(ToolkitConnectionManagerListener.TOPIC, this) + + connectionChanged(connectionManager.activeConnection()) } private fun createInfoPanel(state: ConnectionState): JComponent { @@ -97,7 +113,8 @@ class ExplorerToolWindow(project: Project) : SimpleToolWindowPanel(true, true), val gridBag = GridBag() gridBag.defaultAnchor = GridBagConstraints.CENTER - gridBag.defaultInsets = JBUI.insetsBottom(JBUI.scale(6)) + gridBag.defaultWeightX = 1.0 + gridBag.defaultInsets = JBUI.insets(0, JBUI.scale(30), JBUI.scale(6), JBUI.scale(30)) val textPane = JTextPane().apply { val textColor = if (state is ConnectionState.InvalidConnection) { @@ -119,7 +136,7 @@ class ExplorerToolWindow(project: Project) : SimpleToolWindowPanel(true, true), background = UIUtil.getTreeBackground() } - panel.add(textPane, gridBag.nextLine().next()) + panel.add(textPane, gridBag.nextLine().next().fillCell()) state.actions.forEach { panel.add(createActionLabel(it), gridBag.nextLine().next()) @@ -130,27 +147,94 @@ class ExplorerToolWindow(project: Project) : SimpleToolWindowPanel(true, true), 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) + 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 activeConnectionChanged(newConnection: ToolkitConnection?) { + // weird race condition where message bus fires before active connection has reflected in the connection manager + connectionChanged(newConnection) + } + override fun settingsStateChanged(newState: ConnectionState) { - runInEdt { - treePanelWrapper.setContent( - when (newState) { - is ConnectionState.ValidConnection -> { - invalidateTree() - awsTreePanel + // we use the active connection to ignore changes to AwsConnectionManager when it's not the active connection + connectionChanged(connectionManager.activeConnection()) + } + + private fun connectionChanged(newConnection: ToolkitConnection?) { + val connectionState = accountSettingsManager.connectionState + + fun drawConnectionManagerPanel() { + runInEdt { + treePanelWrapper.setContent( + when (connectionState) { + is ConnectionState.ValidConnection -> { + invalidateTree() + awsTreePanel + } + else -> createInfoPanel(connectionState) + } + ) + } + } + + when (newConnection) { + is AwsConnectionManagerConnection -> { + drawConnectionManagerPanel() + } + + null -> { + // double check that we don't already have iam creds because the ToolkitConnectionListener can fire last, leading to weird state + // where we show "setup" button instead of explorer tree + if (CredentialManager.getInstance().getCredentialIdentifiers().isNotEmpty()) { + drawConnectionManagerPanel() + UiTelemetry.click(project, "ExplorerToolWindow_raceConditionHit") + } else { + runInEdt { + treePanelWrapper.setContent( + CenteredInfoPanel().apply { + addLine(message("gettingstarted.explorer.new.setup.info")) + addDefaultActionButton(message("gettingstarted.explorer.new.setup")) { + GettingStartedPanel.openPanel(project) + } + } + ) } - else -> createInfoPanel(newState) } - ) + } + + is AwsBearerTokenConnection -> { + runInEdt { + treePanelWrapper.setContent( + CenteredInfoPanel().apply { + if (!newConnection.isSono() || CredentialManager.getInstance().getCredentialIdentifiers().isEmpty()) { + // if no iam credentials or we're connected to identity center... + addLine(message("gettingstarted.explorer.iam.add.info")) + addDefaultActionButton(message("gettingstarted.explorer.iam.add")) { + // if builder id, popup identity center add connection + // else if already identity center, popup just the role selector + rolePopupFromConnection(project, newConnection) + } + } else { + // else if iam credentials available + addLine(message("gettingstarted.explorer.iam.switch")) + } + } + ) + } + } + + else -> { + // tree doesn't support other connection types yet + } } } @@ -171,9 +255,25 @@ class ExplorerToolWindow(project: Project) : SimpleToolWindowPanel(true, true), } } + fun > findNode(nodeType: KClass): CancellablePromise?> = + runReadAction { + structureTreeModel.invoker.computeLater { + structureTreeModel.root.children().asSequence() + .filterIsInstance() + .firstOrNull { nodeType.isInstance(it.userObject) } + ?.userObject as AbstractTreeNode<*>? + } + } + // 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) { + // TODO: do we actually need this? re-evaluate limits at later time + // never re-expand dynamic resource nodes because api throttles aggressively + TreeUtil.collectExpandedPaths(tree).filter { TreeUtil.getLastUserObject(it) is DynamicResourceResourceTypeNode }.forEach { + tree.collapsePath(it) + } + val state = TreeState.createOn(tree) block() state.applyTo(tree) @@ -194,34 +294,36 @@ class ExplorerToolWindow(project: Project) : SimpleToolWindowPanel(true, true), } }.installOn(awsTree) - awsTree.addMouseListener(object : PopupHandler() { - override fun invokePopup(comp: Component?, x: Int, y: Int) { - // Build a right click menu based on the selected first node - // All nodes must be the same type (e.g. all S3 buckets, or a service node) - val explorerNode = getSelectedNodesSameType>()?.get(0) ?: return - val actionGroupName = (explorerNode as? ResourceActionNode)?.actionGroupName() + awsTree.addMouseListener( + object : PopupHandler() { + override fun invokePopup(comp: Component?, x: Int, y: Int) { + // Build a right click menu based on the selected first node + // All nodes must be the same type (e.g. all S3 buckets, or a service node) + val explorerNode = getSelectedNodesSameType>()?.get(0) ?: return + val actionGroupName = (explorerNode as? ResourceActionNode)?.actionGroupName() - val totalActions = mutableListOf() + val totalActions = mutableListOf() - (actionGroupName?.let { actionManager.getAction(it) } as? ActionGroup)?.let { totalActions.addAll(it.getChildren(null)) } + (actionGroupName?.let { actionManager.getAction(it) } as? ActionGroup)?.let { totalActions.addAll(it.getChildren(null)) } - if (explorerNode is AwsExplorerResourceNode<*>) { - totalActions.add(CopyArnAction()) - } + if (explorerNode is AwsExplorerResourceNode<*>) { + totalActions.add(CopyArnAction()) + } - totalActions.find { it is DeleteResourceAction<*> }?.let { - totalActions.remove(it) - totalActions.add(Separator.create()) - totalActions.add(it) - } + totalActions.find { it is DeleteResourceAction<*> }?.let { + totalActions.remove(it) + totalActions.add(Separator.create()) + totalActions.add(it) + } - val actionGroup = DefaultActionGroup(totalActions) - if (actionGroup.childrenCount > 0) { - val popupMenu = actionManager.createActionPopupMenu("ExplorerToolWindow", actionGroup) - popupMenu.component.show(comp, x, y) + val actionGroup = DefaultActionGroup(totalActions) + if (actionGroup.childrenCount > 0) { + val popupMenu = actionManager.createActionPopupMenu(ToolkitPlaces.EXPLORER_TOOL_WINDOW, actionGroup) + popupMenu.component.show(comp, x, y) + } } } - }) + ) return awsTree } @@ -291,8 +393,11 @@ class ExplorerToolWindow(project: Project) : SimpleToolWindowPanel(true, true), } } + override fun dispose() { + } + companion object { - fun getInstance(project: Project): ExplorerToolWindow = ServiceManager.getService(project, ExplorerToolWindow::class.java) + fun getInstance(project: Project): ExplorerToolWindow = project.service() } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/RefreshAwsTree.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/RefreshAwsTree.kt index 4c45921e9a..481082ea4e 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/RefreshAwsTree.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/RefreshAwsTree.kt @@ -5,18 +5,36 @@ package software.aws.toolkits.jetbrains.core.explorer import com.intellij.openapi.application.runInEdt import com.intellij.openapi.project.Project +import software.aws.toolkits.core.ConnectionSettings import software.aws.toolkits.jetbrains.core.AwsResourceCache import software.aws.toolkits.jetbrains.core.Resource +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettingsOrThrow +import software.aws.toolkits.jetbrains.core.explorer.cwqTab.CodewhispererQToolWindow +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.DevToolsToolWindow -fun Project.refreshAwsTree(resource: Resource<*>? = null) { - val cache = AwsResourceCache.getInstance(this) +fun Project.refreshAwsTree(resource: Resource<*>? = null, connectionSettings: ConnectionSettings = getConnectionSettingsOrThrow()) { if (resource == null) { - cache.clear() + AwsResourceCache.getInstance().clear(connectionSettings) } else { - cache.clear(resource) + AwsResourceCache.getInstance().clear(resource, connectionSettings) } + runInEdt { // redraw explorer ExplorerToolWindow.getInstance(this).invalidateTree() } } + +fun Project.refreshDevToolTree() { + runInEdt { + if (this.isDisposed) return@runInEdt + DevToolsToolWindow.getInstance(this).redrawContent() + } +} + +fun Project.refreshCwQTree() { + runInEdt { + if (this.isDisposed) return@runInEdt + CodewhispererQToolWindow.getInstance(this).redrawContent() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowListener.kt new file mode 100644 index 0000000000..ecd9e51575 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowListener.kt @@ -0,0 +1,41 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer + +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ex.ToolWindowEx +import software.aws.toolkits.resources.message + +class ToolkitToolWindowListener(project: Project) { + private val toolWindow by lazy { AwsToolkitExplorerToolWindow.toolWindow(project) } + private val actionManager by lazy { ActionManager.getInstance() } + private val explorerActions by lazy { listOf(actionManager.getAction("aws.toolkit.explorer.titleBar")) } + + private val developerToolsActions by lazy { + listOf( + actionManager.getAction("aws.toolkit.showFeedback") + ) + } + private val cwQActions by lazy { + listOf( + actionManager.getAction("aws.toolkit.showFeedback") + ) + } + + fun tabChanged(tabName: String) { + // compiler can't smart cast since property is lazy and therefore has a custom getter + toolWindow.let { + if (it is ToolWindowEx) { + if (tabName == message("explorer.toolwindow.title")) { + it.setTitleActions(explorerActions) + } else if (tabName == message("aws.developer.tools.tab.title")) { + it.setTitleActions(developerToolsActions) + } else if (tabName == message("aws.codewhispererq.tab.title")) { + it.setTitleActions(cwQActions) + } + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ViewResourceDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ViewResourceDialog.kt new file mode 100644 index 0000000000..1f1eff66b1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ViewResourceDialog.kt @@ -0,0 +1,31 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import javax.swing.JComponent + +class ViewResourceDialog(project: Project, val resourceType: String, actionTitle: String, checkResourceNameValidity: (resource: String?) -> Boolean) : + DialogWrapper(project) { + var resourceName = "" + private val component by lazy { + panel { + row("$resourceType:") { + textField().bindText(::resourceName).errorOnApply("$resourceType must be entered") { + it.text.isNullOrBlank() || checkResourceNameValidity(it.text) + } + } + } + } + + init { + title = actionTitle + init() + } + + override fun createCenterPanel(): JComponent = component +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/AbstractActions.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/AbstractActions.kt index dd9cc8bf45..10b809d4fc 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/AbstractActions.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/AbstractActions.kt @@ -19,8 +19,10 @@ import javax.swing.Icon * @see ExplorerNodeAction * @see SingleExplorerNodeAction */ -abstract class SingleResourceNodeAction>(text: String, description: String? = null, icon: Icon? = null) : - SingleExplorerNodeAction(text, description, icon) +abstract class SingleResourceNodeAction> : SingleExplorerNodeAction { + constructor() : super() + constructor(text: String, description: String? = null, icon: Icon? = null) : super(text, description, icon) +} /** * An action from a [AwsExplorerNode] that only operates on a single resource. @@ -29,8 +31,9 @@ abstract class SingleResourceNodeAction>(text: * * @see ExplorerNodeAction */ -abstract class SingleExplorerNodeAction>(text: String, description: String? = null, icon: Icon? = null) : - ExplorerNodeAction(text, description, icon) { +abstract class SingleExplorerNodeAction> : ExplorerNodeAction { + constructor() : super() + constructor(text: String, description: String? = null, icon: Icon? = null) : super(text, description, icon) /** * If only a single item is selected [update] will be invoked with that selection periodically. @@ -61,8 +64,9 @@ abstract class SingleExplorerNodeAction>(text: String, /** * Converts generic [ExplorerNodeAction] list into [T] typed nodes */ -abstract class ExplorerNodeAction>(text: String, description: String? = null, icon: Icon? = null) : - AnAction(text, description, icon) { +abstract class ExplorerNodeAction> : AnAction { + constructor() : super() + constructor(text: String, description: String? = null, icon: Icon? = null) : super(text, description, icon) /** * Invoked periodically with the selected items of type [T]. diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/AnActionTreeNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/AnActionTreeNode.kt new file mode 100644 index 0000000000..741141260d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/AnActionTreeNode.kt @@ -0,0 +1,32 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.actions + +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import java.awt.event.MouseEvent + +class AnActionTreeNode( + project: Project, + private val place: String, + private val action: AnAction +) : AbstractActionTreeNode( + project, + action.templatePresentation.text, + action.templatePresentation.icon +) { + override fun onDoubleClick(event: MouseEvent) { + val e = AnActionEvent.createFromInputEvent( + event, + place, + action.templatePresentation.clone(), + DataManager.getInstance().getDataContext(event.component) + ) + + action.actionPerformed(e) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/CopyArnAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/CopyArnAction.kt index 2af39fc060..d9cc80f001 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/CopyArnAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/CopyArnAction.kt @@ -10,12 +10,11 @@ import com.intellij.openapi.project.DumbAware import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerResourceNode import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.AwsTelemetry -import software.aws.toolkits.telemetry.ServiceType import java.awt.datatransfer.StringSelection class CopyArnAction : SingleResourceNodeAction>(message("explorer.copy_arn"), icon = AllIcons.Actions.Copy), DumbAware { override fun actionPerformed(selected: AwsExplorerResourceNode<*>, e: AnActionEvent) { CopyPasteManager.getInstance().setContents(StringSelection(selected.resourceArn())) - AwsTelemetry.copyArn(e.project, ServiceType.from(selected.serviceId)) + AwsTelemetry.copyArn(e.project, selected.serviceId) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/DeleteResourceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/DeleteResourceAction.kt index ce43a3dd77..9795e465e9 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/DeleteResourceAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/DeleteResourceAction.kt @@ -7,50 +7,36 @@ import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.ui.InputValidator -import com.intellij.openapi.ui.Messages +import software.aws.toolkits.jetbrains.core.explorer.DeleteResourceDialog import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerResourceNode -import software.aws.toolkits.jetbrains.utils.Operation -import software.aws.toolkits.jetbrains.utils.TaggingResourceType import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.jetbrains.utils.notifyInfo -import software.aws.toolkits.jetbrains.utils.warnResourceOperationAgainstCodePipeline import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.AwsTelemetry import software.aws.toolkits.telemetry.Result -import software.aws.toolkits.telemetry.ServiceType -abstract class DeleteResourceAction>(text: String, private val taggingResourceType: TaggingResourceType) : - SingleResourceNodeAction(text, icon = AllIcons.Actions.Cancel), DumbAware { - final override fun actionPerformed(selected: T, e: AnActionEvent) { - warnResourceOperationAgainstCodePipeline(selected.nodeProject, selected.displayName(), selected.resourceArn(), taggingResourceType, Operation.DELETE) { - val resourceType = selected.resourceType() - val resourceName = selected.displayName() +abstract class DeleteResourceAction> : SingleResourceNodeAction, DumbAware { - val response = Messages.showInputDialog(selected.project, - message("delete_resource.message", resourceType, resourceName), - message("delete_resource.title", resourceType, resourceName), - Messages.getWarningIcon(), - null, - object : InputValidator { - override fun checkInput(inputString: String?): Boolean = inputString == resourceName + constructor() : super() + constructor(text: String) : super(text, icon = AllIcons.Actions.Cancel) - override fun canClose(inputString: String?): Boolean = checkInput(inputString) - } - ) + open val comment: String = "" - if (response == null) { - AwsTelemetry.deleteResource(selected.project, ServiceType.from(selected.serviceId), Result.Cancelled) - } else { - ApplicationManager.getApplication().executeOnPooledThread { - try { - performDelete(selected) - notifyInfo(project = selected.project, title = message("delete_resource.deleted", resourceType, resourceName)) - AwsTelemetry.deleteResource(selected.project, ServiceType.from(selected.serviceId), success = true) - } catch (e: Exception) { - e.notifyError(project = selected.project, title = message("delete_resource.delete_failed", resourceType, resourceName)) - AwsTelemetry.deleteResource(selected.project, ServiceType.from(selected.serviceId), success = false) - } + final override fun actionPerformed(selected: T, e: AnActionEvent) { + val resourceType = selected.resourceType() + val resourceName = selected.displayName() + val response = DeleteResourceDialog(selected.nodeProject, resourceType, resourceName, comment).showAndGet() + if (!response) { + AwsTelemetry.deleteResource(selected.project, selected.serviceId, Result.Cancelled) + } else { + ApplicationManager.getApplication().executeOnPooledThread { + try { + performDelete(selected) + notifyInfo(project = selected.project, title = message("delete_resource.deleted", resourceType, resourceName)) + AwsTelemetry.deleteResource(selected.project, selected.serviceId, success = true) + } catch (e: Exception) { + e.notifyError(project = selected.project, title = message("delete_resource.delete_failed", resourceType, resourceName)) + AwsTelemetry.deleteResource(selected.project, selected.serviceId, success = false) } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/ViewResourceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/ViewResourceAction.kt new file mode 100644 index 0000000000..7d4a5a661f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/actions/ViewResourceAction.kt @@ -0,0 +1,24 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.ViewResourceDialog +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode + +abstract class ViewResourceAction>(private val actionTitle: String, val resourceType: String) : + SingleExplorerNodeAction(actionTitle), DumbAware { + + override fun actionPerformed(selected: T, e: AnActionEvent) { + val getResourceNameDialog = ViewResourceDialog(selected.nodeProject, resourceType, actionTitle, this::checkResourceNameValidity) + if (getResourceNameDialog.showAndGet()) { + viewResource(getResourceNameDialog.resourceName, selected) + } + } + + abstract fun viewResource(resourceToView: String, selected: T) + + abstract fun checkResourceNameValidity(resourceName: String?): Boolean +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CodewhispererQToolWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CodewhispererQToolWindow.kt new file mode 100644 index 0000000000..ac73275cae --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CodewhispererQToolWindow.kt @@ -0,0 +1,19 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.cwqTab + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.ToolkitPlaces +import software.aws.toolkits.jetbrains.core.explorer.AbstractExplorerTreeToolWindow + +class CodewhispererQToolWindow(project: Project) : AbstractExplorerTreeToolWindow( + CwQTreeStructure(project) +) { + override val actionPlace = ToolkitPlaces.CWQ_TOOL_WINDOW + + companion object { + fun getInstance(project: Project) = project.service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CwQTreeRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CwQTreeRootNode.kt new file mode 100644 index 0000000000..15c603305e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CwQTreeRootNode.kt @@ -0,0 +1,26 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.cwqTab + +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.explorer.cwqTab.nodes.CwQServiceNode + +class CwQTreeRootNode(private val nodeProject: Project) : AbstractTreeNode(nodeProject, Object()) { + override fun update(presentation: PresentationData) {} + + override fun getChildren(): Collection> = EP_NAME.extensionList + .filter { it.enabled() } + .map { + it.buildServiceRootNode(nodeProject).also { node -> + node.parent = this + } + } + + companion object { + val EP_NAME = ExtensionPointName("aws.toolkit.cwq.serviceNode") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CwQTreeStructure.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CwQTreeStructure.kt new file mode 100644 index 0000000000..12eeddc747 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CwQTreeStructure.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.cwqTab + +import com.intellij.ide.projectView.TreeStructureProvider +import com.intellij.ide.util.treeView.AbstractTreeStructureBase +import com.intellij.openapi.project.Project + +class CwQTreeStructure(project: Project) : AbstractTreeStructureBase(project) { + override fun getRootElement() = CwQTreeRootNode(myProject) + + override fun getProviders(): List? = CwQTreeStructureProvider.EP_NAME.extensionList + + override fun commit() {} + + override fun hasSomethingToCommit(): Boolean = false +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CwQTreeStructureProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CwQTreeStructureProvider.kt new file mode 100644 index 0000000000..492429871f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/CwQTreeStructureProvider.kt @@ -0,0 +1,23 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.cwqTab + +import com.intellij.ide.projectView.TreeStructureProvider +import com.intellij.ide.projectView.ViewSettings +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.extensions.ExtensionPointName + +abstract class CwQTreeStructureProvider : TreeStructureProvider { + companion object { + val EP_NAME = ExtensionPointName("aws.toolkit.cwq.treeStructure") + } + + final override fun modify( + parent: AbstractTreeNode<*>, + children: MutableCollection>, + settings: ViewSettings? + ): MutableCollection> = modify(parent, children) + + abstract fun modify(parent: AbstractTreeNode<*>, children: MutableCollection>): MutableCollection> +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/nodes/CodeWhispererExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/nodes/CodeWhispererExplorerRootNode.kt new file mode 100644 index 0000000000..f003d7a135 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/nodes/CodeWhispererExplorerRootNode.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.cwqTab.nodes + +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.codewhisperer.CodeWhispererClient +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererServiceNode +import software.aws.toolkits.resources.message + +class CodeWhispererExplorerRootNode : CwQServiceNode { + override val serviceId = CodeWhispererClient.SERVICE_NAME + override fun buildServiceRootNode(nodeProject: Project) = CodeWhispererServiceNode(nodeProject, NODE_NAME) + + companion object { + val NODE_NAME = message("explorer.node.codewhisperer") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/nodes/CwQServiceNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/nodes/CwQServiceNode.kt new file mode 100644 index 0000000000..927beec7da --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/nodes/CwQServiceNode.kt @@ -0,0 +1,13 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.cwqTab.nodes + +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project + +interface CwQServiceNode { + val serviceId: String + fun buildServiceRootNode(nodeProject: Project): AbstractTreeNode<*> + fun enabled(): Boolean = true +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/nodes/QServiceNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/nodes/QServiceNode.kt new file mode 100644 index 0000000000..2a3462e402 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/cwqTab/nodes/QServiceNode.kt @@ -0,0 +1,103 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.cwqTab.nodes + +import com.intellij.icons.AllIcons +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.jetbrains.ToolkitPlaces +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.FeatureWithPinnedConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.explorer.actions.AnActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.PinnedConnectionNode +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnection +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnectionType +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.checkBearerConnectionValidity +import software.aws.toolkits.jetbrains.services.amazonq.isQSupportedInThisVersion +import software.aws.toolkits.jetbrains.services.codemodernizer.explorer.nodes.CodeModernizerRunModernizeNode +import software.aws.toolkits.jetbrains.services.codemodernizer.isCodeModernizerAvailable +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.OpenCodeReferenceNode +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend +import software.aws.toolkits.resources.message + +class QServiceNode : CwQServiceNode { + override val serviceId: String = "aws.toolkit.q.service" + + override fun buildServiceRootNode(nodeProject: Project): AbstractTreeNode<*> = QRootNode(nodeProject) + + companion object { + val NODE_NAME = message("q.node.title") + } +} + +class QRootNode(private val nodeProject: Project) : AbstractTreeNode(nodeProject, QServiceNode.NODE_NAME), PinnedConnectionNode { + private val runCodeModernizerNode by lazy { CodeModernizerRunModernizeNode(nodeProject) } + + override fun update(presentation: PresentationData) { + presentation.addText(value, SimpleTextAttributes.REGULAR_ATTRIBUTES) + if (isRunningOnRemoteBackend()) { + presentation.addText(message("codewhisperer.explorer.root_node.unavailable"), SimpleTextAttributes.GRAY_ATTRIBUTES) + return + } + if (!isQSupportedInThisVersion()) { + presentation.addText(message("q.unavailable"), SimpleTextAttributes.GRAY_ATTRIBUTES) + } + } + + override fun getChildren(): Collection> { + if (isRunningOnRemoteBackend()) return emptyList() + val actionManager = ActionManager.getInstance().getAction("q.not.supported") as ActionGroup + val notSupportedNode = actionManager.getChildren(null).mapNotNull { + AnActionTreeNode(project, ToolkitPlaces.CWQ_TOOL_WINDOW, it) + } + if (!isQSupportedInThisVersion()) return notSupportedNode + val connection = checkBearerConnectionValidity(project, BearerTokenFeatureSet.Q) + + val groupId = when (connection) { + is ActiveConnection.NotConnected -> if (otherActiveIdentityConnectionsAvailable()) ENABLE_Q_ACTION_GROUP else Q_SIGNED_OUT_ACTION_GROUP + is ActiveConnection.ValidBearer -> Q_SIGNED_IN_ACTION_GROUP + else -> Q_EXPIRED_TOKEN_ACTION_GROUP + } + + val actions = ActionManager.getInstance().getAction(groupId) as ActionGroup + val childNodes = actions.getChildren(null).mapNotNull { + AnActionTreeNode(project, ToolkitPlaces.CWQ_TOOL_WINDOW, it) + } + return if (groupId == Q_SIGNED_IN_ACTION_GROUP) { + val signedInNodes = childNodes + OpenCodeReferenceNode(nodeProject) + if (connection.connectionType == ActiveConnectionType.IAM_IDC && isCodeModernizerAvailable(project)) { + signedInNodes + runCodeModernizerNode + } else { + signedInNodes + } + } else { + childNodes + } + } + + override fun feature(): FeatureWithPinnedConnection = QConnection.getInstance() + + private fun otherActiveIdentityConnectionsAvailable() = + ToolkitAuthManager.getInstance().listConnections().filterIsInstance().isNotEmpty() + + companion object { + const val Q_SIGNED_IN_ACTION_GROUP = "aws.toolkit.q.idc.signed.in" + const val Q_SIGNED_OUT_ACTION_GROUP = "aws.toolkit.q.sign.in" + const val Q_EXPIRED_TOKEN_ACTION_GROUP = "aws.toolkit.q.expired" + const val ENABLE_Q_ACTION_GROUP = "aws.toolkit.q.enable" + } +} + +class QNotSupportedNode : AnAction(message("q.unavailable.node"), "", AllIcons.General.Warning) { + override fun actionPerformed(e: AnActionEvent) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsToolWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsToolWindow.kt new file mode 100644 index 0000000000..4a913b8ce4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsToolWindow.kt @@ -0,0 +1,19 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.ToolkitPlaces +import software.aws.toolkits.jetbrains.core.explorer.AbstractExplorerTreeToolWindow + +class DevToolsToolWindow(project: Project) : AbstractExplorerTreeToolWindow( + DevToolsTreeStructure(project) +) { + override val actionPlace = ToolkitPlaces.DEVTOOLS_TOOL_WINDOW + + companion object { + fun getInstance(project: Project) = project.service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsTreeRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsTreeRootNode.kt new file mode 100644 index 0000000000..42218798e4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsTreeRootNode.kt @@ -0,0 +1,26 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab + +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.explorer.devToolsTab.nodes.DevToolsServiceNode + +class DevToolsTreeRootNode(private val nodeProject: Project) : AbstractTreeNode(nodeProject, Object()) { + override fun update(presentation: PresentationData) {} + + override fun getChildren(): Collection> = EP_NAME.extensionList + .filter { it.enabled() } + .map { + it.buildServiceRootNode(nodeProject).also { node -> + node.parent = this + } + } + + companion object { + val EP_NAME = ExtensionPointName("aws.toolkit.devTools.serviceNode") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsTreeStructure.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsTreeStructure.kt new file mode 100644 index 0000000000..f0502ae5c1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsTreeStructure.kt @@ -0,0 +1,18 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab + +import com.intellij.ide.projectView.TreeStructureProvider +import com.intellij.ide.util.treeView.AbstractTreeStructureBase +import com.intellij.openapi.project.Project + +class DevToolsTreeStructure(project: Project) : AbstractTreeStructureBase(project) { + override fun getRootElement() = DevToolsTreeRootNode(myProject) + + override fun getProviders(): List? = DevToolsTreeStructureProvider.EP_NAME.extensionList + + override fun commit() {} + + override fun hasSomethingToCommit(): Boolean = false +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsTreeStructureProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsTreeStructureProvider.kt new file mode 100644 index 0000000000..0254034f12 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/DevToolsTreeStructureProvider.kt @@ -0,0 +1,23 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab + +import com.intellij.ide.projectView.TreeStructureProvider +import com.intellij.ide.projectView.ViewSettings +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.extensions.ExtensionPointName + +abstract class DevToolsTreeStructureProvider : TreeStructureProvider { + companion object { + val EP_NAME = ExtensionPointName("aws.toolkit.devTools.treeStructure") + } + + final override fun modify( + parent: AbstractTreeNode<*>, + children: MutableCollection>, + settings: ViewSettings? + ): MutableCollection> = modify(parent, children) + + abstract fun modify(parent: AbstractTreeNode<*>, children: MutableCollection>): MutableCollection> +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/AbstractActionTreeNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/AbstractActionTreeNode.kt new file mode 100644 index 0000000000..6f68846bdc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/AbstractActionTreeNode.kt @@ -0,0 +1,37 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes + +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.jetbrains.core.credentials.pinning.FeatureWithPinnedConnection +import java.awt.event.MouseEvent +import javax.swing.Icon + +abstract class AbstractActionTreeNode(project: Project, value: String, private val awsIcon: Icon?) : AbstractTreeNode(project, value) { + override fun update(presentation: PresentationData) { + val attr = if (isEnabled()) { + SimpleTextAttributes.REGULAR_ATTRIBUTES + } else { + SimpleTextAttributes.GRAYED_ATTRIBUTES + } + presentation.addText(value, attr) + awsIcon?.let { presentation.setIcon(it) } + } + + abstract fun onDoubleClick(event: MouseEvent) + + open fun isEnabled(): Boolean = true + override fun getChildren(): Collection> = emptyList() +} + +interface ActionGroupOnRightClick { + fun actionGroupName(): String +} + +interface PinnedConnectionNode { + fun feature(): FeatureWithPinnedConnection +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/CawsRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/CawsRootNode.kt new file mode 100644 index 0000000000..18e9d49b80 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/CawsRootNode.kt @@ -0,0 +1,59 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes + +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.jetbrains.ToolkitPlaces +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeCatalystConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.ConnectionPinningManager +import software.aws.toolkits.jetbrains.core.credentials.sono.SonoCredentialManager +import software.aws.toolkits.jetbrains.core.explorer.actions.AnActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.actions.OpenWorkspaceInGateway +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend +import software.aws.toolkits.resources.message + +class CawsRootNode(private val nodeProject: Project) : AbstractTreeNode(nodeProject, CawsServiceNode.NODE_NAME), PinnedConnectionNode { + override fun getChildren(): Collection> { + val groupId = if (SonoCredentialManager.getInstance(nodeProject).hasPreviouslyConnected()) { + "aws.caws.devtools.actions.loggedin" + } else { + "aws.caws.devtools.actions.loggedout" + } + + val actions = ActionManager.getInstance().getAction(groupId) as ActionGroup + return actions.getChildren(null).mapNotNull { + if (it is OpenWorkspaceInGateway && isRunningOnRemoteBackend()) { + return@mapNotNull null + } + + AnActionTreeNode(project, ToolkitPlaces.DEVTOOLS_TOOL_WINDOW, it) + } + } + + override fun update(presentation: PresentationData) { + presentation.addText(value, SimpleTextAttributes.REGULAR_ATTRIBUTES) + + with(ConnectionPinningManager.getInstance()) { + if (isFeaturePinned(CodeCatalystConnection.getInstance())) { + presentation.addText(message("caws.connected.builder_id"), SimpleTextAttributes.GRAY_ATTRIBUTES) + } + } + } + + override fun feature() = CodeCatalystConnection.getInstance() +} + +class CawsServiceNode : DevToolsServiceNode { + override val serviceId: String = "aws.toolkit.caws.service" + override fun buildServiceRootNode(nodeProject: Project): AbstractTreeNode<*> = CawsRootNode(nodeProject) + + companion object { + val NODE_NAME = message("caws.devtoolPanel.title") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/DevToolsServiceNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/DevToolsServiceNode.kt new file mode 100644 index 0000000000..1e4d57b434 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/DevToolsServiceNode.kt @@ -0,0 +1,13 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes + +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project + +interface DevToolsServiceNode { + val serviceId: String + fun buildServiceRootNode(nodeProject: Project): AbstractTreeNode<*> + fun enabled(): Boolean = true +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/CawsLearnMore.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/CawsLearnMore.kt new file mode 100644 index 0000000000..cf2071b2a2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/CawsLearnMore.kt @@ -0,0 +1,16 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.actions + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.caws.CawsEndpoints + +class CawsLearnMore : DumbAwareAction(AllIcons.Actions.Help) { + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(CawsEndpoints.ConsoleFactory.marketing()) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/CloneCawsRepository.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/CloneCawsRepository.kt new file mode 100644 index 0000000000..12d3145f9b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/CloneCawsRepository.kt @@ -0,0 +1,28 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.vcs.ProjectLevelVcsManager +import com.intellij.util.ui.cloneDialog.VcsCloneDialog +import software.aws.toolkits.jetbrains.core.credentials.sono.lazilyGetUserId +import software.aws.toolkits.jetbrains.services.caws.CawsCloneDialogExtension +import software.aws.toolkits.telemetry.CodecatalystTelemetry +import software.aws.toolkits.telemetry.Result as TelemetryResult + +class CloneCawsRepository : DumbAwareAction(AllIcons.Vcs.Clone) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(CommonDataKeys.PROJECT) + // TODO: can we simplify further and reuse all the logic from the real action somehow? + val cloneDialog = VcsCloneDialog.Builder(project).forExtension(CawsCloneDialogExtension::class.java) + if (cloneDialog.showAndGet()) { + cloneDialog.doClone(ProjectLevelVcsManager.getInstance(project).compositeCheckoutListener) + } else { + CodecatalystTelemetry.localClone(project = null, userId = lazilyGetUserId(), result = TelemetryResult.Cancelled) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/CopyCawsRepositoryUrl.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/CopyCawsRepositoryUrl.kt new file mode 100644 index 0000000000..1df1668bd6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/CopyCawsRepositoryUrl.kt @@ -0,0 +1,79 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.ComputableActionGroup +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.credentials.sono.SonoCredentialManager +import software.aws.toolkits.jetbrains.services.caws.CawsCodeRepository +import software.aws.toolkits.jetbrains.services.caws.CawsResources +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import java.awt.datatransfer.StringSelection + +class CopyCawsRepositoryUrl : DumbAwareAction(AllIcons.Actions.Copy) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(CommonDataKeys.PROJECT) + val cawsConnectionSettings = SonoCredentialManager.getInstance(project).getConnectionSettings() ?: return + + JBPopupFactory.getInstance().createActionGroupPopup( + message("caws.copy.url.select_repository"), + object : ComputableActionGroup.Simple() { + override fun computeChildren(manager: ActionManager): Array { + val cache = AwsResourceCache.getInstance() + return runBlocking { + val projects = cache.getResource(CawsResources.ALL_PROJECTS, cawsConnectionSettings).await() + + projects.flatMap { cawsProject -> + cache.getResource(CawsResources.codeRepositories(cawsProject), cawsConnectionSettings).await() + }.map { + object : DumbAwareAction(it.presentableString) { + override fun actionPerformed(e: AnActionEvent) { + copyUrl(project, cawsConnectionSettings, it) + } + } + }.toTypedArray() + } + } + }, + e.dataContext, + false, + null, + 5 + ) + .showInBestPositionFor(e.dataContext) + } + + private fun copyUrl(project: Project, cawsConnectionSettings: ClientConnectionSettings<*>, repository: CawsCodeRepository) { + object : Task.Backgroundable(project, message("caws.devtoolPanel.fetch.git.url", repository.presentableString)) { + override fun run(indicator: ProgressIndicator) { + val url = AwsResourceCache.getInstance() + .getResource(CawsResources.cloneUrls(repository), cawsConnectionSettings) + .toCompletableFuture() + .get() + CopyPasteManager.getInstance().setContents(StringSelection(url)) + + notifyInfo( + title = message("action.aws.caws.devtools.actions.copyCloneUrl.text"), + content = message("caws.devtoolPanel.git_url_copied"), + project = project + ) + } + }.queue() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/OpenWorkspaceInGateway.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/OpenWorkspaceInGateway.kt new file mode 100644 index 0000000000..274c8c08d7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/OpenWorkspaceInGateway.kt @@ -0,0 +1,15 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.actions + +import com.intellij.ide.ui.ProductIcons +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction + +class OpenWorkspaceInGateway : DumbAwareAction(ProductIcons.getInstance().productIcon) { + override fun actionPerformed(e: AnActionEvent) { + ActionManager.getInstance().getAction("OpenRemoteDevelopment").actionPerformed(e) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/SonoLogin.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/SonoLogin.kt new file mode 100644 index 0000000000..d606bcc480 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/SonoLogin.kt @@ -0,0 +1,23 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.core.credentials.sono.SonoCredentialManager +import software.aws.toolkits.jetbrains.core.explorer.refreshDevToolTree +import software.aws.toolkits.telemetry.UiTelemetry + +class SonoLogin : DumbAwareAction(AllIcons.Actions.Execute) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + UiTelemetry.click(project, elementId = "auth_start_CodeCatalyst") + ApplicationManager.getApplication().executeOnPooledThread { + SonoCredentialManager.loginSono(project) + project.refreshDevToolTree() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/UnpinConnectionAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/UnpinConnectionAction.kt new file mode 100644 index 0000000000..37942f34b5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/devToolsTab/nodes/actions/UnpinConnectionAction.kt @@ -0,0 +1,42 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.UpdateInBackground +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.credentials.pinning.ConnectionPinningManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.FeatureWithPinnedConnection +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.PinnedConnectionNode +import software.aws.toolkits.resources.message + +class UnpinConnectionAction : AnAction(), DumbAware, UpdateInBackground { + override fun update(e: AnActionEvent) { + val project = e.project + val feature = feature(e) + + e.presentation.isEnabledAndVisible = project != null && feature != null && ConnectionPinningManager.getInstance().isFeaturePinned(feature) + + feature?.featureName?.let { e.presentation.text = message("connection.pinning.unlink", it) } + } + + override fun actionPerformed(e: AnActionEvent) { + val feature = feature(e) ?: return + ConnectionPinningManager.getInstance().setPinnedConnection(feature, null) + + e.getData(ExplorerTreeToolWindowDataKeys.REFRESH_CALLBACK)?.invoke() + } + + private fun feature(e: AnActionEvent): FeatureWithPinnedConnection? { + val nodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + if (nodes == null || nodes.size != 1) { + return null + } + + val node = nodes.firstOrNull() as? PinnedConnectionNode ?: return null + return node.feature() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AppRunnerExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AppRunnerExplorerRootNode.kt new file mode 100644 index 0000000000..4230632d9c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AppRunnerExplorerRootNode.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.core.explorer.nodes + +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.apprunner.AppRunnerClient +import software.aws.toolkits.jetbrains.services.apprunner.AppRunnerNode + +class AppRunnerExplorerRootNode : AwsExplorerServiceNode { + override val serviceId: String = AppRunnerClient.SERVICE_NAME + + override fun buildServiceRootNode(project: Project) = AppRunnerNode(project, this) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerNode.kt index 0378c039f5..d2fbd3eb0c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerNode.kt @@ -15,7 +15,7 @@ import javax.swing.Icon /** * Top level class for any node in the AWS explorer tree */ -abstract class AwsExplorerNode(val nodeProject: Project, value: T, private val awsIcon: Icon?) : +abstract class AwsExplorerNode(val nodeProject: Project, value: T, private val awsIcon: Icon?) : AbstractTreeNode(nodeProject, value) { protected val region by lazy { nodeProject.activeRegion() } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerResourceNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerResourceNode.kt index 495898125a..b3f30854bf 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerResourceNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerResourceNode.kt @@ -9,11 +9,11 @@ import javax.swing.Icon /** * Top level class for a node that represents a resource such as an AWS Lambda. */ -abstract class AwsExplorerResourceNode( +abstract class AwsExplorerResourceNode( project: Project, val serviceId: String, value: T, - awsIcon: Icon + awsIcon: Icon? = null ) : AwsExplorerNode(project, value, awsIcon), ResourceActionNode { override fun actionGroupName() = "aws.toolkit.explorer.$serviceId.${resourceType()}" 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 f0403e425e..d0bb261145 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 @@ -8,7 +8,6 @@ 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.AwsConnectionManager -import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider /** * The root node of the AWS explorer tree. @@ -17,14 +16,13 @@ class AwsExplorerRootNode(private val nodeProject: Project) : AbstractTreeNode> { val settings = AwsConnectionManager.getInstance(nodeProject) val region = settings.selectedRegion ?: return emptyList() - val regionProvider = AwsRegionProvider.getInstance() return EP_NAME.extensionList - .filter { regionProvider.isServiceSupported(region, it.serviceId) } + .filter { it.enabled(region) } .map { it.buildServiceRootNode(nodeProject) } } - override fun update(presentation: PresentationData) { } + 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/explorer/nodes/AwsExplorerServiceNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerServiceNode.kt index f704ddd8ad..20e1ae7ec4 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerServiceNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerServiceNode.kt @@ -4,9 +4,11 @@ package software.aws.toolkits.jetbrains.core.explorer.nodes import com.intellij.openapi.project.Project +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider interface AwsExplorerServiceNode { val serviceId: String - val displayName: String - fun buildServiceRootNode(project: Project): AwsExplorerServiceRootNode + fun buildServiceRootNode(project: Project): AwsExplorerNode<*> + fun enabled(region: AwsRegion): Boolean = AwsRegionProvider.getInstance().isServiceSupported(region, serviceId) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerServiceRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerServiceRootNode.kt index 33c30df470..082ec42136 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerServiceRootNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/AwsExplorerServiceRootNode.kt @@ -4,20 +4,22 @@ package software.aws.toolkits.jetbrains.core.explorer.nodes import com.intellij.openapi.project.Project -import software.aws.toolkits.jetbrains.core.AwsResourceCache import software.aws.toolkits.jetbrains.core.Resource +import software.aws.toolkits.jetbrains.core.getResourceNow /** * Top level node for any AWS service node */ -abstract class AwsExplorerServiceRootNode(project: Project, private val service: AwsExplorerServiceNode) : - AwsExplorerNode(project, service.displayName, null), +abstract class AwsExplorerServiceRootNode(project: Project, service: AwsExplorerServiceNode) : + AwsExplorerNode(project, service, null), ResourceActionNode, ResourceParentNode { - val serviceId: String - get() = service.serviceId + private val serviceId = service.serviceId + abstract override fun displayName(): String + + override fun getChildren(): List> = super.getChildren() override fun isAlwaysShowPlus(): Boolean = true override fun actionGroupName() = "aws.toolkit.explorer.$serviceId" } @@ -25,7 +27,7 @@ abstract class AwsExplorerServiceRootNode(project: Project, private val service: abstract class CacheBackedAwsExplorerServiceRootNode(project: Project, service: AwsExplorerServiceNode, private val resource: Resource>) : AwsExplorerServiceRootNode(project, service) { - final override fun getChildrenInternal(): List> = AwsResourceCache.getInstance(nodeProject).getResourceNow(resource).map(this::toNode) + final override fun getChildrenInternal(): List> = nodeProject.getResourceNow(resource).map(this::toNode) abstract fun toNode(child: T): AwsExplorerNode<*> } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/CloudFormationExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/CloudFormationExplorerRootNode.kt index a504e1795a..e8103dfa9d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/CloudFormationExplorerRootNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/CloudFormationExplorerRootNode.kt @@ -6,11 +6,8 @@ package software.aws.toolkits.jetbrains.core.explorer.nodes import com.intellij.openapi.project.Project import software.amazon.awssdk.services.cloudformation.CloudFormationClient import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationServiceNode -import software.aws.toolkits.resources.message class CloudFormationExplorerRootNode : AwsExplorerServiceNode { override val serviceId: String = CloudFormationClient.SERVICE_NAME - override val displayName: String = message("explorer.node.cloudformation") - - override fun buildServiceRootNode(project: Project) = CloudFormationServiceNode(project, this) + override fun buildServiceRootNode(project: Project): AwsExplorerNode<*> = CloudFormationServiceNode(project, this) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/CloudWatchRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/CloudWatchRootNode.kt index 3e6f5a99c0..756cd77455 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/CloudWatchRootNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/CloudWatchRootNode.kt @@ -6,11 +6,8 @@ package software.aws.toolkits.jetbrains.core.explorer.nodes import com.intellij.openapi.project.Project import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogsServiceNode -import software.aws.toolkits.resources.message class CloudWatchRootNode : AwsExplorerServiceNode { override val serviceId: String = CloudWatchLogsClient.SERVICE_NAME - override val displayName: String = message("explorer.node.cloudwatch") - - override fun buildServiceRootNode(project: Project) = CloudWatchLogsServiceNode(project, this) + override fun buildServiceRootNode(project: Project): AwsExplorerNode<*> = CloudWatchLogsServiceNode(project, this) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/DynamoDbExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/DynamoDbExplorerRootNode.kt new file mode 100644 index 0000000000..352d2aa3a6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/DynamoDbExplorerRootNode.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.core.explorer.nodes + +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.aws.toolkits.jetbrains.services.dynamodb.explorer.DynamoDbServiceNode + +class DynamoDbExplorerRootNode : AwsExplorerServiceNode { + override val serviceId: String = DynamoDbClient.SERVICE_NAME + override fun buildServiceRootNode(project: Project) = DynamoDbServiceNode(project, this) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/EcrExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/EcrExplorerRootNode.kt new file mode 100644 index 0000000000..49b11a2023 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/EcrExplorerRootNode.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.core.explorer.nodes + +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.ecr.EcrClient +import software.aws.toolkits.jetbrains.services.ecr.EcrServiceNode + +class EcrExplorerRootNode : AwsExplorerServiceNode { + override val serviceId: String = EcrClient.SERVICE_NAME + override fun buildServiceRootNode(project: Project): AwsExplorerNode<*> = EcrServiceNode(project, this) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/EcsExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/EcsExplorerRootNode.kt index 24e18f7153..81a80a37b5 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/EcsExplorerRootNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/EcsExplorerRootNode.kt @@ -6,11 +6,8 @@ package software.aws.toolkits.jetbrains.core.explorer.nodes import com.intellij.openapi.project.Project import software.amazon.awssdk.services.ecs.EcsClient import software.aws.toolkits.jetbrains.services.ecs.EcsParentNode -import software.aws.toolkits.resources.message class EcsExplorerRootNode : AwsExplorerServiceNode { override val serviceId: String = EcsClient.SERVICE_NAME - override val displayName: String = message("explorer.node.ecs") - - override fun buildServiceRootNode(project: Project) = EcsParentNode(project, this) + override fun buildServiceRootNode(project: Project): AwsExplorerNode<*> = EcsParentNode(project, this) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/LambdaExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/LambdaExplorerRootNode.kt index aec8417375..907fc86dc1 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/LambdaExplorerRootNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/LambdaExplorerRootNode.kt @@ -6,11 +6,8 @@ package software.aws.toolkits.jetbrains.core.explorer.nodes import com.intellij.openapi.project.Project import software.amazon.awssdk.services.lambda.LambdaClient import software.aws.toolkits.jetbrains.services.lambda.LambdaServiceNode -import software.aws.toolkits.resources.message class LambdaExplorerRootNode : AwsExplorerServiceNode { override val serviceId: String = LambdaClient.SERVICE_NAME - override val displayName: String = message("explorer.node.lambda") - - override fun buildServiceRootNode(project: Project) = LambdaServiceNode(project, this) + override fun buildServiceRootNode(project: Project): AwsExplorerNode<*> = LambdaServiceNode(project, this) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/OtherResourcesRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/OtherResourcesRootNode.kt new file mode 100644 index 0000000000..c887f98753 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/OtherResourcesRootNode.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.core.explorer.nodes + +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.cloudformation.CloudFormationClient +import software.aws.toolkits.jetbrains.services.dynamic.explorer.OtherResourcesNode + +class OtherResourcesRootNode : AwsExplorerServiceNode { + override val serviceId: String = CloudFormationClient.SERVICE_NAME + override fun buildServiceRootNode(project: Project): AwsExplorerNode<*> = OtherResourcesNode(project, this) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/S3ExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/S3ExplorerRootNode.kt index 9c86f949d0..664aa82f48 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/S3ExplorerRootNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/S3ExplorerRootNode.kt @@ -6,11 +6,8 @@ package software.aws.toolkits.jetbrains.core.explorer.nodes import com.intellij.openapi.project.Project import software.amazon.awssdk.services.s3.S3Client import software.aws.toolkits.jetbrains.services.s3.S3ServiceNode -import software.aws.toolkits.resources.message class S3ExplorerRootNode : AwsExplorerServiceNode { override val serviceId: String = S3Client.SERVICE_NAME - override val displayName: String = message("explorer.node.s3") - - override fun buildServiceRootNode(project: Project) = S3ServiceNode(project, this) + override fun buildServiceRootNode(project: Project): AwsExplorerNode<*> = S3ServiceNode(project, this) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/SchemasExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/SchemasExplorerRootNode.kt index 3907693f0a..dec12bd049 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/SchemasExplorerRootNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/SchemasExplorerRootNode.kt @@ -6,11 +6,8 @@ package software.aws.toolkits.jetbrains.core.explorer.nodes import com.intellij.openapi.project.Project import software.amazon.awssdk.services.schemas.SchemasClient import software.aws.toolkits.jetbrains.services.schemas.SchemasServiceNode -import software.aws.toolkits.resources.message class SchemasExplorerRootNode : AwsExplorerServiceNode { override val serviceId: String = SchemasClient.SERVICE_NAME - override val displayName: String = message("explorer.node.schemas") - - override fun buildServiceRootNode(project: Project) = SchemasServiceNode(project, this) + override fun buildServiceRootNode(project: Project): AwsExplorerNode<*> = SchemasServiceNode(project, this) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/SqsExplorerRootNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/SqsExplorerRootNode.kt new file mode 100644 index 0000000000..c2ec827048 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/nodes/SqsExplorerRootNode.kt @@ -0,0 +1,12 @@ +// 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.sqs.SqsClient +import software.aws.toolkits.jetbrains.services.sqs.SqsServiceNode + +class SqsExplorerRootNode : AwsExplorerServiceNode { + override val serviceId: String = SqsClient.SERVICE_NAME + override fun buildServiceRootNode(project: Project): AwsExplorerNode<*> = SqsServiceNode(project, this) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedAuthUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedAuthUtils.kt new file mode 100644 index 0000000000..c4044768d8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedAuthUtils.kt @@ -0,0 +1,413 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import org.slf4j.LoggerFactory +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.services.ssooidc.model.InvalidGrantException +import software.amazon.awssdk.services.ssooidc.model.InvalidRequestException +import software.amazon.awssdk.services.ssooidc.model.SsoOidcException +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.BearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ConfigFilesFacade +import software.aws.toolkits.jetbrains.core.credentials.DefaultConfigFilesFacade +import software.aws.toolkits.jetbrains.core.credentials.LegacyManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ProfileSsoManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.UserConfigSsoSessionProfile +import software.aws.toolkits.jetbrains.core.credentials.loginSso +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileCredentialsIdentifierSso +import software.aws.toolkits.jetbrains.core.credentials.profiles.SsoSessionConstants +import software.aws.toolkits.jetbrains.core.credentials.reauthConnectionIfNeeded +import software.aws.toolkits.jetbrains.core.credentials.sono.CODEWHISPERER_SCOPES +import software.aws.toolkits.jetbrains.core.credentials.sono.IDENTITY_CENTER_ROLE_ACCESS_SCOPE +import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.getConnectionCount +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.getEnabledConnections +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.getSourceOfEntry +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AuthTelemetry +import software.aws.toolkits.telemetry.FeatureId +import software.aws.toolkits.telemetry.Result +import java.io.IOException + +private val LOG = LoggerFactory.getLogger("GettingStartedAuthUtils") + +fun rolePopupFromConnection( + project: Project, + connection: AwsBearerTokenConnection, + configFilesFacade: ConfigFilesFacade = DefaultConfigFilesFacade(), + isFirstInstance: Boolean = false +) { + runInEdt { + if (!connection.id.startsWith(SsoSessionConstants.SSO_SESSION_SECTION_NAME) || connection !is BearerSsoConnection) { + // require reauth if it's not a profile-based sso connection + requestCredentialsForExplorer(project, isFirstInstance = isFirstInstance, connectionInitiatedFromExplorer = true) + } else { + val session = connection.id.substringAfter("${SsoSessionConstants.SSO_SESSION_SECTION_NAME}:") + + val tokenProvider = if (!connection.scopes.contains(IDENTITY_CENTER_ROLE_ACCESS_SCOPE)) { + val scopes = connection.scopes + IDENTITY_CENTER_ROLE_ACCESS_SCOPE + val profile = UserConfigSsoSessionProfile( + configSessionName = session, + ssoRegion = connection.region, + startUrl = connection.startUrl, + scopes = scopes + ) + + authAndUpdateConfig(project, profile, configFilesFacade) { + Messages.showErrorDialog(project, it, message("gettingstarted.explorer.iam.add")) + } ?: return@runInEdt + } else { + reauthConnectionIfNeeded(project, connection) + connection + }.getConnectionSettings().tokenProvider + + IdcRolePopup(project, connection.region, session, tokenProvider).show() + } + } +} + +fun requestCredentialsForCodeWhisperer( + project: Project, + popupBuilderIdTab: Boolean = true, + initialConnectionCount: Int = getConnectionCount(), + initialAuthConnections: String = getEnabledConnections( + project + ), + isFirstInstance: Boolean = false, + connectionInitiatedFromExplorer: Boolean = false +): Boolean { + val authenticationDialog = SetupAuthenticationDialog( + project, + state = SetupAuthenticationDialogState().also { + if (popupBuilderIdTab) { + it.selectedTab.set(SetupAuthenticationTabs.BUILDER_ID) + } + }, + tabSettings = mapOf( + SetupAuthenticationTabs.IDENTITY_CENTER to AuthenticationTabSettings( + disabled = false, + notice = SetupAuthenticationNotice( + SetupAuthenticationNotice.NoticeType.WARNING, + message("gettingstarted.setup.codewhisperer.use_builder_id"), + CODEWHISPERER_AUTH_LEARN_MORE_LINK + ) + ), + SetupAuthenticationTabs.BUILDER_ID to AuthenticationTabSettings( + disabled = false, + notice = SetupAuthenticationNotice( + SetupAuthenticationNotice.NoticeType.WARNING, + message("gettingstarted.setup.codewhisperer.use_identity_center"), + CODEWHISPERER_AUTH_LEARN_MORE_LINK + ) + ), + SetupAuthenticationTabs.IAM_LONG_LIVED to AuthenticationTabSettings( + disabled = true, + notice = SetupAuthenticationNotice( + SetupAuthenticationNotice.NoticeType.ERROR, + message("gettingstarted.setup.auth.no_iam"), + CODEWHISPERER_AUTH_LEARN_MORE_LINK + + ) + ) + ), + scopes = CODEWHISPERER_SCOPES + Q_SCOPES, + promptForIdcPermissionSet = false, + sourceOfEntry = SourceOfEntry.CODEWHISPERER, + featureId = FeatureId.Codewhisperer, + isFirstInstance = isFirstInstance, + connectionInitiatedFromExplorer = connectionInitiatedFromExplorer + ) + val isAuthenticationSuccessful = authenticationDialog.showAndGet() + if (isAuthenticationSuccessful) { + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(SourceOfEntry.CODEWHISPERER, isFirstInstance, connectionInitiatedFromExplorer), + featureId = FeatureId.Codewhisperer, + credentialSourceId = authenticationDialog.authType, + isAggregated = true, + attempts = authenticationDialog.attempts + 1, + result = Result.Succeeded + ) + AuthTelemetry.addedConnections( + project, + source = getSourceOfEntry(SourceOfEntry.CODEWHISPERER, isFirstInstance, connectionInitiatedFromExplorer), + authConnectionsCount = initialConnectionCount, + newAuthConnectionsCount = getConnectionCount() - initialConnectionCount, + enabledAuthConnections = initialAuthConnections, + newEnabledAuthConnections = getEnabledConnections(project), + attempts = authenticationDialog.attempts + 1, + result = Result.Succeeded + ) + } else { + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(SourceOfEntry.CODEWHISPERER, isFirstInstance, connectionInitiatedFromExplorer), + featureId = FeatureId.Codewhisperer, + credentialSourceId = authenticationDialog.authType, + isAggregated = false, + attempts = authenticationDialog.attempts + 1, + result = Result.Cancelled, + ) + } + return isAuthenticationSuccessful +} + +fun requestCredentialsForQ( + project: Project, + initialConnectionCount: Int = getConnectionCount(), + initialAuthConnections: String = getEnabledConnections( + project + ), + isFirstInstance: Boolean = false, + connectionInitiatedFromExplorer: Boolean = false, + connectionInitiatedFromQChatPanel: Boolean = false +): Boolean { + // try to scope upgrade if we have a codewhisperer connection + val codeWhispererConnection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance()) + if (codeWhispererConnection is LegacyManagedBearerSsoConnection) { + codeWhispererConnection.let { + return tryOrNull { + loginSso(project, it.startUrl, it.region, Q_SCOPES) + } != null + } + } + + val dialogState = SetupAuthenticationDialogState().apply { + (codeWhispererConnection as? ProfileSsoManagedBearerSsoConnection)?.let { connection -> + idcTabState.apply { + profileName = connection.configSessionName + startUrl = connection.startUrl + region = AwsRegionProvider.getInstance().let { it.get(connection.region) ?: it.defaultRegion() } + } + + // default selected tab is IdC, but just in case + selectedTab.set(SetupAuthenticationTabs.IDENTITY_CENTER) + } ?: run { + selectedTab.set(SetupAuthenticationTabs.BUILDER_ID) + } + } + + val authenticationDialog = SetupAuthenticationDialog( + project, + state = dialogState, + tabSettings = mapOf( + SetupAuthenticationTabs.IDENTITY_CENTER to AuthenticationTabSettings( + disabled = false, + notice = SetupAuthenticationNotice( + SetupAuthenticationNotice.NoticeType.WARNING, + message("gettingstarted.setup.codewhisperer.use_builder_id"), + CODEWHISPERER_AUTH_LEARN_MORE_LINK + ) + ), + SetupAuthenticationTabs.BUILDER_ID to AuthenticationTabSettings( + disabled = false, + notice = SetupAuthenticationNotice( + SetupAuthenticationNotice.NoticeType.WARNING, + message("gettingstarted.setup.codewhisperer.use_identity_center"), + CODEWHISPERER_AUTH_LEARN_MORE_LINK + ) + ), + SetupAuthenticationTabs.IAM_LONG_LIVED to AuthenticationTabSettings( + disabled = true, + notice = SetupAuthenticationNotice( + SetupAuthenticationNotice.NoticeType.ERROR, + message("gettingstarted.setup.auth.no_iam"), + CODEWHISPERER_AUTH_LEARN_MORE_LINK + ) + ) + ), + scopes = CODEWHISPERER_SCOPES + Q_SCOPES, + promptForIdcPermissionSet = false, + sourceOfEntry = SourceOfEntry.Q, + featureId = FeatureId.Q, // TODO: Update Q in common + connectionInitiatedFromQChatPanel = connectionInitiatedFromQChatPanel + ) + + val isAuthenticationSuccessful = authenticationDialog.showAndGet() + if (isAuthenticationSuccessful) { + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(SourceOfEntry.Q, isFirstInstance, connectionInitiatedFromExplorer, connectionInitiatedFromQChatPanel), + featureId = FeatureId.Q, + credentialSourceId = authenticationDialog.authType, + isAggregated = true, + attempts = authenticationDialog.attempts + 1, + result = Result.Succeeded + ) + AuthTelemetry.addedConnections( + project, + source = getSourceOfEntry(SourceOfEntry.Q, isFirstInstance, connectionInitiatedFromExplorer, connectionInitiatedFromQChatPanel), + authConnectionsCount = initialConnectionCount, + newAuthConnectionsCount = getConnectionCount() - initialConnectionCount, + enabledAuthConnections = initialAuthConnections, + newEnabledAuthConnections = getEnabledConnections(project), + attempts = authenticationDialog.attempts + 1, + result = Result.Succeeded + ) + } else { + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(SourceOfEntry.Q, isFirstInstance, connectionInitiatedFromExplorer, connectionInitiatedFromQChatPanel), + featureId = FeatureId.Q, + credentialSourceId = authenticationDialog.authType, + isAggregated = false, + attempts = authenticationDialog.attempts + 1, + result = Result.Cancelled, + ) + } + return isAuthenticationSuccessful +} + +fun reauthenticateWithQ(project: Project) { + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) + if (connection !is ManagedBearerSsoConnection) return + ApplicationManager.getApplication().executeOnPooledThread { + reauthConnectionIfNeeded(project, connection) + } +} + +fun requestCredentialsForExplorer( + project: Project, + initialConnectionCount: Int = getConnectionCount(), + initialAuthConnections: String = getEnabledConnections( + project + ), + isFirstInstance: Boolean = false, + connectionInitiatedFromExplorer: Boolean = false +): Boolean { + val authenticationDialog = SetupAuthenticationDialog( + project, + tabSettings = mapOf( + SetupAuthenticationTabs.BUILDER_ID to AuthenticationTabSettings( + disabled = true, + notice = SetupAuthenticationNotice( + SetupAuthenticationNotice.NoticeType.ERROR, + message("gettingstarted.setup.explorer.no_builder_id"), + "https://docs.aws.amazon.com/signin/latest/userguide/differences-aws_builder_id.html" + ) + ) + ), + promptForIdcPermissionSet = true, + sourceOfEntry = SourceOfEntry.RESOURCE_EXPLORER, + featureId = FeatureId.AwsExplorer, + isFirstInstance = isFirstInstance, + connectionInitiatedFromExplorer = connectionInitiatedFromExplorer + ) + val isAuthSuccessful = authenticationDialog.showAndGet() + if (isAuthSuccessful) { + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(SourceOfEntry.RESOURCE_EXPLORER, isFirstInstance, connectionInitiatedFromExplorer), + featureId = FeatureId.AwsExplorer, + credentialSourceId = authenticationDialog.authType, + isAggregated = true, + attempts = authenticationDialog.attempts + 1, + result = Result.Succeeded + ) + AuthTelemetry.addedConnections( + project, + source = getSourceOfEntry(SourceOfEntry.RESOURCE_EXPLORER, isFirstInstance, connectionInitiatedFromExplorer), + authConnectionsCount = initialConnectionCount, + newAuthConnectionsCount = getConnectionCount() - initialConnectionCount, + enabledAuthConnections = initialAuthConnections, + newEnabledAuthConnections = getEnabledConnections(project), + attempts = authenticationDialog.attempts + 1, + result = Result.Succeeded + ) + } else { + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(SourceOfEntry.RESOURCE_EXPLORER, isFirstInstance, connectionInitiatedFromExplorer), + featureId = FeatureId.AwsExplorer, + credentialSourceId = authenticationDialog.authType, + isAggregated = false, + attempts = authenticationDialog.attempts + 1, + result = Result.Cancelled, + ) + } + return isAuthSuccessful +} + +internal fun ssoErrorMessageFromException(e: Exception) = when (e) { + is IllegalStateException -> e.message ?: message("general.unknown_error") + is ProcessCanceledException -> message("codewhisperer.credential.login.dialog.exception.cancel_login") + is InvalidGrantException -> message("codewhisperer.credential.login.exception.invalid_grant") + is InvalidRequestException -> message("codewhisperer.credential.login.exception.invalid_input") + is SsoOidcException -> message("codewhisperer.credential.login.exception.general.oidc") + else -> { + val baseMessage = when (e) { + is IOException -> "codewhisperer.credential.login.exception.io" + else -> "codewhisperer.credential.login.exception.general" + } + + message(baseMessage, "${e.javaClass.name}: ${e.message}") + } +} + +internal fun authAndUpdateConfig( + project: Project, + profile: UserConfigSsoSessionProfile, + configFilesFacade: ConfigFilesFacade, + onError: (String) -> Unit +): BearerSsoConnection? { + val connection = try { + ToolkitAuthManager.getInstance().tryCreateTransientSsoConnection(profile) { + reauthConnectionIfNeeded(project, it) + } + } catch (e: Exception) { + val message = ssoErrorMessageFromException(e) + + onError(message) + LOG.error(e) { "Failed to authenticate: message: $message; profile: $profile" } + return null + } + + configFilesFacade.updateSectionInConfig( + SsoSessionConstants.SSO_SESSION_SECTION_NAME, + Profile.builder() + .name(profile.configSessionName) + .properties( + mapOf( + "sso_start_url" to profile.startUrl, + "sso_region" to profile.ssoRegion, + "sso_registration_scopes" to profile.scopes.joinToString(",") + ) + ).build() + ) + + return connection +} + +fun deleteSsoConnectionCW(connection: AwsBearerTokenConnection) = + deleteSsoConnection(getSsoSessionProfileNameFromBearer(connection)) + +fun deleteSsoConnectionExplorer(connection: CredentialIdentifier) = + deleteSsoConnection(getSsoSessionProfileNameFromCredentials(connection)) + +fun deleteSsoConnection(sessionName: String) = DefaultConfigFilesFacade().deleteSsoConnectionFromConfig(sessionName) + +fun getSsoSessionProfileNameFromBearer(connection: AwsBearerTokenConnection): String = + connection.id.substringAfter("${SsoSessionConstants.SSO_SESSION_SECTION_NAME}:") + +fun getSsoSessionProfileNameFromCredentials(connection: CredentialIdentifier): String { + connection as ProfileCredentialsIdentifierSso + return connection.ssoSessionName +} + +const val CODEWHISPERER_AUTH_LEARN_MORE_LINK = "https://docs.aws.amazon.com/codewhisperer/latest/userguide/codewhisperer-auth.html" diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartup.kt new file mode 100644 index 0000000000..dae0aa98ab --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartup.kt @@ -0,0 +1,86 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.configurationStore.getPersistentStateComponentStorageLocation +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.getConnectionCount +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.getEnabledConnections +import software.aws.toolkits.jetbrains.settings.GettingStartedSettings +import software.aws.toolkits.telemetry.AuthTelemetry +import software.aws.toolkits.telemetry.CredentialSourceId +import software.aws.toolkits.telemetry.FeatureId +import software.aws.toolkits.telemetry.Result + +class GettingStartedOnStartup : StartupActivity { + override fun runActivity(project: Project) { + try { + val hasStartedToolkitBefore = tryOrNull { + getPersistentStateComponentStorageLocation(GettingStartedSettings::class.java)?.exists() + } ?: true + + if (hasStartedToolkitBefore && CredentialManager.getInstance().getCredentialIdentifiers().isNotEmpty()) { + GettingStartedSettings.getInstance().shouldDisplayPage = false + } + + val settings = GettingStartedSettings.getInstance() + if (!settings.shouldDisplayPage) { + return + } else { + GettingStartedPanel.openPanel(project, firstInstance = true, connectionInitiatedFromExplorer = false) + AuthTelemetry.addConnection( + project, + source = SourceOfEntry.FIRST_STARTUP.toString(), + featureId = FeatureId.Unknown, + credentialSourceId = CredentialSourceId.Unknown, + isAggregated = true, + result = Result.Succeeded + ) + AuthTelemetry.addedConnections( + project, + source = SourceOfEntry.FIRST_STARTUP.toString(), + authConnectionsCount = getConnectionCount(), + newAuthConnectionsCount = 0, + enabledAuthConnections = getEnabledConnections(project), + newEnabledAuthConnections = "", + attempts = 1, + result = Result.Succeeded + ) + settings.shouldDisplayPage = false + } + } catch (e: Exception) { + LOG.error(e) { "Error opening getting started panel" } + AuthTelemetry.addConnection( + project, + source = SourceOfEntry.FIRST_STARTUP.toString(), + featureId = FeatureId.Unknown, + credentialSourceId = CredentialSourceId.Unknown, + isAggregated = false, + result = Result.Failed, + reason = "Error opening getting started panel" + ) + AuthTelemetry.addedConnections( + project, + source = SourceOfEntry.FIRST_STARTUP.toString(), + authConnectionsCount = getConnectionCount(), + newAuthConnectionsCount = 0, + enabledAuthConnections = getEnabledConnections(project), + newEnabledAuthConnections = "", + attempts = 1, + result = Result.Failed + ) + } + } + + companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopup.kt new file mode 100644 index 0000000000..3bb50204e6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopup.kt @@ -0,0 +1,155 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.util.Disposer +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.toNullableProperty +import org.jetbrains.annotations.VisibleForTesting +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.sso.SsoClient +import software.amazon.awssdk.services.sso.model.RoleInfo +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.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManagerConnection +import software.aws.toolkits.jetbrains.core.credentials.ConfigFilesFacade +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.credentials.DefaultConfigFilesFacade +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileWatcher +import software.aws.toolkits.jetbrains.ui.AsyncComboBox +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message + +data class IdcRolePopupState( + var roleInfo: RoleInfo? = null +) + +class IdcRolePopup( + private val project: Project, + private val region: String, + private val sessionName: String, + private val tokenProvider: SdkTokenProvider, + val state: IdcRolePopupState = IdcRolePopupState(), + private val configFilesFacade: ConfigFilesFacade = DefaultConfigFilesFacade() +) : DialogWrapper(project) { + init { + title = message("gettingstarted.setup.idc.role.title") + init() + } + + override fun showAndGet(): Boolean { + if (ApplicationManager.getApplication().isUnitTestMode) { + return false + } + + return super.showAndGet() + } + + private val client = AwsClientManager.getInstance().createUnmanagedClient( + AnonymousCredentialsProvider.create(), + Region.of(region) + ) + + override fun dispose() { + client.close() + super.dispose() + } + + override fun createCenterPanel() = panel { + row { + label(message("gettingstarted.setup.idc.roleLabel")) + } + + row { + val combo = AsyncComboBox { label, value, _ -> + value ?: return@AsyncComboBox + label.text = "${value.roleName()} (${value.accountId()})" + } + + Disposer.register(myDisposable, combo) + combo.proposeModelUpdate { model -> + val token = tokenProvider.resolveToken().token() + + client.listAccounts { it.accessToken(token) } + .accountList() + .flatMap { account -> + client.listAccountRoles { + it.accessToken(token) + it.accountId(account.accountId()) + }.roleList() + }.forEach { + model.addElement(it) + } + + state.roleInfo?.let { + model.selectedItem = it + } + } + + cell(combo) + .align(AlignX.FILL) + .errorOnApply(message("gettingstarted.setup.error.not_selected")) { it.selected() == null } + .bindItem(state::roleInfo.toNullableProperty()) + } + } + + @VisibleForTesting + public override fun doOKAction() { + if (!okAction.isEnabled) { + return + } + applyFields() + + val roleInfo = state.roleInfo + checkNotNull(roleInfo) + + doOkActionWithRoleInfo(roleInfo) + + close(OK_EXIT_CODE) + } + + @VisibleForTesting + internal fun doOkActionWithRoleInfo(roleInfo: RoleInfo) { + val profileName = "$sessionName-${roleInfo.accountId()}-${roleInfo.roleName()}" + if (profileName !in configFilesFacade.readAllProfiles().keys) { + configFilesFacade.appendProfileToConfig( + Profile.builder() + .name(profileName) + .properties( + mapOf( + "sso_session" to sessionName, + "sso_account_id" to roleInfo.accountId(), + "sso_role_name" to roleInfo.roleName() + ) + ) + .build() + ) + } + + // force CredentialManager to pick up change + ProfileWatcher.getInstance().forceRefresh() + + CredentialManager.getInstance().getCredentialIdentifierById("profile:$profileName")?.let { + ToolkitConnectionManager.getInstance(project).switchConnection(AwsConnectionManagerConnection(project)) + AwsConnectionManager.getInstance(project).changeCredentialProvider(it) + } ?: let { + LOG.warn { "Could not autoswitch to profile $profileName" } + } + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialog.kt new file mode 100644 index 0000000000..85678edf6b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialog.kt @@ -0,0 +1,509 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.BrowserLink +import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.toNullableProperty +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import org.jetbrains.annotations.VisibleForTesting +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.profiles.internal.ProfileFileReader +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.sts.StsClient +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.ToolkitPlaces +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.credentials.ConfigFilesFacade +import software.aws.toolkits.jetbrains.core.credentials.DefaultConfigFilesFacade +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.UserConfigSsoSessionProfile +import software.aws.toolkits.jetbrains.core.credentials.loginSso +import software.aws.toolkits.jetbrains.core.credentials.sono.IDENTITY_CENTER_ROLE_ACCESS_SCOPE +import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES_UNAVAILABLE_BUILDER_ID +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.getSourceOfEntry +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.utils.runUnderProgressIfNeeded +import software.aws.toolkits.jetbrains.utils.ui.editorNotificationCompoundBorder +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AuthTelemetry +import software.aws.toolkits.telemetry.CredentialSourceId +import software.aws.toolkits.telemetry.FeatureId +import software.aws.toolkits.telemetry.Result +import java.awt.BorderLayout +import java.util.Locale +import javax.swing.Action +import javax.swing.BorderFactory +import javax.swing.JComponent +import javax.swing.JLabel + +data class SetupAuthenticationDialogState( + var idcTabState: IdentityCenterTabState = IdentityCenterTabState(), + val builderIdTabState: BuilderIdTabState = BuilderIdTabState, + var iamTabState: IamLongLivedCredentialsState = IamLongLivedCredentialsState(), +) { + private val graph = PropertyGraph() + val selectedTab = graph.property(SetupAuthenticationTabs.IDENTITY_CENTER) + data class IdentityCenterTabState( + var profileName: String = "", + var startUrl: String = "", + var region: AwsRegion = AwsRegionProvider.getInstance().defaultRegion(), + var rolePopupState: IdcRolePopupState = IdcRolePopupState() + ) + + // has no state yet + object BuilderIdTabState + + data class IamLongLivedCredentialsState( + // should be blank if default profile exists + var profileName: String = "default", + var accessKey: String = "", + var secretKey: String = "", + ) +} + +enum class SetupAuthenticationTabs { + IDENTITY_CENTER, + BUILDER_ID, + IAM_LONG_LIVED +} + +data class AuthenticationTabSettings( + val disabled: Boolean = false, + val notice: SetupAuthenticationNotice +) + +data class SetupAuthenticationNotice( + val type: NoticeType, + val message: String, + val learnMore: String +) { + enum class NoticeType { + WARNING, + ERROR + } +} + +enum class SourceOfEntry { + RESOURCE_EXPLORER, + CODECATALYST, + CODEWHISPERER, + EXPLORER, + FIRST_STARTUP, + Q, + AMAZONQ_CHAT_PANEL, + UNKNOWN; + override fun toString(): String { + val value = this.name.lowercase() + // If the string in lowercase contains an _ eg RESOURCE_EXPLORER, this function returns camelCase of the string i.e resourceExplorer + return if (value.contains("_")) { + // convert to camelCase + ( + value.substringBefore("_") + + value.substringAfter("_").replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() + } + ) + } else { + value + } + } +} + +class SetupAuthenticationDialog( + private val project: Project, + private val scopes: List = emptyList(), + private val state: SetupAuthenticationDialogState = SetupAuthenticationDialogState(), + private val tabSettings: Map = emptyMap(), + private val promptForIdcPermissionSet: Boolean = false, + private val configFilesFacade: ConfigFilesFacade = DefaultConfigFilesFacade(), + private val sourceOfEntry: SourceOfEntry, + private val featureId: FeatureId, + private val isFirstInstance: Boolean = false, + private val connectionInitiatedFromExplorer: Boolean = false, + private val connectionInitiatedFromQChatPanel: Boolean = false +) : DialogWrapper(project) { + private val rootTabPane = JBTabbedPane() + private val idcTab = idcTab() + private val builderIdTab = builderIdTab() + private val iamTab = iamTab() + private val wrappers = SetupAuthenticationTabs.values().associateWith { BorderLayoutPanel() } + var attempts = 0 + var authType = CredentialSourceId.IamIdentityCenter + + init { + title = message("gettingstarted.setup.title") + init() + + // actions don't exist until after init + okAction.putValue(Action.NAME, message("gettingstarted.setup.connect")) + } + + // called as part of init() + override fun createCenterPanel(): JComponent { + wrappers[SetupAuthenticationTabs.IDENTITY_CENTER]?.addToCenter(idcTab) + wrappers[SetupAuthenticationTabs.BUILDER_ID]?.addToCenter(builderIdTab) + wrappers[SetupAuthenticationTabs.IAM_LONG_LIVED]?.addToCenter(iamTab) + + idcTab.registerValidators(myDisposable) { validations -> + if (selectedTab() == SetupAuthenticationTabs.IDENTITY_CENTER) { + setOKActionEnabled(validations.values.all { it.okEnabled }) + } + } + + builderIdTab.registerValidators(myDisposable) { validations -> + if (selectedTab() == SetupAuthenticationTabs.BUILDER_ID) { + setOKActionEnabled(validations.values.all { it.okEnabled }) + } + } + + iamTab.registerValidators(myDisposable) { validations -> + if (selectedTab() == SetupAuthenticationTabs.IAM_LONG_LIVED) { + setOKActionEnabled(validations.values.all { it.okEnabled }) + } + } + + tabSettings.forEach { tab, settings -> + val notice = settings.notice + + wrappers[tab]?.addToTop( + BorderLayoutPanel().apply { + add(JLabel(notice.message + "\u00a0"), BorderLayout.CENTER) + add(BrowserLink(message("gettingstarted.setup.learnmore"), notice.learnMore), BorderLayout.EAST) + + background = when (notice.type) { + SetupAuthenticationNotice.NoticeType.WARNING -> JBUI.CurrentTheme.NotificationWarning.backgroundColor() + SetupAuthenticationNotice.NoticeType.ERROR -> JBUI.CurrentTheme.NotificationError.backgroundColor() + } + + val borderColor = when (notice.type) { + SetupAuthenticationNotice.NoticeType.WARNING -> JBUI.CurrentTheme.NotificationWarning.borderColor() + SetupAuthenticationNotice.NoticeType.ERROR -> JBUI.CurrentTheme.NotificationError.borderColor() + } + + border = editorNotificationCompoundBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, borderColor)) + } + ) + } + + rootTabPane.add(message("gettingstarted.setup.tabs.idc"), wrappers[SetupAuthenticationTabs.IDENTITY_CENTER]) + rootTabPane.add(message("gettingstarted.setup.tabs.builderid"), wrappers[SetupAuthenticationTabs.BUILDER_ID]) + rootTabPane.add(message("gettingstarted.setup.tabs.iam"), wrappers[SetupAuthenticationTabs.IAM_LONG_LIVED]) + + rootTabPane.selectedComponent = wrappers[state.selectedTab.get()] + + rootTabPane.addChangeListener { + val selectedTab = selectedTab() + + state.selectedTab.set(selectedTab) + okAction.isEnabled = tabSettings[selectedTab]?.disabled?.not() ?: true + } + + return rootTabPane + } + + override fun applyFields() { + when (selectedTab()) { + SetupAuthenticationTabs.IDENTITY_CENTER -> { + idcTab.apply() + } + + SetupAuthenticationTabs.IAM_LONG_LIVED -> { + iamTab.apply() + } + + SetupAuthenticationTabs.BUILDER_ID -> { + builderIdTab.apply() + } + } + } + + override fun doValidateAll(): List = + when (selectedTab()) { + SetupAuthenticationTabs.IDENTITY_CENTER -> { + idcTab.validateAll() + } + + SetupAuthenticationTabs.IAM_LONG_LIVED -> { + iamTab.validateAll() + } + + SetupAuthenticationTabs.BUILDER_ID -> { + emptyList() + } + } + + @VisibleForTesting + public override fun doOKAction() { + if (!okAction.isEnabled) { + return + } + + applyFields() + val scopes = if (promptForIdcPermissionSet) { + (scopes + IDENTITY_CENTER_ROLE_ACCESS_SCOPE).toSet().toList() + } else { + scopes + } + + when (selectedTab()) { + SetupAuthenticationTabs.IDENTITY_CENTER -> { + authType = CredentialSourceId.IamIdentityCenter + val profileName = state.idcTabState.profileName + // we have this check here so we blow up early if user has an invalid config file + try { + configFilesFacade.readSsoSessions() + } catch (e: Exception) { + handleConfigFacadeError(e) + return + } + + val profile = UserConfigSsoSessionProfile( + configSessionName = profileName, + ssoRegion = state.idcTabState.region.id, + startUrl = state.idcTabState.startUrl, + scopes = scopes + ) + + val connection = authAndUpdateConfig(project, profile, configFilesFacade) { + Messages.showErrorDialog(project, it, title) + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(sourceOfEntry, isFirstInstance, connectionInitiatedFromExplorer, connectionInitiatedFromQChatPanel), + featureId = featureId, + credentialSourceId = CredentialSourceId.IamIdentityCenter, + isAggregated = false, + attempts = ++attempts, + result = Result.Failed, + reason = "ConnectionUnsuccessful" + ) + } ?: return + + if (!promptForIdcPermissionSet) { + ToolkitConnectionManager.getInstance(project).switchConnection(connection) + close(OK_EXIT_CODE) + return + } + + val tokenProvider = connection.getConnectionSettings().tokenProvider + val rolePopup = IdcRolePopup( + project, + state.idcTabState.region.id, + profileName, + tokenProvider, + state.idcTabState.rolePopupState, + configFilesFacade = configFilesFacade + ) + + if (!rolePopup.showAndGet()) { + // don't close window if role is needed but was not confirmed + return + } + } + + SetupAuthenticationTabs.BUILDER_ID -> { + authType = CredentialSourceId.AwsId + val newScopes = if (featureId == FeatureId.Q || featureId == FeatureId.Codewhisperer) scopes - Q_SCOPES_UNAVAILABLE_BUILDER_ID else scopes + loginSso(project, SONO_URL, SONO_REGION, newScopes) + } + + SetupAuthenticationTabs.IAM_LONG_LIVED -> { + authType = CredentialSourceId.SharedCredentials + val profileName = state.iamTabState.profileName + val existingProfiles = try { + configFilesFacade.readAllProfiles() + } catch (e: Exception) { + handleConfigFacadeError(e) + return + } + + if (existingProfiles.containsKey(profileName)) { + Messages.showErrorDialog(project, message("gettingstarted.setup.iam.profile.exists", profileName), title) + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(sourceOfEntry, isFirstInstance, connectionInitiatedFromExplorer), + featureId = featureId, + credentialSourceId = CredentialSourceId.SharedCredentials, + isAggregated = false, + attempts = ++attempts, + result = Result.Failed, + reason = "DuplicateProfileName" + ) + return + } + + val callerIdentity = tryOrNull { + runUnderProgressIfNeeded(project, message("settings.states.validating.short"), cancelable = true) { + AwsClientManager.getInstance().createUnmanagedClient( + StaticCredentialsProvider.create(AwsBasicCredentials.create(state.iamTabState.accessKey, state.iamTabState.secretKey)), + Region.AWS_GLOBAL + ).use { client -> + client.getCallerIdentity() + } + } + } + + if (callerIdentity == null) { + Messages.showErrorDialog(project, message("gettingstarted.setup.iam.profile.invalid_credentials"), title) + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(sourceOfEntry, isFirstInstance, connectionInitiatedFromExplorer), + featureId = featureId, + credentialSourceId = CredentialSourceId.SharedCredentials, + isAggregated = false, + attempts = ++attempts, + result = Result.Failed, + reason = "InvalidCredentials" + ) + return + } + + val profile = Profile.builder() + .name(profileName) + .properties( + mapOf( + "aws_access_key_id" to state.iamTabState.accessKey, + "aws_secret_access_key" to state.iamTabState.secretKey + ) + ) + .build() + + configFilesFacade.appendProfileToCredentials(profile) + } + } + + close(OK_EXIT_CODE) + } + + private fun selectedTab() = wrappers.entries.firstOrNull { (_, wrapper) -> wrapper == rootTabPane.selectedComponent }?.key + ?: error("Could not determine selected tab") + + private fun idcTab() = panel { + row(message("gettingstarted.setup.iam.profile")) { + textField() + .comment(message("gettingstarted.setup.idc.profile.comment")) + .errorOnApply(message("gettingstarted.setup.error.not_empty")) { it.text.isBlank() } + .bindText(state.idcTabState::profileName) + } + + row(message("gettingstarted.setup.idc.startUrl")) { + textField() + .comment(message("gettingstarted.setup.idc.startUrl.comment")) + .align(AlignX.FILL) + .errorOnApply(message("gettingstarted.setup.error.not_empty")) { it.text.isBlank() } + .errorOnApply(message("gettingstarted.setup.idc.no_builder_id")) { it.text == SONO_URL } + .bindText(state.idcTabState::startUrl) + } + + row(message("gettingstarted.setup.idc.region")) { + comboBox( + AwsRegionProvider.getInstance().allRegionsForService("sso").values, + SimpleListCellRenderer.create("null") { it.displayName } + ).bindItem(state.idcTabState::region.toNullableProperty()) + .errorOnApply(message("gettingstarted.setup.error.not_selected")) { it.selected() == null } + } + } + + private fun builderIdTab() = panel { + row { + text(message("gettingstarted.setup.builderid.notice")) + } + + indent { + message("gettingstarted.setup.builderid.bullets").split("\n").forEach { + row { + text(" $it") + } + } + } + } + + // https://docs.aws.amazon.com/STS/latest/APIReference/API_Credentials.html + private val accessKeyRegex = "\\w{16,128}".toRegex() + + private fun iamTab() = panel { + row { + text(message("gettingstarted.setup.iam.notice")) { hyperlinkEvent -> + val actionEvent = AnActionEvent.createFromInputEvent( + hyperlinkEvent.inputEvent, + ToolkitPlaces.ADD_CONNECTION_DIALOG, + null, + DataContext { if (PlatformDataKeys.PROJECT.`is`(it)) project else null } + ) + ActionManager.getInstance().getAction("aws.settings.upsertCredentials").actionPerformed(actionEvent) + } + } + + row(message("gettingstarted.setup.iam.profile")) { + textField() + .comment(message("gettingstarted.setup.iam.profile.comment")) + .errorOnApply(message("gettingstarted.setup.error.not_empty")) { it.text.isBlank() } + .bindText(state.iamTabState::profileName) + } + + row(message("gettingstarted.setup.iam.access_key")) { + textField() + .errorOnApply(message("gettingstarted.setup.error.not_empty")) { it.text.isBlank() } + .errorOnApply(message("gettingstarted.setup.iam.access_key.invalid")) { !accessKeyRegex.matches(it.text) } + .bindText(state.iamTabState::accessKey) + } + + row(message("gettingstarted.setup.iam.secret_key")) { + passwordField() + .errorOnApply(message("gettingstarted.setup.error.not_empty")) { it.password.isEmpty() } + .bindText(state.iamTabState::secretKey) + } + } + + private fun handleConfigFacadeError(e: Exception) { + // we'll consider nested exceptions and exception loops to be out of scope + val (errorTemplate, errorType) = if (e.stackTrace.any { it.className == ProfileFileReader::class.java.canonicalName }) { + "gettingstarted.auth.config.issue" to "ConfigParseError" + } else { + "codewhisperer.credential.login.exception.general" to e::class.java.name + } + + AuthTelemetry.addConnection( + project, + source = getSourceOfEntry(sourceOfEntry, isFirstInstance, connectionInitiatedFromExplorer, connectionInitiatedFromQChatPanel), + featureId = featureId, + credentialSourceId = CredentialSourceId.IamIdentityCenter, + isAggregated = false, + attempts = ++attempts, + result = Result.Failed, + reason = errorType + ) + + val error = message(errorTemplate, e.localizedMessage ?: e::class.java.name) + LOG.error(e) { error } + Messages.showErrorDialog(project, error, title) + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedEditor.kt new file mode 100644 index 0000000000..7f0845a5dc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedEditor.kt @@ -0,0 +1,44 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted.editor + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.components.JBScrollPane +import java.beans.PropertyChangeListener +import javax.swing.JComponent + +class GettingStartedEditor( + private val project: Project, + private val file: VirtualFile, + private val isFirstInstance: Boolean, + private val connectionInitiatedFromExplorer: Boolean = false +) : + UserDataHolderBase(), FileEditor { + override fun dispose() { + } + + override fun getComponent(): JComponent = JBScrollPane(GettingStartedPanel(project, isFirstInstance, connectionInitiatedFromExplorer)) + + override fun getFile(): VirtualFile = file + + override fun getName(): String = file.name + + override fun getPreferredFocusedComponent(): JComponent? = null + + override fun setState(state: FileEditorState) {} + + override fun isModified(): Boolean = false + + override fun isValid(): Boolean = true + + override fun addPropertyChangeListener(listener: PropertyChangeListener) { + } + + override fun removePropertyChangeListener(listener: PropertyChangeListener) { + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedEditorProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedEditorProvider.kt new file mode 100644 index 0000000000..68bda003f1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedEditorProvider.kt @@ -0,0 +1,29 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted.editor + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +class GettingStartedEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile) = file is GettingStartedVirtualFile + + override fun createEditor(project: Project, file: VirtualFile): FileEditor { + file as GettingStartedVirtualFile + val firstInstance = file.firstInstance + return GettingStartedEditor(project, file, firstInstance, file.connectionInitiatedFromExplorer) + } + + override fun getEditorTypeId() = EDITOR_TYPE + + override fun getPolicy() = FileEditorPolicy.HIDE_DEFAULT_EDITOR + + companion object { + const val EDITOR_TYPE = "GettingStartedUxMainPanel" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedFileIconProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedFileIconProvider.kt new file mode 100644 index 0000000000..7b6ea22958 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedFileIconProvider.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted.editor + +import com.intellij.ide.FileIconProvider +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import icons.AwsIcons +import javax.swing.Icon + +class GettingStartedFileIconProvider : FileIconProvider { + override fun getIcon(file: VirtualFile, flags: Int, project: Project?): Icon? = if (file is GettingStartedVirtualFile) { + AwsIcons.Logos.AWS_SMILE_SMALL + } else { + null + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedPanel.kt new file mode 100644 index 0000000000..973ba4a2d1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedPanel.kt @@ -0,0 +1,1197 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted.editor + +import com.intellij.icons.AllIcons +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DefaultProjectFactory +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.MessageDialogBuilder +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager +import com.intellij.ui.GotItTooltip +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.TitledSeparator +import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.IntelliJSpacingConfiguration +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.actionListener +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.gridLayout.Gaps +import com.intellij.util.Alarm +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import icons.AwsIcons +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ProfileSsoManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.loginSso +import software.aws.toolkits.jetbrains.core.credentials.logoutFromSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeCatalystConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.ConnectionPinningManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.FeatureWithPinnedConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.CODECATALYST_SCOPES +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.core.explorer.AwsToolkitExplorerToolWindow +import software.aws.toolkits.jetbrains.core.explorer.cwqTab.nodes.CodeWhispererExplorerRootNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.DevToolsToolWindow +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.CawsServiceNode +import software.aws.toolkits.jetbrains.core.gettingstarted.SourceOfEntry +import software.aws.toolkits.jetbrains.core.gettingstarted.deleteSsoConnectionCW +import software.aws.toolkits.jetbrains.core.gettingstarted.deleteSsoConnectionExplorer +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel.PanelConstants.BULLET_PANEL_HEIGHT +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel.PanelConstants.GOT_IT_ID_PREFIX +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel.PanelConstants.PANEL_HEIGHT +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel.PanelConstants.PANEL_TITLE_FONT +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel.PanelConstants.PANEL_WIDTH +import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsForCodeWhisperer +import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsForExplorer +import software.aws.toolkits.jetbrains.services.caws.CawsEndpoints +import software.aws.toolkits.jetbrains.services.caws.CawsResources +import software.aws.toolkits.jetbrains.services.codewhisperer.learn.LearnCodeWhispererEditorProvider +import software.aws.toolkits.jetbrains.ui.feedback.FeedbackDialog +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend +import software.aws.toolkits.jetbrains.utils.ui.editorNotificationCompoundBorder +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AuthTelemetry +import software.aws.toolkits.telemetry.CredentialSourceId +import software.aws.toolkits.telemetry.FeatureId +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.UiTelemetry +import java.awt.Dimension +import java.awt.Image +import javax.swing.ImageIcon +import javax.swing.JComponent +import javax.swing.JLabel + +class GettingStartedPanel( + private val project: Project, + private val isFirstInstance: Boolean = false, + private val connectionInitiatedFromExplorer: Boolean = false +) : BorderLayoutPanel(), Disposable { + private val infoBanner = ConnectionInfoBanner() + private val featureSetPanel = FeatureColumns() + private val alarm = Alarm() + private val oldConnectionCount = getConnectionCount() + private val initialEnabledConnection = getEnabledConnections(project) + + init { + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun onChange(providerId: String) { + connectionUpdated() + } + } + ) + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + connectionUpdated() + } + } + ) + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + ConnectionPinningManagerListener.TOPIC, + object : ConnectionPinningManagerListener { + override fun pinnedConnectionChanged(feature: FeatureWithPinnedConnection, newConnection: ToolkitConnection?) { + connectionUpdated() + } + } + ) + + addToCenter( + panel { + indent { + row { + icon(AwsIcons.Logos.AWS_SMILE_LARGE) + panel { + row { + label(message("aws.onboarding.getstarted.panel.title")).applyToComponent { + font = JBFont.h1().asBold() + } + } + row { + browserLink(message("aws.onboarding.getstarted.panel.comment_link_doc"), url = AwsToolkit.AWS_DOCS_URL) + .actionListener { event, component -> + UiTelemetry.click(project, "auth_GettingStartedDocumentation") + } + browserLink(message("aws.onboarding.getstarted.panel.comment_link_github"), url = AwsToolkit.GITHUB_URL) + .actionListener { event, component -> + UiTelemetry.click(project, "auth_GettingStartedConnectOnGithub") + } + text(message("aws.onboarding.getstarted.panel.share_feedback")) { hyperlinkEvent -> + val actionEvent = AnActionEvent.createFromInputEvent( + hyperlinkEvent.inputEvent, + PanelConstants.SHARE_FEEDBACK_LINK, + null + ) { if (PlatformDataKeys.PROJECT.`is`(it)) project else null } + ActionManager.getInstance().getAction("aws.toolkit.getstarted.shareFeedback").actionPerformed(actionEvent) + } + } + } + } + + row { + cell(infoBanner) + .align(AlignX.FILL) + + topGap(TopGap.MEDIUM) + bottomGap(BottomGap.MEDIUM) + } + + // can't use group() because the font cant be overridden + row { + panel { + row { + cell(TitledSeparator(message("aws.onboarding.getstarted.panel.group_title"))).applyToComponent { + border = null + setTitleFont(JBFont.h1().asBold()) + }.align(AlignX.FILL) + } + row { + label("Note: " + (message("gettingstarted.codewhisperer.remote"))).applyToComponent { + + font = JBFont.h4().asBold() + } + }.bottomGap(BottomGap.SMALL).visible(isRunningOnRemoteBackend()) + featureSetPanel.setFeatureContent() + row { + cell(featureSetPanel) + } + } + } + + collapsibleGroup(message("aws.onboarding.getstarted.panel.bottom_text_question")) { + row { + text(message("aws.onboarding.getstarted.panel.bottom_text")) + } + row { + // CodeWhisperer auth bullets + cell( + PanelAuthBullets( + message("aws.codewhispererq.tab.title"), + listOf( + AuthPanelBullet( + true, + message("iam_identity_center.name"), + message("aws.onboarding.getstarted.panel.idc_row_comment_text") + ), + AuthPanelBullet( + true, + message("aws_builder_id.service_name"), + message("aws.onboarding.getstarted.panel.builderid_row_comment_text") + ), + AuthPanelBullet( + false, + message("settings.credentials.iam"), + message("aws.getstarted.auth.panel.notSupport_text"), + ) + ) + ) + ).visible(!isRunningOnRemoteBackend()) + // Resource Explorer panel auth bullets + cell( + PanelAuthBullets( + message("aws.getstarted.resource.panel_title"), + listOf( + AuthPanelBullet( + true, + message("iam_identity_center.name"), + message("aws.onboarding.getstarted.panel.idc_row_comment_text") + ), + AuthPanelBullet( + false, + message("aws_builder_id.service_name"), + message("aws.getstarted.auth.panel.notSupport_text") + ), + AuthPanelBullet( + true, + message("settings.credentials.iam"), + message("aws.onboarding.getstarted.panel.iam_row_comment_text") + ) + ) + ) + ) + // CodeCatalyst panel auth bullets + cell( + PanelAuthBullets( + message("caws.title"), + listOf( + AuthPanelBullet( + false, + message("iam_identity_center.name"), + message("aws.getstarted.auth.panel.notSupport_text") + ), + AuthPanelBullet( + true, + message("aws_builder_id.service_name"), + message("aws.onboarding.getstarted.panel.builderid_row_comment_text") + ), + AuthPanelBullet( + false, + message("settings.credentials.iam"), + message("aws.getstarted.auth.panel.notSupport_text") + ) + ) + ) + ) + } + } + } + }.apply { + isOpaque = false + } + ) + + border = JBUI.Borders.empty(JBUI.scale(32), JBUI.scale(16)) + } + + private fun connectionUpdated() { + alarm.cancelAllRequests() + alarm.addRequest( + { + featureSetPanel.setFeatureContent() + }, + 1000 + ) + } + + private fun showGotIt(tabName: String, nodeName: String?, tooltip: GotItTooltip) { + AwsToolkitExplorerToolWindow.toolWindow(project).activate { + AwsToolkitExplorerToolWindow.getInstance(project).selectTab(tabName)?.let { + if (tabName == AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID) { + DevToolsToolWindow.getInstance(project).showGotIt(nodeName, tooltip) + } else { + tooltip.show(it as JComponent, GotItTooltip.TOP_MIDDLE) + } + } + } + } + + private inner class CodeCatalystPanel : FeatureDescriptionPanel() { + override val loginSuccessTitle = message("gettingstarted.setup.auth.success.title", message("caws.devtoolPanel.title")) + override val loginSuccessBody = message("gettingstarted.setup.auth.success.body", message("caws.devtoolPanel.title")) + lateinit var panelNotConnected: Panel + lateinit var panelConnected: Panel + lateinit var panelReauthenticationRequired: Panel + lateinit var panelConnectionInProgress: Panel + + init { + addToCenter( + panel { + indent { + row { + label(message("caws.title")) + .applyToComponent { + font = PANEL_TITLE_FONT + } + } + + image("/gettingstarted/codecatalyst.png") + + row { + text(message("caws.getstarted.panel.description")) + } + + row { + browserLink(message("codewhisperer.gettingstarted.panel.learn_more"), CawsEndpoints.ConsoleFactory.baseUrl()) + .actionListener { event, component -> + UiTelemetry.click(project, "auth_CodecatalystDocumentation") + } + } + panelNotConnected = panel { + row { + button(message("caws.getstarted.panel.login")) { + val loginSuccess = tryOrNull { + controlPanelVisibility(panelNotConnected, panelConnectionInProgress) + loginSso(project, SONO_URL, SONO_REGION, CODECATALYST_SCOPES) + } != null + + handleLogin(loginSuccess) + + if (loginSuccess) { + controlPanelVisibility(panelConnectionInProgress, panelConnected) + val tooltip = GotItTooltip( + "aws.toolkit.devtool.tab.whatsnew", + message("gettingstarted.explorer.gotit.codecatalyst.body"), + project + ) + .withHeader(message("gettingstarted.explorer.gotit.codecatalyst.title")) + .withPosition(Balloon.Position.above) + + showGotIt(AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID, CawsServiceNode.NODE_NAME, tooltip) + AuthTelemetry.addConnection( + project, + getSourceOfEntry(SourceOfEntry.CODECATALYST, isFirstInstance, connectionInitiatedFromExplorer), + FeatureId.Codecatalyst, + CredentialSourceId.AwsId, + isAggregated = true, + Result.Succeeded + ) + AuthTelemetry.addedConnections( + project, + getSourceOfEntry(SourceOfEntry.CODECATALYST, isFirstInstance, connectionInitiatedFromExplorer), + oldConnectionCount, + getConnectionCount() - oldConnectionCount, + enabledAuthConnections = initialEnabledConnection, + newEnabledAuthConnections = getEnabledConnections(project).toString(), + attempts = 1, + Result.Succeeded + ) + } else { + controlPanelVisibility(panelConnectionInProgress, panelNotConnected) + AuthTelemetry.addConnection( + project, + getSourceOfEntry(SourceOfEntry.CODECATALYST, isFirstInstance, connectionInitiatedFromExplorer), + FeatureId.Codecatalyst, + CredentialSourceId.AwsId, + isAggregated = false, + Result.Failed, + reason = "Browserloginfailure" + ) + AuthTelemetry.addedConnections( + project, + getSourceOfEntry(SourceOfEntry.CODECATALYST, isFirstInstance, connectionInitiatedFromExplorer), + oldConnectionCount, + getConnectionCount() - oldConnectionCount, + enabledAuthConnections = initialEnabledConnection, + newEnabledAuthConnections = getEnabledConnections(project).toString(), + attempts = 1, + Result.Failed + ) + } + }.applyToComponent { + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + } + } + row { + browserLink(message("gettingstarted.codecatalyst.panel.setup"), PanelConstants.SET_UP_CODECATALYST) + } + }.visible(checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODECATALYST) is ActiveConnection.NotConnected) + panelConnectionInProgress = panel { + row { + button( + message("gettingstarted.connecting.in.browser") + ) {}.applyToComponent { + this.isEnabled = false + } + } + row { + browserLink(message("gettingstarted.codecatalyst.panel.setup"), PanelConstants.SET_UP_CODECATALYST) + } + }.visible(false) + + panelConnected = panel { + row { + button(message("gettingstarted.codecatalyst.open.explorer")) { + AwsToolkitExplorerToolWindow.getInstance(project).selectTab(AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID)?.isVisible = true + } + } + val connectionSettings = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature( + CodeCatalystConnection.getInstance() + ) as AwsBearerTokenConnection? + if (connectionSettings != null) { + AwsResourceCache.getInstance().getResource( + CawsResources.ALL_SPACES, + connectionSettings.getConnectionSettings() + ).thenAccept { spaces -> + row { + label(message("caws.getstarted.panel.question.text")) + }.visible(spaces.isEmpty()) + row { + browserLink(message("gettingstarted.codecatalyst.panel.create.space"), PanelConstants.CREATE_CODECATALYST_SPACE) + }.visible(spaces.isEmpty()) + } + } + + row { + label(message("gettingstarted.auth.connected.builderid")).applyToComponent { this.icon = PanelConstants.CHECKMARK_ICON } + } + row { + link(message("toolkit.login.aws_builder_id.already_connected.reconnect")) { + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature( + CodeCatalystConnection.getInstance() + ) as AwsBearerTokenConnection + logoutFromSsoConnection(project, connection) { + controlPanelVisibility(panelConnected, panelNotConnected) + } + } + } + }.visible(checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODECATALYST) is ActiveConnection.ValidBearer) + + panelReauthenticationRequired = panel { + row { + button(message("general.auth.reauthenticate")) { + controlPanelVisibility(panelReauthenticationRequired, panelConnectionInProgress) + val loginSuccess = tryOrNull { + loginSso(project, SONO_URL, SONO_REGION, CODECATALYST_SCOPES) + } != null + + handleLogin(loginSuccess) + + if (loginSuccess) { + controlPanelVisibility(panelConnectionInProgress, panelConnected) + val tooltip = GotItTooltip( + "aws.toolkit.devtool.tab.whatsnew", + message("gettingstarted.explorer.gotit.codecatalyst.body"), + project + ) + .withHeader(message("gettingstarted.explorer.gotit.codecatalyst.title")) + .withPosition(Balloon.Position.above) + + showGotIt(AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID, CawsServiceNode.NODE_NAME, tooltip) + } else { + controlPanelVisibility(panelConnectionInProgress, panelReauthenticationRequired) + } + }.applyToComponent { + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + } + } + + val connectionSettings = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature( + CodeCatalystConnection.getInstance() + ) as AwsBearerTokenConnection? + if (connectionSettings != null) { + AwsResourceCache.getInstance().getResource( + CawsResources.ALL_SPACES, + connectionSettings.getConnectionSettings() + ).thenAccept { spaces -> + row { + label(message("caws.getstarted.panel.question.text")) + }.visible(spaces.isEmpty()) + row { + browserLink(message("gettingstarted.codecatalyst.panel.create.space"), PanelConstants.CREATE_CODECATALYST_SPACE) + }.visible(spaces.isEmpty()) + } + } + + row { + label(message("gettingstarted.auth.builderid.expired")).applyToComponent { icon = PanelConstants.X_ICON } + } + row { + link(message("toolkit.login.aws_builder_id.already_connected.reconnect")) { + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature( + CodeCatalystConnection.getInstance() + ) as AwsBearerTokenConnection + logoutFromSsoConnection(project, connection) { + controlPanelVisibility(panelConnected, panelNotConnected) + } + } + } + }.visible(checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODECATALYST) is ActiveConnection.ExpiredBearer) + } + }.apply { + isOpaque = false + } + ) + } + } + + private inner class ResourceExplorerPanel : FeatureDescriptionPanel() { + override val loginSuccessTitle = message("gettingstarted.setup.auth.success.iam.title") + override val loginSuccessBody = message("gettingstarted.setup.auth.success.iam.body") + lateinit var panelNotConnected: Panel + lateinit var panelConnected: Panel + lateinit var panelReauthenticationRequired: Panel + lateinit var panelConnectionInProgress: Panel + + init { + addToCenter( + panel { + indent { + row { + label(message("aws.getstarted.resource.panel_title")) + .applyToComponent { + font = PANEL_TITLE_FONT + } + } + + image("/gettingstarted/explorer.png") + + row { + text(message("aws.getstarted.resource.panel_description")) + } + + row { + browserLink( + message("codewhisperer.gettingstarted.panel.learn_more"), + url = PanelConstants.RESOURCE_EXPLORER_LEARN_MORE + ).actionListener { event, component -> + UiTelemetry.click(project, "auth_ResourceExplorerDocumentation") + } + } + panelNotConnected = panel { + row { + button(message("aws.onboarding.getstarted.panel.button_iam_login")) { + controlPanelVisibility(panelNotConnected, panelConnectionInProgress) + val loginSuccess = requestCredentialsForExplorer( + project, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ) + handleLogin(loginSuccess) + + if (loginSuccess) { + val tooltip = GotItTooltip( + "$GOT_IT_ID_PREFIX.explorer", + message("gettingstarted.explorer.gotit.explorer.body"), + project + ) + .withHeader(message("gettingstarted.explorer.gotit.explorer.title")) + .withPosition(Balloon.Position.below) + + showGotIt(AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID, null, tooltip) + controlPanelVisibility(panelConnectionInProgress, panelConnected) + } else { + controlPanelVisibility(panelConnectionInProgress, panelNotConnected) + } + }.applyToComponent { + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + } + + topGap(TopGap.MEDIUM) + } + + row { + label(message("aws.getstarted.resource.panel_question_text")) + } + row { + browserLink(message("aws.onboarding.getstarted.panel.signup_iam_text"), url = PanelConstants.RESOURCE_EXPLORER_SIGNUP_DOC) + } + }.visible(checkIamConnectionValidity(project) is ActiveConnection.NotConnected) + panelConnectionInProgress = panel { + row { + button(message("general.open.in.progress")) {}.applyToComponent { + this.isEnabled = false + } + } + row { + label(message("aws.getstarted.resource.panel_question_text")) + } + row { + browserLink(message("aws.onboarding.getstarted.panel.signup_iam_text"), url = PanelConstants.RESOURCE_EXPLORER_SIGNUP_DOC) + } + }.visible(false) + + panelConnected = panel { + row { + button(message("gettingstarted.explorer.open.menu")) { + AwsToolkitExplorerToolWindow.getInstance(project).selectTab(AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID)?.isVisible = true + } + } + row { + label(message("gettingstarted.auth.connected.iam")).applyToComponent { icon = PanelConstants.CHECKMARK_ICON } + }.visible(checkIamConnectionValidity(project).connectionType == ActiveConnectionType.IAM) + row { + label(message("gettingstarted.auth.connected.idc")).applyToComponent { this.icon = PanelConstants.CHECKMARK_ICON } + }.visible(checkIamConnectionValidity(project).connectionType == ActiveConnectionType.IAM_IDC) + row { + link(message("toolkit.login.aws_builder_id.already_connected.reconnect")) { + val activeConnection = checkIamConnectionValidity(project) + val connection = activeConnection.activeConnectionIam + if (connection != null) { + val confirmDeletion = MessageDialogBuilder.okCancel( + message("gettingstarted.auth.idc.sign.out.confirmation.title"), + message("gettingstarted.auth.idc.sign.out.confirmation") + ).yesText(message("general.confirm")).ask(project) + if (confirmDeletion) { + deleteSsoConnectionExplorer(connection) + controlPanelVisibility(panelConnected, panelNotConnected) + } + } + } + }.visible(checkIamConnectionValidity(project).connectionType == ActiveConnectionType.IAM_IDC) + row { + link(message("general.add.another")) { + requestCredentialsForExplorer( + project, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ) + } + } + }.visible(checkIamConnectionValidity(project) is ActiveConnection.ValidIam) + panelReauthenticationRequired = panel { + row { + button(message("general.auth.reauthenticate")) { + controlPanelVisibility(panelReauthenticationRequired, panelConnectionInProgress) + val loginSuccess = requestCredentialsForExplorer( + project, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ) + handleLogin(loginSuccess) + + if (loginSuccess) { + controlPanelVisibility(panelConnectionInProgress, panelConnected) + val tooltip = GotItTooltip( + "$GOT_IT_ID_PREFIX.explorer", + message("gettingstarted.explorer.gotit.explorer.body"), + project + ) + .withHeader(message("gettingstarted.explorer.gotit.explorer.title")) + .withPosition(Balloon.Position.below) + + showGotIt(AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID, null, tooltip) + } else { + controlPanelVisibility(panelConnectionInProgress, panelReauthenticationRequired) + } + }.applyToComponent { + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + } + } + row { + button(message("gettingstarted.explorer.open.menu")) { + AwsToolkitExplorerToolWindow.getInstance(project).selectTab(AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID)?.isVisible = true + } + } + row { + label(message("gettingstarted.auth.idc.expired")).applyToComponent { icon = PanelConstants.X_ICON } + }.visible(checkIamConnectionValidity(project).connectionType == ActiveConnectionType.IAM_IDC) + + row { + link(message("toolkit.login.aws_builder_id.already_connected.reconnect")) { + val activeConnection = checkIamConnectionValidity(project) + val connection = activeConnection.activeConnectionIam + if (connection != null) { + val confirmDeletion = MessageDialogBuilder.okCancel( + message("gettingstarted.auth.idc.sign.out.confirmation.title"), + message("gettingstarted.auth.idc.sign.out.confirmation") + ).yesText(message("general.confirm")).ask(project) + if (confirmDeletion) { + deleteSsoConnectionExplorer(connection) + controlPanelVisibility(panelConnected, panelNotConnected) + } + } + } + }.visible(checkIamConnectionValidity(project).connectionType == ActiveConnectionType.IAM_IDC) + + row { + label(message("gettingstarted.auth.iam.invalid")).applyToComponent { icon = PanelConstants.X_ICON } + }.visible(checkIamConnectionValidity(project).connectionType == ActiveConnectionType.IAM) + + row { + link(message("general.add.another")) { + requestCredentialsForExplorer( + project, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ) + } + } + }.visible(checkIamConnectionValidity(project) is ActiveConnection.ExpiredIam) + } + }.apply { + isOpaque = false + } + ) + } + } + + private inner class CodeWhispererPanel : FeatureDescriptionPanel() { + override val loginSuccessTitle = message("gettingstarted.setup.auth.success.title", message("codewhisperer.experiment")) + override val loginSuccessBody = message("gettingstarted.setup.auth.success.body", message("codewhisperer.experiment")) + lateinit var panelNotConnected: Panel + lateinit var panelConnected: Panel + lateinit var panelReauthenticationRequired: Panel + lateinit var panelConnectionInProgress: Panel + init { + addToCenter( + panel { + indent { + row { + label(message("aws.codewhispererq.tab.title")) + .applyToComponent { + font = PANEL_TITLE_FONT + } + } + + image("/gettingstarted/q.png") + + row { + text(message("codewhisperer.gettingstarted.panel.comment")) + } + + row { + text(message("codewhisperer.gettingstarted.panel.learn_more.with.q")) + } + panelNotConnected = panel { + row { + button(message("codewhisperer.gettingstarted.panel.login_button")) { + controlPanelVisibility(panelNotConnected, panelConnectionInProgress) + handleCodeWhispererLogin( + requestCredentialsForCodeWhisperer( + project, + popupBuilderIdTab = true, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ), + panelNotConnected + ) + }.applyToComponent { + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + } + + topGap(TopGap.SMALL) + } + + row { + label(message("codewhisperer.gettingstarted.panel.licence_comment")) + } + row { + text(message("aws.onboarding.getstarted.panel.login_with_iam")) { + handleCodeWhispererLogin( + requestCredentialsForCodeWhisperer( + project, + popupBuilderIdTab = false, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ), + panelNotConnected + ) + } + } + }.visible(checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER) is ActiveConnection.NotConnected) + + panelConnectionInProgress = panel { + row { + button(message("gettingstarted.connecting.in.browser")) {}.applyToComponent { + this.isEnabled = false + } + } + row { + label(message("codewhisperer.gettingstarted.panel.licence_comment")) + } + row { + text(message("aws.onboarding.getstarted.panel.login_with_iam")) { + handleCodeWhispererLogin( + requestCredentialsForCodeWhisperer( + project, + popupBuilderIdTab = false, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ), + panelNotConnected + ) + } + } + }.visible(false) + panelConnected = panel { + row { + button(message("codewhisperer.explorer.learn")) { + LearnCodeWhispererEditorProvider.openEditor(project) + } + } + row { + label(message("gettingstarted.auth.connected.builderid")).applyToComponent { this.icon = PanelConstants.CHECKMARK_ICON } + }.visible( + checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER).connectionType == ActiveConnectionType.BUILDER_ID + ) + row { + label(message("gettingstarted.auth.connected.idc")).applyToComponent { this.icon = PanelConstants.CHECKMARK_ICON } + }.visible( + checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER).connectionType == ActiveConnectionType.IAM_IDC + ) + row { + link(message("toolkit.login.aws_builder_id.already_connected.reconnect")) { + val validConnection = checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER) + + val connection = validConnection.activeConnectionBearer + if (connection is ProfileSsoManagedBearerSsoConnection) { + if (validConnection.connectionType == ActiveConnectionType.IAM_IDC) { + val confirmDeletion = MessageDialogBuilder.okCancel( + message("gettingstarted.auth.idc.sign.out.confirmation.title"), + message("gettingstarted.auth.idc.sign.out.confirmation") + ).yesText(message("general.confirm")).ask(project) + if (confirmDeletion) { + deleteSsoConnectionCW(connection) + } + } + } + if (connection != null) { + logoutFromSsoConnection(project, connection) { + controlPanelVisibility(panelConnected, panelNotConnected) + } + } + } + } + row { + text(message("aws.onboarding.getstarted.panel.login_with_iam")) { + handleCodeWhispererLogin( + requestCredentialsForCodeWhisperer( + project, + popupBuilderIdTab = false, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ), + panelNotConnected + ) + } + }.visible( + checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER).connectionType == ActiveConnectionType.BUILDER_ID + ) + row { + text("${message("codewhisperer.gettingstarted.panel.login_button")}") { + controlPanelVisibility(panelConnected, panelConnectionInProgress) + handleCodeWhispererLogin( + requestCredentialsForCodeWhisperer( + project, + popupBuilderIdTab = true, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ), + panelConnected + ) + } + }.visible( + checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER).connectionType == ActiveConnectionType.IAM_IDC + ) + }.visible(checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER) is ActiveConnection.ValidBearer) + + panelReauthenticationRequired = panel { + row { + button(message("general.auth.reauthenticate")) { + controlPanelVisibility(panelReauthenticationRequired, panelConnectionInProgress) + handleCodeWhispererLogin( + requestCredentialsForCodeWhisperer( + project, + popupBuilderIdTab = true, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ), + panelReauthenticationRequired + ) + }.applyToComponent { + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + } + + topGap(TopGap.SMALL) + } + row { + label(message("gettingstarted.auth.builderid.expired")).applyToComponent { this.icon = PanelConstants.X_ICON } + }.visible( + checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER).connectionType == ActiveConnectionType.BUILDER_ID + ) + row { + label(message("gettingstarted.auth.idc.expired")).applyToComponent { this.icon = PanelConstants.X_ICON } + }.visible( + checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER).connectionType == ActiveConnectionType.IAM_IDC + ) + row { + link(message("toolkit.login.aws_builder_id.already_connected.reconnect")) { + val validConnection = checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER) + val connection = validConnection.activeConnectionBearer + if (connection is ProfileSsoManagedBearerSsoConnection) { + if (validConnection.connectionType == ActiveConnectionType.IAM_IDC) { + val confirmDeletion = MessageDialogBuilder.okCancel( + message("gettingstarted.auth.idc.sign.out.confirmation.title"), + message("gettingstarted.auth.idc.sign.out.confirmation") + ).yesText(message("general.confirm")).ask(project) + if (confirmDeletion) { + deleteSsoConnectionCW(connection) + } + } + logoutFromSsoConnection(project, connection) { + controlPanelVisibility(panelConnected, panelNotConnected) + } + } + } + text(message("aws.onboarding.getstarted.panel.login_with_iam")) { + handleCodeWhispererLogin( + requestCredentialsForCodeWhisperer( + project, + popupBuilderIdTab = false, + isFirstInstance = isFirstInstance, + connectionInitiatedFromExplorer = connectionInitiatedFromExplorer + ), + panelNotConnected + ) + } + }.visible( + checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER).connectionType == ActiveConnectionType.BUILDER_ID + ) + row { + text("${message("codewhisperer.gettingstarted.panel.login_button")}") { + controlPanelVisibility(panelConnected, panelConnectionInProgress) + handleCodeWhispererLogin( + requestCredentialsForCodeWhisperer( + project, + popupBuilderIdTab = true, + oldConnectionCount, + initialEnabledConnection, + isFirstInstance, + connectionInitiatedFromExplorer + ), + panelConnected + ) + } + }.visible( + checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER).connectionType == ActiveConnectionType.IAM_IDC + ) + }.visible(checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER) is ActiveConnection.ExpiredBearer) + } + }.apply { + isOpaque = false + } + ) + } + + private fun handleCodeWhispererLogin(authResult: Boolean, revertToPanel: Panel) { + handleLogin(authResult) + if (authResult) { + controlPanelVisibility(panelConnectionInProgress, panelConnected) + val tooltip = GotItTooltip("$GOT_IT_ID_PREFIX.codewhisperer", message("codewhisperer.explorer.tooltip.comment"), project) + .withHeader(message("codewhisperer.explorer.tooltip.title")) + .withPosition(Balloon.Position.above) + + showGotIt(AwsToolkitExplorerToolWindow.CODEWHISPERER_Q_TAB_ID, CodeWhispererExplorerRootNode.NODE_NAME, tooltip) + } else { + controlPanelVisibility(panelConnectionInProgress, revertToPanel) + } + } + } + + private class PanelAuthBullets(private val panelTitle: String, bullets: List) : GettingStartedBorderedPanel() { + init { + preferredSize = Dimension(PANEL_WIDTH, BULLET_PANEL_HEIGHT) + + addToCenter( + panel { + indent { + row { + label(panelTitle).applyToComponent { + font = PANEL_TITLE_FONT + } + } + + bullets.forEach { bullet -> + row { + val icon = if (bullet.enable) { + PanelConstants.CHECKMARK_ICON + } else { + PanelConstants.X_ICON + } + + icon(icon) + panel { + row(bullet.titleName) { + }.rowComment(bullet.comment) + .enabled(bullet.enable) + } + } + } + } + }.apply { + isOpaque = false + } + ) + } + } + + private abstract class GettingStartedBorderedPanel : BorderLayoutPanel() { + init { + preferredSize = Dimension(PANEL_WIDTH, PANEL_HEIGHT) + + border = IdeBorderFactory.createRoundedBorder().apply { + setColor(UIUtil.getLabelForeground()) + } + + isOpaque = false + } + + private val indentSize = IntelliJSpacingConfiguration().horizontalIndent + + protected fun Panel.image(path: String) { + row { + // `this` is a [Row], so class needs to be specified or we get the wrong classloader + val image = ImageIcon(GettingStartedPanel::class.java.getResource(path)).image + // need to account for margin introduced by indent + // Image.SCALE_DEFAULT is the only valid parameter for gifs + .getScaledInstance(PANEL_WIDTH - (indentSize * 2), -1, if (path.endsWith("gif")) Image.SCALE_DEFAULT else Image.SCALE_SMOOTH) + cell(JLabel(ImageIcon(image))) + .customize(Gaps.EMPTY) + } + } + } + + private abstract inner class FeatureDescriptionPanel : GettingStartedBorderedPanel() { + abstract val loginSuccessTitle: String + abstract val loginSuccessBody: String + + protected fun handleLogin(authResult: Boolean) { + if (authResult) { + infoBanner.setSuccessMessage(loginSuccessTitle, loginSuccessBody) + } + } + } + + private class ConnectionInfoBanner : BorderLayoutPanel(10, 0) { + private val wrapper = Wrapper() + init { + addToCenter(wrapper) + } + + fun setSuccessMessage(title: String, body: String) = setMessage(title, body, false) + + fun setErrorMessage(title: String, body: String) = setMessage(title, body, true) + + fun setConnectionFailedMessage() = setErrorMessage( + message("gettingstarted.setup.auth.failure.title"), + message("gettingstarted.setup.auth.failure.body") + ) + + private fun setMessage(title: String, body: String, isError: Boolean) { + wrapper.setContent( + panel { + row { + val icon = if (isError) AllIcons.General.ErrorDialog else AllIcons.General.SuccessDialog + icon(icon) + panel { + row { + text(title).applyToComponent { + font = JBFont.label().asBold() + } + } + row { + text(body) + } + } + } + }.apply { + isOpaque = false + } + ) + + val (borderColor, backgroundColor) = if (isError) { + JBUI.CurrentTheme.Banner.ERROR_BORDER_COLOR to JBUI.CurrentTheme.Banner.ERROR_BACKGROUND + } else { + JBUI.CurrentTheme.Banner.SUCCESS_BORDER_COLOR to JBUI.CurrentTheme.Banner.SUCCESS_BACKGROUND + } + + border = editorNotificationCompoundBorder( + IdeBorderFactory.createRoundedBorder().apply { + setColor(borderColor) + } + ) + + background = backgroundColor + } + } + + private object PanelConstants { + const val GOT_IT_ID_PREFIX = "aws.toolkit.gettingstarted" + const val RESOURCE_EXPLORER_LEARN_MORE = "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/working-with-aws.html" + const val RESOURCE_EXPLORER_SIGNUP_DOC = "https://aws.amazon.com/free/" + const val SHARE_FEEDBACK_LINK = "FeedbackDialog" + const val SET_UP_CODECATALYST = "https://docs.aws.amazon.com/codecatalyst/latest/userguide/setting-up-topnode.html" + const val CREATE_CODECATALYST_SPACE = "https://codecatalyst.aws/spaces/create" + val CHECKMARK_ICON = AllIcons.General.InspectionsOK + val X_ICON = AllIcons.Ide.Notification.Close + val PANEL_TITLE_FONT = JBFont.h2().asBold() + const val PANEL_WIDTH = 300 + const val PANEL_HEIGHT = 450 + const val BULLET_PANEL_HEIGHT = 200 + } + + data class AuthPanelBullet( + val enable: Boolean, + val titleName: String, + val comment: String + ) + + private inner class FeatureColumns : BorderLayoutPanel(10, 0) { + private val wrapper = Wrapper() + init { + isOpaque = false + + addToCenter(wrapper) + } + + fun setFeatureContent() { + wrapper.setContent( + panel { + row { + // CodeWhisperer panel + cell(CodeWhispererPanel()).visible(!isRunningOnRemoteBackend()) + // Resource Explorer Panel + cell(ResourceExplorerPanel()) + // CodeCatalyst Panel + cell(CodeCatalystPanel()) + } + }.apply { + isOpaque = false + } + ) + } + } + + companion object { + fun openPanel(project: Project, firstInstance: Boolean = false, connectionInitiatedFromExplorer: Boolean = false) = FileEditorManager.getInstance( + project + ).openTextEditor( + OpenFileDescriptor( + project, + GettingStartedVirtualFile(firstInstance, connectionInitiatedFromExplorer) + ), + true + ) + } + + override fun dispose() { + } +} + +class ShareFeedbackInGetStarted : DumbAwareAction() { + override fun actionPerformed(e: AnActionEvent) { + runInEdt { + FeedbackDialog(DefaultProjectFactory.getInstance().defaultProject).show() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedPanelUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedPanelUtils.kt new file mode 100644 index 0000000000..8390fb6542 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedPanelUtils.kt @@ -0,0 +1,143 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted.editor + +import com.intellij.openapi.project.Project +import com.intellij.ui.dsl.builder.Panel +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ConnectionState +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.lazyIsUnauthedBearerConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeCatalystConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.profiles.SsoSessionConstants +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.gettingstarted.SourceOfEntry +import software.aws.toolkits.jetbrains.services.caws.CawsConstants + +enum class ActiveConnectionType { + BUILDER_ID, + IAM_IDC, + IAM, + UNKNOWN +} + +enum class BearerTokenFeatureSet { + CODEWHISPERER, + CODECATALYST, + Q +} + +fun controlPanelVisibility(currentPanel: Panel, newPanel: Panel) { + currentPanel.visible(false) + newPanel.visible(true) +} + +sealed interface ActiveConnection { + val activeConnectionBearer: AwsBearerTokenConnection? + val connectionType: ActiveConnectionType? + val activeConnectionIam: CredentialIdentifier? + + data class ExpiredBearer( + override val activeConnectionBearer: AwsBearerTokenConnection?, + override val connectionType: ActiveConnectionType?, + override val activeConnectionIam: CredentialIdentifier? = null + ) : ActiveConnection + + data class ExpiredIam( + override val activeConnectionBearer: AwsBearerTokenConnection? = null, + override val connectionType: ActiveConnectionType?, + override val activeConnectionIam: CredentialIdentifier? + ) : ActiveConnection + + data class ValidBearer( + override val activeConnectionBearer: AwsBearerTokenConnection?, + override val connectionType: ActiveConnectionType?, + override val activeConnectionIam: CredentialIdentifier? = null + ) : ActiveConnection + + data class ValidIam( + override val activeConnectionBearer: AwsBearerTokenConnection? = null, + override val connectionType: ActiveConnectionType?, + override val activeConnectionIam: CredentialIdentifier? + ) : ActiveConnection + + object NotConnected : ActiveConnection { + override val activeConnectionBearer: AwsBearerTokenConnection? + get() = null + + override val connectionType: ActiveConnectionType? + get() = null + + override val activeConnectionIam: CredentialIdentifier? + get() = null + } +} + +fun checkBearerConnectionValidity(project: Project, source: BearerTokenFeatureSet): ActiveConnection { + val connections = ToolkitAuthManager.getInstance().listConnections().filterIsInstance() + if (connections.isEmpty()) return ActiveConnection.NotConnected + + val activeConnection = when (source) { + BearerTokenFeatureSet.CODEWHISPERER -> ToolkitConnectionManager.getInstance(project).activeConnectionForFeature( + CodeWhispererConnection.getInstance() + ) + BearerTokenFeatureSet.CODECATALYST -> ToolkitConnectionManager.getInstance(project).activeConnectionForFeature( + CodeCatalystConnection.getInstance() + ) + BearerTokenFeatureSet.Q -> ToolkitConnectionManager.getInstance(project).activeConnectionForFeature( + QConnection.getInstance() + ) + } ?: return ActiveConnection.NotConnected + + activeConnection as AwsBearerTokenConnection + val connectionType = if (activeConnection.startUrl == SONO_URL) ActiveConnectionType.BUILDER_ID else ActiveConnectionType.IAM_IDC + return if (activeConnection.lazyIsUnauthedBearerConnection()) { + ActiveConnection.ExpiredBearer(activeConnection, connectionType) + } else { + ActiveConnection.ValidBearer(activeConnection, connectionType) + } +} + +fun checkIamConnectionValidity(project: Project): ActiveConnection { + val currConn = AwsConnectionManager.getInstance(project).selectedCredentialIdentifier ?: return ActiveConnection.NotConnected + val invalidConnection = AwsConnectionManager.getInstance(project).connectionState.let { it.isTerminal && it !is ConnectionState.ValidConnection } + return if (invalidConnection) { + ActiveConnection.ExpiredIam(connectionType = isCredentialSso(currConn.shortName), activeConnectionIam = currConn) + } else { + ActiveConnection.ValidIam(connectionType = isCredentialSso(currConn.shortName), activeConnectionIam = currConn) + } +} + +fun isCredentialSso(providerId: String): ActiveConnectionType { + val profileName = providerId.split("-").first() + val ssoSessionIds = CredentialManager.getInstance().getSsoSessionIdentifiers().map { + it.id.substringAfter( + "${SsoSessionConstants.SSO_SESSION_SECTION_NAME}:" + ) + } + return if (profileName in ssoSessionIds) ActiveConnectionType.IAM_IDC else ActiveConnectionType.IAM +} + +fun getSourceOfEntry( + sourceOfEntry: SourceOfEntry, + isStartup: Boolean = false, + connectionInitiatedFromExplorer: Boolean = false, + connectionInitiatedFromQChatPanel: Boolean = false +): String { + val src = if (connectionInitiatedFromExplorer) { + SourceOfEntry.EXPLORER.toString() + } else if (connectionInitiatedFromQChatPanel) { + SourceOfEntry.AMAZONQ_CHAT_PANEL.toString() + } else { + sourceOfEntry.toString() + } + val source = if (isStartup) SourceOfEntry.FIRST_STARTUP.toString() else src + return if (System.getenv(CawsConstants.CAWS_ENV_ID_VAR) != null) "REMOTE_$source" else source +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedTelemetryUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedTelemetryUtils.kt new file mode 100644 index 0000000000..1af2bc88e1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedTelemetryUtils.kt @@ -0,0 +1,51 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted.editor + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager +import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileCredentialsIdentifierSso + +fun getConnectionCount(): Int { + val bearerTokenCount = ToolkitAuthManager.getInstance().listConnections().size + val iamCredentialCount = CredentialManager.getInstance().getCredentialIdentifiers().count { it !is ProfileCredentialsIdentifierSso } + return bearerTokenCount + iamCredentialCount +} + +fun getEnabledConnectionsForTelemetry(project: Project): Set { + val enabledConnections = mutableSetOf() + val explorerConnection = checkIamConnectionValidity(project) + if (explorerConnection !is ActiveConnection.NotConnected) { + if (explorerConnection.connectionType == ActiveConnectionType.IAM_IDC) { + enabledConnections.add(AuthFormId.IDENTITYCENTER_EXPLORER) + } else enabledConnections.add( + AuthFormId.IAMCREDENTIALS_EXPLORER + ) + } + val codeCatalystConnection = checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODECATALYST) // Currently this will always be builder id + if (codeCatalystConnection !is ActiveConnection.NotConnected) enabledConnections.add(AuthFormId.BUILDERID_CODECATALYST) + + val codeWhispererConnection = checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER) + if (codeWhispererConnection !is ActiveConnection.NotConnected) { + if (codeWhispererConnection.connectionType == ActiveConnectionType.IAM_IDC) { + enabledConnections.add(AuthFormId.IDENTITYCENTER_CODEWHISPERER) + } else enabledConnections.add( + AuthFormId.BUILDERID_CODEWHISPERER + ) + } + return enabledConnections +} + +fun getEnabledConnections(project: Project): String = + getEnabledConnectionsForTelemetry(project).joinToString(",") + +enum class AuthFormId { + IAMCREDENTIALS_EXPLORER, + IDENTITYCENTER_EXPLORER, + BUILDERID_CODECATALYST, + BUILDERID_CODEWHISPERER, + IDENTITYCENTER_CODEWHISPERER, + UNKNOWN +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedVirtualFile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedVirtualFile.kt new file mode 100644 index 0000000000..b888f6ae12 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/editor/GettingStartedVirtualFile.kt @@ -0,0 +1,19 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted.editor + +import com.intellij.testFramework.LightVirtualFile +import software.aws.toolkits.resources.message + +class GettingStartedVirtualFile(val firstInstance: Boolean = false, val connectionInitiatedFromExplorer: Boolean = false) : LightVirtualFile( + message("gettingstarted.editor.title") +) { + override fun toString() = "GettingStartedVirtualFile[${getName()}]" + override fun getPath() = getName() + override fun isWritable() = false + override fun isDirectory() = false + + override fun hashCode() = toString().hashCode() + override fun equals(other: Any?) = other is GettingStartedVirtualFile && name == other.name +} 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 cc4a8718a1..6f4d561f72 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 @@ -3,20 +3,33 @@ package software.aws.toolkits.jetbrains.core.help +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants + enum class HelpIds(shortId: String, val url: String) { + // App Runner + APPRUNNER_PAUSE_RESUME( + "appRunnerPauseResume", + "https://docs.aws.amazon.com/console/apprunner/manage-pause" + ), + APPRUNNER_CODE_CONFIG( + "appRunnerCodeConfig", + "https://docs.aws.amazon.com/console/apprunner/config-file" + ), + APPRUNNER_CONNECTIONS( + "appRunnnerServiceConnections", + "https://docs.aws.amazon.com/console/apprunner/manage-connections" + ), + + // Explorer EXPLORER_WINDOW( "explorerWindow", "https://docs.aws.amazon.com/console/toolkit-for-jetbrains/aws-explorer" ), - // Cloud Debugging - CLOUD_DEBUG_ENABLE( - "enableCloudDebugging", - "https://docs.aws.amazon.com/console/toolkit-for-jetbrains/cloud-debug" - ), - CLOUD_DEBUG_RUN_CONFIGURATION( - "cloudDebugRunConfiguration", - "https://docs.aws.amazon.com/console/toolkit-for-jetbrains/run-debug-config-dialog-cloud-debug" + EXPLORER_CREDS_HELP( + "explorerCredsHelp", + "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/setup-credentials.html" ), + // Lambda CREATE_FUNCTION_DIALOG( "createFunctionDialog", @@ -30,6 +43,7 @@ enum class HelpIds(shortId: String, val url: String) { "updateFunctionCodeDialog", "https://docs.aws.amazon.com/console/toolkit-for-jetbrains/update-code-dialog" ), + // Serverless NEW_SERVERLESS_PROJECT_DIALOG( "newServerlessProjectDialog", @@ -39,16 +53,19 @@ enum class HelpIds(shortId: String, val url: String) { "deployServerlessApplicationDialog", "https://docs.aws.amazon.com/console/toolkit-for-jetbrains/deploy-serverless-application-dialog" ), + // Schema code download DOWNLOAD_CODE_FOR_SCHEMA_DIALOG( "downloadCodeForSchemaDialog", "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/eventbridge-schemas.html" ), + // Schema search SCHEMA_SEARCH_DIALOG( "schemaSearchDialog", "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/eventbridge-schemas.html" ), + // Others RUN_DEBUG_CONFIGURATIONS_DIALOG( "runDebugConfigurationsDialog", @@ -64,13 +81,56 @@ enum class HelpIds(shortId: String, val url: String) { ), CFN_LINT( "cloudformation.linter", - "https://github.com/aws-cloudformation/cfn-python-lint/blob/master/README.md" + "https://github.com/aws-cloudformation/cfn-lint/blob/main/README.md" ), // RDS RDS_SETUP_IAM_AUTH( "rdsIamAuth", "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html" - ); + ), + + // AWS CLI + AWS_CLI_INSTALL( + "awsCli.install", + "https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html" + ), + + // Ecs Exec + ECS_EXEC_PERMISSIONS_REQUIRED( + "ecsExecPermissions", + "https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-exec.html#ecs-exec-enabling-and-using" + ), + + // What is AWS Toolkit? + AWS_TOOLKIT_GETTING_STARTED( + "awsToolkitGettingStarted", + "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/welcome.html" + ), + + // CodeWhisperer + CODEWHISPERER_TOKEN( + "CodeWhispererToken", + CodeWhispererConstants.CODEWHISPERER_LEARN_MORE_URI + ), + + // TODO: update this + CODEWHISPERER_LOGIN_YES_NO( + "CodeWhispererLoginYesNoDialog", + CodeWhispererConstants.CODEWHISPERER_LOGIN_HELP_URI + ), + + // TODO: update this + CODEWHISPERER_LOGIN_DIALOG( + "CodeWhispererLoginDialog", + CodeWhispererConstants.CODEWHISPERER_LOGIN_HELP_URI + ), + + // TODO: update this + TOOLKIT_ADD_CONNECTIONS_DIALOG( + "ToolkitAddConnectionsDialog", + CodeWhispererConstants.CODEWHISPERER_LOGIN_HELP_URI + ) + ; val id = "aws.toolkit.$shortId" } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/JetBrainsMinimumVersionChange.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/JetBrainsMinimumVersionChange.kt deleted file mode 100644 index 14aab93117..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/JetBrainsMinimumVersionChange.kt +++ /dev/null @@ -1,46 +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.notification - -import com.intellij.openapi.application.ApplicationInfo -import com.intellij.openapi.application.ApplicationNamesInfo -import software.aws.toolkits.resources.message - -class JetBrainsMinimumVersionChange : NoticeType { - override val id: String = "JetBrainsMinimumVersion_193" - private val noticeContents = NoticeContents( - message("notice.title.jetbrains.minimum.version"), - message( - "notice.message.jetbrains.minimum.version", - ApplicationInfo.getInstance().fullVersion, - ApplicationNamesInfo.getInstance().fullProductName, - "2019.3" - ) - ) - - override fun getSuppressNotificationValue(): String = ApplicationInfo.getInstance().fullVersion - - override fun isNotificationSuppressed(previousSuppressNotificationValue: String?): Boolean { - previousSuppressNotificationValue?.let { - return previousSuppressNotificationValue == getSuppressNotificationValue() - } - return false - } - - override fun isNotificationRequired(): Boolean { - val appInfo = ApplicationInfo.getInstance() - val majorVersion = appInfo.majorVersion.toIntOrNull() - val minorVersion = appInfo.minorVersion.toFloatOrNull() - - majorVersion?.let { - minorVersion?.let { - return majorVersion < 2019 || (majorVersion == 2019 && minorVersion < 3) - } - } - - return true - } - - override fun getNoticeContents(): NoticeContents = noticeContents -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt new file mode 100644 index 0000000000..927fba3266 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt @@ -0,0 +1,65 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notification + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ApplicationNamesInfo +import com.intellij.openapi.extensions.ExtensionNotApplicableException +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity +import software.aws.toolkits.resources.message + +class MinimumVersionChange @JvmOverloads constructor(isUnderTest: Boolean = false) : StartupActivity.DumbAware { + init { + if (ApplicationManager.getApplication().isUnitTestMode && !isUnderTest) { + throw ExtensionNotApplicableException.INSTANCE + } + } + + override fun runActivity(project: Project) { + if (System.getProperty(SKIP_PROMPT)?.toBoolean() == true) { + return + } + + // Setting is stored application wide + if (PropertiesComponent.getInstance().getBoolean(IGNORE_PROMPT)) { + return + } + + if (ApplicationInfo.getInstance().build.baselineVersion >= MIN_VERSION) { + return + } + + val title = message("aws.toolkit_deprecation.title") + val message = message( + "aws.toolkit_deprecation.message", + ApplicationNamesInfo.getInstance().fullProductName, + ApplicationInfo.getInstance().fullVersion, + MIN_VERSION_HUMAN + ) + + val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup("aws.toolkit_deprecation") + notificationGroup.createNotification(title, message, NotificationType.WARNING) + .addAction( + NotificationAction.createSimpleExpiring(message("general.notification.action.hide_forever")) { + PropertiesComponent.getInstance().setValue(IGNORE_PROMPT, true) + } + ) + .notify(project) + } + + companion object { + const val MIN_VERSION = 231 + const val MIN_VERSION_HUMAN = "2023.1" + + // Used by tests to make sure the prompt never shows up + const val SKIP_PROMPT = "aws.suppress_deprecation_prompt" + const val IGNORE_PROMPT = "aws.ignore_deprecation_prompt" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/NoticeManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/NoticeManager.kt deleted file mode 100644 index bbbbae6c0e..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/NoticeManager.kt +++ /dev/null @@ -1,98 +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.notification - -import com.intellij.notification.NotificationDisplayType -import com.intellij.notification.NotificationGroup -import com.intellij.notification.NotificationType -import com.intellij.notification.Notifications -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -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.project.Project -import software.aws.toolkits.resources.message - -interface NoticeManager { - fun getRequiredNotices(notices: List, project: Project): List - fun notify(notices: List, project: Project) - - companion object { - fun getInstance(): NoticeManager = ServiceManager.getService(NoticeManager::class.java) - } -} - -internal const val NOTICE_NOTIFICATION_GROUP_ID = "AWS Toolkit Notices" - -@State(name = "notices", storages = [Storage("aws.xml")]) -class DefaultNoticeManager : PersistentStateComponent, - NoticeManager { - private val internalState = mutableMapOf() - private val notificationGroup = NotificationGroup(NOTICE_NOTIFICATION_GROUP_ID, NotificationDisplayType.STICKY_BALLOON, true) - - override fun getState(): NoticeStateList = NoticeStateList(internalState.values.map { it }.toList()) - - override fun loadState(state: NoticeStateList) { - internalState.clear() - state.value.forEach { - val id = it.id ?: return@forEach - internalState[id] = it - } - } - - /** - * Returns the notices that require notification - */ - override fun getRequiredNotices(notices: List, project: Project): List = notices.filter { it.isNotificationRequired() } - .filter { - internalState[it.id]?.let { state -> - state.noticeSuppressedValue?.let { previouslySuppressedValue -> - return@filter !it.isNotificationSuppressed(previouslySuppressedValue) - } - } - - true - } - - override fun notify(notices: List, project: Project) { - notices.forEach { notify(it, project) } - } - - private fun notify(notice: NoticeType, project: Project) { - val notification = notificationGroup.createNotification( - notice.getNoticeContents().title, - notice.getNoticeContents().message, - NotificationType.INFORMATION, - null - ) - - notification.addAction( - object : AnAction(message("notice.suppress")) { - override fun actionPerformed(e: AnActionEvent) { - suppressNotification(notice) - notification.hideBalloon() - } - } - ) - - Notifications.Bus.notify(notification, project) - } - - fun suppressNotification(notice: NoticeType) { - internalState[notice.id] = NoticeState(notice.id, notice.getSuppressNotificationValue()) - } - - fun resetAllNotifications() { - internalState.clear() - } -} - -data class NoticeStateList(var value: List = listOf()) - -data class NoticeState( - var id: String? = null, - var noticeSuppressedValue: String? = null -) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/NoticeStartupActivity.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/NoticeStartupActivity.kt deleted file mode 100644 index 15511d56ef..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/NoticeStartupActivity.kt +++ /dev/null @@ -1,19 +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.notification - -import com.intellij.openapi.components.ServiceManager -import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.project.Project -import com.intellij.openapi.startup.StartupActivity - -class NoticeStartupActivity : StartupActivity, DumbAware { - override fun runActivity(project: Project) { - val noticeManager = - ServiceManager.getService(NoticeManager::class.java) - - val notices = noticeManager.getRequiredNotices(NoticeType.notices(), project) - noticeManager.notify(notices, project) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/NoticeType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/NoticeType.kt deleted file mode 100644 index 6662fc4aac..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/NoticeType.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.core.notification - -import com.intellij.openapi.extensions.ExtensionPointName - -interface NoticeType { - val id: String - - // The value persisted to represent that this notice has been suppressed - fun getSuppressNotificationValue(): String - - // Indicates whether or not a suppressed notice should remain suppressed - fun isNotificationSuppressed(previousSuppressNotificationValue: String?): Boolean - - fun isNotificationRequired(): Boolean - - // Notification Title/Message - fun getNoticeContents(): NoticeContents - - companion object { - val EP_NAME = ExtensionPointName("aws.toolkit.notice") - - internal fun notices(): List = EP_NAME.extensions.toList() - } -} - -data class NoticeContents(val title: String, val message: String) 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 index b24f4aaf55..1d70a74e39 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/plugins/PluginUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/plugins/PluginUtils.kt @@ -6,4 +6,5 @@ package software.aws.toolkits.jetbrains.core.plugins import com.intellij.ide.plugins.PluginManagerCore.getPlugin import com.intellij.openapi.extensions.PluginId +// TODO: all usages should probably be leveraging EPs 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 1f622a03d8..1e688a191f 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 @@ -3,7 +3,7 @@ package software.aws.toolkits.jetbrains.core.region -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.components.service import org.slf4j.event.Level import software.amazon.awssdk.regions.providers.AwsProfileRegionProvider import software.amazon.awssdk.regions.providers.AwsRegionProviderChain @@ -18,15 +18,18 @@ import software.aws.toolkits.core.utils.inputStream import software.aws.toolkits.core.utils.logWhenNull import software.aws.toolkits.core.utils.tryOrNull import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider +import software.aws.toolkits.resources.BundledResources -class AwsRegionProvider constructor(remoteResourceResolverProvider: RemoteResourceResolverProvider) : ToolkitRegionProvider() { +class AwsRegionProvider : 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() + val inputStream = RemoteResourceResolverProvider.getInstance().get().resolve(ServiceEndpointResource).toCompletableFuture().get()?.inputStream() + val partitions = inputStream?.use { PartitionParser.parse(it) }?.partitions + ?: BundledResources.ENDPOINTS_FILE.use { PartitionParser.parse(BundledResources.ENDPOINTS_FILE) }?.partitions + ?: throw Exception("Failed to retrieve partitions.") partitions.asSequence().associateBy { it.partition }.mapValues { PartitionData( @@ -63,6 +66,6 @@ class AwsRegionProvider constructor(remoteResourceResolverProvider: RemoteResour private val LOG = getLogger() @JvmStatic - fun getInstance(): ToolkitRegionProvider = ServiceManager.getService(ToolkitRegionProvider::class.java) + fun getInstance(): ToolkitRegionProvider = service() } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/terminal/AwsLocalTerminalRunner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/terminal/AwsLocalTerminalRunner.kt new file mode 100644 index 0000000000..a072aaac81 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/terminal/AwsLocalTerminalRunner.kt @@ -0,0 +1,25 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.terminal + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.terminal.JBTerminalWidget +import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner + +class AwsLocalTerminalRunner( + project: Project, + private val termName: String, + private val applyConnection: (MutableMap) -> Unit +) : LocalTerminalDirectRunner(project) { + override fun getInitialCommand(envs: MutableMap): MutableList = super.getInitialCommand(envs.apply(applyConnection)) + override fun createTerminalWidget(parent: Disposable, currentWorkingDirectory: String?, deferSessionStartUntilUiShown: Boolean): JBTerminalWidget { + val widget = super.createTerminalWidget(parent, currentWorkingDirectory, deferSessionStartUntilUiShown) + return widget.apply { + terminalTitle.change { + tag = termName + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/terminal/OpenAwsLocalTerminal.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/terminal/OpenAwsLocalTerminal.kt new file mode 100644 index 0000000000..6ab896eb58 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/terminal/OpenAwsLocalTerminal.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.core.terminal + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.util.ExceptionUtil +import org.jetbrains.plugins.terminal.TerminalIcons +import org.jetbrains.plugins.terminal.TerminalTabState +import org.jetbrains.plugins.terminal.TerminalView +import software.amazon.awssdk.profiles.ProfileFileSystemSetting +import software.aws.toolkits.core.credentials.mergeWithExistingEnvironmentVariables +import software.aws.toolkits.core.region.mergeWithExistingEnvironmentVariables +import software.aws.toolkits.core.shortName +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ConnectionState +import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileCredentialsIdentifier +import software.aws.toolkits.jetbrains.core.experiments.ToolkitExperiment +import software.aws.toolkits.jetbrains.core.experiments.isEnabled +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry +import software.aws.toolkits.telemetry.Result + +class OpenAwsLocalTerminal : DumbAwareAction( + { message("aws.terminal.action") }, + { message("aws.terminal.action.tooltip") }, + TerminalIcons.OpenTerminal_13x13 +) { + + override fun update(e: AnActionEvent) { + if (AwsLocalTerminalExperiment.isEnabled()) { + e.presentation.isEnabled = e.project?.let { AwsConnectionManager.getInstance(it) }?.isValidConnectionSettings() == true + } else { + e.presentation.isEnabledAndVisible = false + } + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(LangDataKeys.PROJECT) + when (val state = AwsConnectionManager.getInstance(project).connectionState) { + is ConnectionState.ValidConnection -> { + val connection = state.connection + ApplicationManager.getApplication().executeOnPooledThread { + val credentials = try { + connection.credentials.resolveCredentials() + } catch (e: Exception) { + LOG.error(e) { message("aws.terminal.exception.failed_to_resolve_credentials", ExceptionUtil.getThrowableText(e)) } + AwsTelemetry.openLocalTerminal(project, result = Result.Failed) + return@executeOnPooledThread + } + runInEdt { + val runner = AwsLocalTerminalRunner(project, connection.shortName) { envs -> + connection.region.mergeWithExistingEnvironmentVariables(envs, replace = true) + when (val identifier = connection.credentials.identifier) { + is ProfileCredentialsIdentifier -> envs[ProfileFileSystemSetting.AWS_PROFILE.environmentVariable()] = identifier.profileName + else -> credentials.mergeWithExistingEnvironmentVariables(envs, replace = true) + } + } + TerminalView.getInstance(project).createNewSession(runner, TerminalTabState().apply { this.myTabName = connection.shortName }) + AwsTelemetry.openLocalTerminal(project, result = Result.Succeeded) + } + } + } + else -> { + LOG.error { message("aws.terminal.exception.invalid_credentials", state.displayMessage) } + AwsTelemetry.openLocalTerminal(project, result = Result.Failed) + } + } + } + + private companion object { + private val LOG = getLogger() + } +} + +object AwsLocalTerminalExperiment : + ToolkitExperiment("connectedLocalTerminal", { message("aws.terminal.action") }, { message("aws.terminal.action.tooltip") }, default = true) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/DefaultToolManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/DefaultToolManager.kt new file mode 100644 index 0000000000..a5047e9937 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/DefaultToolManager.kt @@ -0,0 +1,288 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.PerformInBackgroundOption +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.progress.util.ProgressIndicatorUtils +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.ThrowableComputable +import com.intellij.openapi.util.io.FileUtil +import com.intellij.serviceContainer.NonInjectable +import com.intellij.util.ExceptionUtil +import com.intellij.util.io.delete +import com.intellij.util.io.readText +import com.intellij.util.io.write +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.tools.ToolManager.Companion.MANAGED_TOOL_INSTALL_ROOT +import software.aws.toolkits.jetbrains.utils.assertIsNonDispatchThread +import software.aws.toolkits.jetbrains.utils.runUnderProgressIfNeeded +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AwsTelemetry +import software.aws.toolkits.telemetry.Result +import java.io.FileNotFoundException +import java.nio.file.Files +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.Paths +import java.time.Clock +import java.time.Instant +import java.time.Period +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.io.path.exists + +class DefaultToolManager @NonInjectable internal constructor(private val clock: Clock) : ToolManager { + constructor() : this(Clock.systemUTC()) + + private val versionCache = ToolVersionCache() + private val updateCheckCache = ConcurrentHashMap, Instant>() + private val managedToolLock = ReentrantLock() + + override fun getTool(type: ToolType): Tool>? { + // Check if user gave a custom path + ToolSettings.getInstance().getExecutablePath(type)?.let { + return Tool(type, Paths.get(it)) + } + + if (type is ManagedToolType) { + val managedTool = checkForInstalledTool(type) + if (managedTool != null) { + ApplicationManager.getApplication().executeOnPooledThread { + checkForUpdates(type) + } + return managedTool + } + } + + return detectTool(type)?.let { + Tool(type, it) + } + } + + override fun getOrInstallTool(type: ManagedToolType, project: Project?): Tool> = getTool(type) ?: runUnderProgressIfNeeded( + project, + message("executableCommon.installing", type.displayName), + cancelable = false + ) { + installTool(project, type, ProgressManager.getInstance().progressIndicator) + } + + /** + * Returns a reference to the requested tool located at the specified path. + * + * Note: The tool may not have been checked for [Validity] yet. Caller must make the check before using the tool. + */ + override fun getToolForPath(type: ToolType, toolExecutablePath: Path): Tool> = + Tool(type, toolExecutablePath) + + /** + * Attempts to detect the requested Tool on the local file system. + * + * @return Either the path to the tool if found, or null if the tool can't be found or not auto-detectable + */ + override fun detectTool(type: ToolType): Path? { + if (type is AutoDetectableToolType<*>) { + return type.resolve() + } + return null + } + + /** + * Checks the [Validity] of the specified tool instance. An optional stricter version can be specified to override the minimum version. + * + * If called on a UI thread, a modal dialog is shown while the validation is in progress to avoid UI lock-ups + */ + override fun validateCompatability( + tool: Tool>?, + stricterMinVersion: T?, + project: Project? + ): Validity { + tool ?: return Validity.NotInstalled + + return runUnderProgressIfNeeded(project, message("executableCommon.validating", tool.type.displayName), false) { + determineCompatability(tool, stricterMinVersion) + } + } + + private fun determineCompatability(tool: Tool>, stricterMinVersion: T?): Validity { + assertIsNonDispatchThread() + + val version = when (val cacheResult = versionCache.getValue(tool)) { + is ToolVersionCache.Result.Failure -> return Validity.ValidationFailed(detailedMessage(cacheResult.reason, tool)) + is ToolVersionCache.Result.Success -> cacheResult.version + } + + val baseVersionCompatability = tool.type.supportedVersions()?.let { + version.isValid(it) + } ?: Validity.Valid(version) + + if (baseVersionCompatability !is Validity.Valid) { + return baseVersionCompatability + } + + stricterMinVersion?.let { + if (stricterMinVersion > version) { + return Validity.VersionTooOld(version, stricterMinVersion) + } + } + + return Validity.Valid(version) + } + + private fun detailedMessage(exception: Exception, tool: Tool>) = when (exception) { + is FileNotFoundException, is NoSuchFileException -> message("general.file_not_found", exception.message ?: tool.path) + else -> ExceptionUtil.getMessage(exception) + } ?: message("general.unknown_error") + + @VisibleForTesting + internal fun checkForUpdates(type: ManagedToolType, project: Project? = null) { + assertIsNonDispatchThread() + + val now = Instant.now(clock) + val lastCheck = updateCheckCache.getOrDefault(type, Instant.MIN) + val needCheck = lastCheck.plus(UPDATE_CHECK_INTERVAL).isBefore(now) + if (!needCheck) { + LOG.debug { "Checked for newer versions of ${type.id} recently, nothing to do" } + return + } + + updateCheckCache[type] = now + + val latestVersion = type.determineLatestVersion() + type.supportedVersions()?.let { + val latestVersionCompatibility = latestVersion.isValid(it) + if (latestVersionCompatibility !is Validity.Valid) { + LOG.warn { "Latest version of ${type.id} (${latestVersion.displayValue()} is not compatible with the toolkit: ${type.supportedVersions()}" } + return + } + } + + val currentInstall = checkForInstalledTool(type) + val currentVersion = currentInstall?.let { type.determineVersion(it.path) } + if (currentVersion != null && currentVersion >= latestVersion) { + LOG.debug { "Current version of ${type.id} is greater than or equal to latest version, nothing to do" } + return + } + + ProgressManager.getInstance().run( + object : Task.Backgroundable( + project, + message("executableCommon.updating", type.displayName), + /* canBeCanceled */ + false, + PerformInBackgroundOption.ALWAYS_BACKGROUND + ) { + override fun run(indicator: ProgressIndicator) { + installTool(project, type, indicator) + } + } + ) + } + + private fun installTool(project: Project?, type: ManagedToolType, indicator: ProgressIndicator?): Tool> { + assertIsNonDispatchThread() + + try { + val latestVersion = type.determineLatestVersion() + + type.supportedVersions()?.let { + val latestVersionCompatibility = latestVersion.isValid(it) + if (latestVersionCompatibility !is Validity.Valid) { + throw IllegalStateException( + message( + "executableCommon.latest_not_compatible", + type.displayName, + it.displayValue() + ) + ) + } + } + + return performInstall(type, latestVersion, indicator).also { + AwsTelemetry.toolInstallation(project, type.telemetryId, Result.Succeeded) + } + } catch (e: Exception) { + AwsTelemetry.toolInstallation(project, type.telemetryId, Result.Failed) + throw IllegalStateException(message("executableCommon.failed_install", type.displayName), e) + } + } + + private fun performInstall(type: ManagedToolType, version: V, indicator: ProgressIndicator?): Tool> { + assertIsNonDispatchThread() + + val downloadDir = Files.createTempDirectory(type.id) + try { + val downloadFile = try { + indicator?.text2 = message("executableCommon.downloading", type.displayName) + type.downloadVersion(version, downloadDir, indicator) + } finally { + indicator?.text2 = "" + } + + val versionString = version.displayValue() + val installLocation = managedToolInstallDir(type.id, versionString) + + return ProgressIndicatorUtils.computeWithLockAndCheckingCanceled( + managedToolLock, + 50, + TimeUnit.MILLISECONDS, + ThrowableComputable { + // Default the indicator to be indeterminate, a tool type can change it back if it can track status better + indicator?.isIndeterminate = true + + if (installLocation.exists()) { + installLocation.delete(recursively = true) + } + type.installVersion(downloadFile, installLocation, indicator) + + // Check install before updating marker + val tool = type.toTool(installLocation) + + managedToolMarkerFile(type.id).write(versionString) + + return@ThrowableComputable tool + } + ) + } finally { + FileUtil.delete(downloadDir) + } + } + + private fun checkForInstalledTool(type: ManagedToolType): Tool>? { + val markerVersion = readMarkerVersion(type) ?: return null + val installLocation = managedToolInstallDir(type.id, markerVersion).takeIf { it.exists() } ?: return null + return type.toTool(installLocation) + } + + private fun readMarkerVersion(type: ManagedToolType<*>): String? { + val markerFile = managedToolMarkerFile(type.id).takeIf { it.exists() } ?: return null + val markerVersion = managedToolLock.withLock { + markerFile.readText() + } + // Avoid dir traversal + if (markerVersion.contains("/") || markerVersion.contains("\\")) { + return null + } + + return markerVersion + } + + companion object { + private val LOG = getLogger() + private val UPDATE_CHECK_INTERVAL = Period.ofDays(1) + private const val VERSION_MARKER_FILENAME = "VERSION" + + internal fun managedToolMarkerFile(toolId: String) = MANAGED_TOOL_INSTALL_ROOT.resolve(toolId).resolve(VERSION_MARKER_FILENAME) + internal fun managedToolInstallDir(toolId: String, version: String) = MANAGED_TOOL_INSTALL_ROOT.resolve(toolId).resolve(version) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/FourPartVersion.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/FourPartVersion.kt new file mode 100644 index 0000000000..a581fbe9ef --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/FourPartVersion.kt @@ -0,0 +1,30 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +data class FourPartVersion(val major: Int, val minor: Int, val patch: Int, val build: Int) : Version { + override fun displayValue(): String = "$major.$minor.$patch.$build" + + override fun compareTo(other: Version): Int = COMPARATOR.compare(this, other as FourPartVersion) + + companion object { + private val COMPARATOR = compareBy { it.major } + .thenBy { it.minor } + .thenBy { it.patch } + .thenBy { it.build } + + fun parse(version: String): FourPartVersion { + val parts = version.split(".") + if (parts.size != 4) { + throw IllegalArgumentException("[$version] not in the format of MAJOR.MINOR.PATCH.BUILD") + } + + try { + return FourPartVersion(parts[0].toInt(), parts[1].toInt(), parts[2].toInt(), parts[3].toInt()) + } catch (e: Exception) { + throw IllegalArgumentException("[$version] could not be parsed", e) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/SemanticVersion.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/SemanticVersion.kt new file mode 100644 index 0000000000..5de5f38a01 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/SemanticVersion.kt @@ -0,0 +1,37 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +data class SemanticVersion(val major: Int, val minor: Int, val patch: Int) : Version { + override fun displayValue(): String = "$major.$minor.$patch" + + override fun compareTo(other: Version): Int = COMPARATOR.compare(this, other as SemanticVersion) + + companion object { + // TODO: Support pre-release + private val COMPARATOR = compareBy { it.major } + .thenBy { it.minor } + .thenBy { it.patch } + + fun parse(version: String): SemanticVersion { + val parts = version.split(".") + if (parts.size != 3) { + throw IllegalArgumentException("[$version] not in the format of MAJOR.MINOR.PATCH") + } + + try { + val preReleaseStart = parts[2].indexOfFirst { it == '+' || it == '-' } + val patchStr = if (preReleaseStart >= 0) { + parts[2].substring(0, preReleaseStart) + } else { + parts[2] + } + + return SemanticVersion(parts[0].toInt(), parts[1].toInt(), patchStr.toInt()) + } catch (e: Exception) { + throw IllegalArgumentException("[$version] could not be parsed", e) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/Tool.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/Tool.kt new file mode 100644 index 0000000000..4a813a47fe --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/Tool.kt @@ -0,0 +1,26 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +import java.nio.file.Path + +data class Tool>(val type: T, val path: Path) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Tool<*> + + if (type.id != other.type.id) return false + if (path != other.path) return false + + return true + } + + override fun hashCode(): Int { + var result = type.id.hashCode() + result = 31 * result + path.hashCode() + return result + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolConfigurable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolConfigurable.kt new file mode 100644 index 0000000000..2299f72c56 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolConfigurable.kt @@ -0,0 +1,60 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.ui.emptyText +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.resources.message +import java.nio.file.Path + +class ToolConfigurable : BoundConfigurable(message("executableCommon.configurable.title")), SearchableConfigurable { + private val settings = ToolSettings.getInstance() + private val manager = ToolManager.getInstance() + private val panel by lazy { + panel { + ToolType.EP_NAME.extensionList.forEach { toolType -> + row(toolType.displayName) { + textFieldWithBrowseButton(fileChooserDescriptor = FileChooserDescriptorFactory.createSingleFileDescriptor()) + .bindText( + { settings.getExecutablePath(toolType) ?: "" }, + { settings.setExecutablePath(toolType, it.takeIf { v -> v.isNotBlank() }) } + ) + .validationOnInput { + it.textField.text.takeIf { t -> t.isNotBlank() }?.let { path -> + manager.validateCompatability(Path.of(path), toolType).toValidationInfo(toolType, component) + } + }.applyToComponent { + setEmptyText(toolType, textField as JBTextField) + }.resizableColumn() + .align(Align.FILL) + + browserLink(message("aws.settings.learn_more"), toolType.documentationUrl()) + } + } + } + } + + override fun createPanel() = panel + + override fun apply() { + panel.apply() + } + + override fun getId(): String = "aws.tools" + + private fun setEmptyText(toolType: ToolType, field: JBTextField) { + val resolved = (toolType as? AutoDetectableToolType<*>)?.resolve() + field.emptyText.text = when { + resolved != null && toolType.getTool()?.path == resolved -> message("executableCommon.auto_resolved", resolved) + toolType is ManagedToolType<*> -> message("executableCommon.auto_managed") + else -> message("common.none") + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolManager.kt new file mode 100644 index 0000000000..eae40810e8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolManager.kt @@ -0,0 +1,77 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.util.io.createDirectories +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Service for managing external tools such as CLIs. + */ +interface ToolManager { + + /** + * Returns a reference to the requested tool. + * + * The returned Tool will first be checked if the user has set a path for it explicitly. If they have not, we will attempt to detect it for them if + * supported. + * Note: The tool may not have been checked for [Validity] yet. Caller must make the check before using the tool. + */ + fun getTool(type: ToolType): Tool>? + + fun getOrInstallTool(type: ManagedToolType, project: Project? = null): Tool> + + /** + * Returns a reference to the requested tool located at the specified path. + * + * Note: The tool may not have been checked for [Validity] yet. Caller must make the check before using the tool. + */ + fun getToolForPath(type: ToolType, toolExecutablePath: Path): Tool> + + /** + * Attempts to detect the requested Tool on the local file system. + * + * @return Either the path to the tool if found, or null if the tool can't be found or not auto-detectable + */ + fun detectTool(type: ToolType): Path? + + /** + * Checks the [Validity] of the specified tool instance. An optional stricter version can be specified to override the minimum version. + * + * If called on a UI thread, a modal dialog is shown while the validation is in progress to avoid UI lock-ups + */ + fun validateCompatability( + tool: Tool>?, + stricterMinVersion: T? = null, + project: Project? = null + ): Validity + + companion object { + fun getInstance(): ToolManager = service() + + internal val MANAGED_TOOL_INSTALL_ROOT by lazy { + Paths.get(PathManager.getSystemPath(), "aws-static-resources").resolve("tools").createDirectories() + } + } +} + +/** + * Checks the [Validity] of the specified tool at the specified path. An optional stricter version can be specified to override the minimum version. + * + * If called on a UI thread, a modal dialog is shown while the validation is in progress to avoid UI lock-ups + */ +fun ToolManager.validateCompatability( + path: Path, + type: ToolType, + stricterMinVersion: T? = null, + project: Project? = null +): Validity = validateCompatability(getToolForPath(type, path), stricterMinVersion, project) + +fun ToolType.getTool(): Tool>? = ToolManager.getInstance().getTool(this) + +fun ManagedToolType.getOrInstallTool(project: Project? = null): Tool> = ToolManager.getInstance().getOrInstallTool(this, project) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolSettings.kt new file mode 100644 index 0000000000..593eae872c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolSettings.kt @@ -0,0 +1,43 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.SimplePersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.util.xmlb.annotations.Attribute +import com.intellij.util.xmlb.annotations.Property +import com.intellij.util.xmlb.annotations.Tag + +@State(name = "tools", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) +class ToolSettings : SimplePersistentStateComponent(ExecutableOptions()) { + fun getExecutablePath(executable: ToolType<*>) = state.value[executable.id]?.executablePath + + fun setExecutablePath(executable: ToolType<*>, value: String?) { + if (value == null) { + state.value.remove(executable.id) + } else { + val original = state.value[executable.id] ?: ExecutableState2() + state.value[executable.id] = original.copy(executablePath = value) + } + } + + companion object { + fun getInstance(): ToolSettings = service() + } +} + +class ExecutableOptions : BaseState() { + @get:Property + val value by map() +} + +@Tag("ExecutableState") +data class ExecutableState2( + @Attribute(value = "path") + val executablePath: String? = null, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolType.kt new file mode 100644 index 0000000000..852c0588ea --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolType.kt @@ -0,0 +1,109 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.progress.ProgressIndicator +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.ssm.SsmPlugin +import software.aws.toolkits.jetbrains.utils.checkSuccess +import software.aws.toolkits.telemetry.ToolId +import java.nio.file.Path + +/** + * Represents an executable external tool such as a CLI + * + * Note: It is recommended that all implementations of this interface are stateless and are an `object` + */ +interface ToolType { + /** + * ID used to represent the executable in caches and settings. Must be globally unique + */ + val id: String + + /** + * Name of the executable for users, e.g. the marketing name of the executable + */ + val displayName: String + + /** + * List of supported [VersionRange]. An empty list means any version is supported + */ + fun supportedVersions(): VersionRange? + + /** + * Returns the [Version] for the executable of this type located at the specified location + */ + fun determineVersion(path: Path): VersionScheme + + companion object { + internal val EP_NAME = ExtensionPointName.create>("aws.toolkit.tool") + } +} + +/** + * Used to power the 'Learn more' link in the configurable + */ +interface DocumentedToolType : ToolType { + fun documentationUrl(): String +} + +/** + * Indicates that a [ToolType] can be auto-detected for the user on their system + */ +interface AutoDetectableToolType : ToolType { + /** + * Attempt to automatically detect the tool's binary file + * + * @return the resolved path or null if not found + * @throws Exception if an exception occurred attempting to resolve the path + */ + fun resolve(): Path? +} + +interface ManagedToolType : ToolType { + override val id: String + get() = telemetryId.toString() + + /** + * Used to identify this tool with the telemetry stack + */ + val telemetryId: ToolId + fun determineLatestVersion(): VersionScheme + fun downloadVersion(version: VersionScheme, destinationDir: Path, indicator: ProgressIndicator?): Path + fun installVersion(downloadArtifact: Path, destinationDir: Path, indicator: ProgressIndicator?) + fun toTool(installDir: Path): Tool> +} + +abstract class BaseToolType : ToolType { + final override fun determineVersion(path: Path): VersionScheme { + val processOutput = ExecUtil.execAndGetOutput(GeneralCommandLine(path.toString()).apply(::versionCommand), VERSION_TIMEOUT_MS) + + if (!processOutput.checkSuccess(LOGGER)) { + throw IllegalStateException("Failed to determine version of ${SsmPlugin.displayName}") + } + return parseVersion(processOutput.stdout.trim()) + } + + /** + * Used as a hook to call executable with parameters to determine version. + * + * [baseCmd] is a [GeneralCommandLine] with the [Tool.path] as the base + */ + open fun versionCommand(baseCmd: GeneralCommandLine) { + baseCmd.withParameters("--version") + } + + /** + * Parse the version from standard out + */ + abstract fun parseVersion(output: String): VersionScheme + + companion object { + private const val VERSION_TIMEOUT_MS = 5000 + private val LOGGER = getLogger>() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolVersionCache.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolVersionCache.kt new file mode 100644 index 0000000000..1debc4ed4f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/ToolVersionCache.kt @@ -0,0 +1,62 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +import com.intellij.openapi.progress.util.ProgressIndicatorUtils +import com.intellij.openapi.util.ThrowableComputable +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.lastModified +import software.aws.toolkits.core.utils.warn +import java.nio.file.Files +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock + +/** + * Stores data related to a file path. Cache is invalidated when the cache entry is detected as stale. Errors are + * cached until the underlying path is detected as stale. Stale is defined as the cache entries (file modification time)[Files.getLastModifiedTime] + * is older than the path's current modification time. + */ +class ToolVersionCache { + private val cache = ConcurrentHashMap, Result<*>>() + private val lock = ReentrantLock() + + @Suppress("UNCHECKED_CAST") + fun getValue(tool: Tool>): Result = ProgressIndicatorUtils.computeWithLockAndCheckingCanceled( + lock, + 50, + TimeUnit.MILLISECONDS, + ThrowableComputable { + val lastResult = cache[tool] + var lastModifiedTime = 0L + try { + lastModifiedTime = tool.path.lastModified().toMillis() + + if (lastResult == null || lastResult.lastModifiedTime < lastModifiedTime) { + Result.Success(getVersion(tool), lastModifiedTime).also { + cache[tool] = it + } as Result + } else { + lastResult as Result + } + } catch (e: Exception) { + LOG.warn(e) { "Unable to get tool version for $tool" } + Result.Failure(e, lastModifiedTime).also { + cache[tool] = it + } as Result + } + } + ) + + private fun getVersion(tool: Tool>): Version = tool.type.determineVersion(tool.path) + + sealed class Result(open val lastModifiedTime: Long) { + data class Failure(val reason: Exception, override val lastModifiedTime: Long) : Result(lastModifiedTime) + data class Success(val version: V, override val lastModifiedTime: Long) : Result(lastModifiedTime) + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/Validity.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/Validity.kt new file mode 100644 index 0000000000..a215e9301a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/Validity.kt @@ -0,0 +1,63 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +import com.intellij.openapi.ui.ValidationInfo +import software.aws.toolkits.core.utils.htmlWrap +import software.aws.toolkits.resources.message +import javax.swing.JComponent + +/** + * Represents if the [Tool] is compatible with the Toolkit + */ +sealed class Validity { + + /** + * The Tool is installed and compatible + */ + data class Valid(val version: Version) : Validity() + + /** + * The tool is not valid for some reason + */ + sealed class Invalid : Validity() + + /** + * Represents that the tool was not found + */ + object NotInstalled : Invalid() + + /** + * The Tool is not installed on the system, or it is unsuitable to be used due to some issue such as corruption or permissions. + */ + data class ValidationFailed(val detailedMessage: String) : Invalid() + + /** + * The Tool is installed, but its version is too low to be compatible with the Toolkit / feature + */ + data class VersionTooOld(val actualVersion: Version, val minVersion: Version) : Invalid() + + /** + * The Tool is installed, but its version is too new to be compatible with the Toolkit / feature + */ + data class VersionTooNew(val actualVersion: Version, val maxVersion: Version) : Invalid() +} + +/** + * @return Convert [Validity] to a human-readable error message if one is applicable + */ +fun Validity.Invalid.toErrorMessage(executableType: ToolType<*>): String = when (this) { + is Validity.ValidationFailed -> message("executableCommon.missing_executable", executableType.displayName, this.detailedMessage) + is Validity.NotInstalled -> message("executableCommon.not_installed") + is Validity.VersionTooNew -> message("executableCommon.version_too_high") + is Validity.VersionTooOld -> message("executableCommon.version_too_low2", executableType.displayName, this.minVersion) +} + +/** + * @return Convert [Validity] to a human-readable error message for UI validation if one is applicable + */ +fun Validity.toValidationInfo(executableType: ToolType<*>, component: JComponent? = null) = when (this) { + is Validity.Valid -> null + is Validity.Invalid -> ValidationInfo(toErrorMessage(executableType).htmlWrap(), component) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/Versions.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/Versions.kt new file mode 100644 index 0000000000..a2fad540dc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/tools/Versions.kt @@ -0,0 +1,36 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.tools + +/** + * Top level interface for different versioning schemes such as semantic version + */ +interface Version : Comparable { + /** + * @return Human-readable representation of the version + */ + fun displayValue(): String +} + +/** + * @return true if the specified version is compatible with the specified version ranges. Always returns true if no range is specified. + */ +fun T.isValid(range: VersionRange?): Validity = when { + range == null -> Validity.Valid(this) + this < range.minVersion -> Validity.VersionTooOld(this, range.minVersion) + range.maxVersion <= this -> Validity.VersionTooNew(this, range.maxVersion) + else -> Validity.Valid(this) +} + +/** + * Represents a range of versions. + * + * @property minVersion The minimum version supported, inclusive. + * @property maxVersion The maximum version supported, exclusive. + */ +data class VersionRange(val minVersion: T, val maxVersion: T) { + fun displayValue() = "${minVersion.displayValue()} ≤ X < ${maxVersion.displayValue()}" +} + +infix fun T.until(that: T): VersionRange = VersionRange(this, that) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/toolwindow/ToolkitToolWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/toolwindow/ToolkitToolWindow.kt index d9d7c68059..01bce86349 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/toolwindow/ToolkitToolWindow.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/toolwindow/ToolkitToolWindow.kt @@ -4,109 +4,97 @@ package software.aws.toolkits.jetbrains.core.toolwindow import com.intellij.openapi.Disposable -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.application.runInEdt import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer -import com.intellij.openapi.wm.ToolWindow -import com.intellij.openapi.wm.ToolWindowAnchor +import com.intellij.openapi.util.Key import com.intellij.openapi.wm.ToolWindowManager import com.intellij.ui.content.Content -import com.intellij.ui.content.impl.ContentImpl -import icons.AwsIcons -import java.util.concurrent.ConcurrentHashMap -import javax.swing.Icon import javax.swing.JComponent interface ToolkitToolWindow { - fun addTab(title: String, component: JComponent, activate: Boolean = false, id: String = title, disposable: Disposable? = null): ToolkitToolWindowTab - fun find(id: String): ToolkitToolWindowTab? - // prefix is prefix of the id. Assumes the window is using id composed of paths, like: "loggroup/logstream" - fun findPrefix(prefix: String): List -} + val project: Project + val toolWindowId: String -interface ToolkitToolWindowTab : Disposable { - fun show() -} + fun toolWindow() = ToolWindowManager.getInstance(project).getToolWindow(toolWindowId) + ?: throw IllegalStateException("Can't find tool window $toolWindowId") + + /** + * Adds a new tab to the tool window + * + * @param title Title of the tab + * @param component The JComponent of the tab's content. If [Disposable] will be auto disposed on close + * @param activate Show the tab upon adding it + * @param id Unique ID to identify the tab + * @param additionalDisposable An additional [Disposable] to dispose when the tab is closed + */ + fun addTab( + title: String, + component: JComponent, + activate: Boolean = false, + id: String = title, + additionalDisposable: Disposable? = null + ): Content { + val toolWindow = toolWindow() + val contentManager = toolWindow.contentManager + val content = contentManager.factory.createContent(component, title, false).also { + it.isCloseable = true + it.isPinnable = true + it.putUserData(AWS_TOOLKIT_TAB_ID_KEY, id) -class ToolkitToolWindowManager(private val project: Project) { - private val toolWindows = ConcurrentHashMap() - internal fun getInstance(type: ToolkitToolWindowType) = toolWindows.computeIfAbsent(type) { ManagedToolkitToolWindow(type) } - - inner class ManagedToolkitToolWindow(private val type: ToolkitToolWindowType) : ToolkitToolWindow { - private val tabs = mutableMapOf() - - override fun addTab(title: String, component: JComponent, activate: Boolean, id: String, disposable: Disposable?): ToolkitToolWindowTab { - val content = ContentImpl(component, title, true) - val toolWindow = windowManager.getToolWindow(type.id) - ?: windowManager.registerToolWindow(type.id, true, type.anchor, project, true).also { - it.setIcon(type.icon) - it.stripeTitle = type.title - } - Disposer.register(content, Disposable { closeWindowIfEmpty(toolWindow, type.id) }) - disposable?.let { Disposer.register(content, it) } - toolWindow.contentManager.addContent(content) - return ManagedToolkitToolWindowTab(toolWindow, content).also { - tabs[id] = it - if (activate) { - it.show() - } + if (additionalDisposable != null) { + it.setDisposer(additionalDisposable) } } - override fun find(id: String): ToolkitToolWindowTab? { - val tab = tabs[id] ?: return null - if (Disposer.isDisposed(tab.content)) { - tabs.remove(id) - return null - } - return tab + contentManager.addContent(content) + if (activate) { + show(content) } - override fun findPrefix(prefix: String): List = tabs - .filter { it.key.startsWith("$prefix/") || it.key == prefix } - .mapNotNull { - if (Disposer.isDisposed(it.value.content)) { - tabs.remove(it.key) - null - } else { - it.value - } - } + return content } - inner class ManagedToolkitToolWindowTab(private val toolWindow: ToolWindow, internal val content: Content) : ToolkitToolWindowTab { - override fun show() { - toolWindow.activate(null, true) - toolWindow.contentManager.setSelectedContent(content) - } + fun removeContent(content: Content) = runInEdt { + val toolWindow = toolWindow() + toolWindow.contentManager.removeContent(content, true) + } - override fun dispose() { - if (!Disposer.isDisposed(content)) { - toolWindow.contentManager.removeContent(content, true) + fun show(content: Content) { + val toolWindow = toolWindow() + toolWindow.activate(null, true) + toolWindow.contentManager.setSelectedContent(content) + } + + fun showExistingContent(id: String): Boolean { + val toolWindow = toolWindow() + + val content = find(id) + if (content != null) { + runInEdt { + toolWindow.activate(null, true) + toolWindow.contentManager.setSelectedContent(content) } + + return true } + + return false } - private val windowManager - get() = ToolWindowManager.getInstance(project) + fun find(id: String): Content? = + toolWindow().contentManager.contents.find { id == it.getUserData(AWS_TOOLKIT_TAB_ID_KEY) } + + // prefix is prefix of the id. Assumes the window is using id composed of paths, like: "loggroup/logstream" + fun findPrefix(prefix: String): List { + val toolWindow = toolWindow() - private fun closeWindowIfEmpty(window: ToolWindow, id: String) { - if (window.contentManager.contentCount == 0) { - windowManager.unregisterToolWindow(id) + return toolWindow.contentManager.contents.filter { + val key = it.getUserData(AWS_TOOLKIT_TAB_ID_KEY) ?: "" + key.startsWith("$prefix/") || key == prefix } } companion object { - fun getInstance(project: Project, toolWindowType: ToolkitToolWindowType): ToolkitToolWindow = ServiceManager.getService( - project, - ToolkitToolWindowManager::class.java - ).getInstance(toolWindowType) + private val AWS_TOOLKIT_TAB_ID_KEY = Key.create("awsToolkitTabId") } } - -data class ToolkitToolWindowType( - val id: String, - val title: String, - val icon: Icon = AwsIcons.Logos.AWS, - val anchor: ToolWindowAnchor = ToolWindowAnchor.BOTTOM -) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/utils/CollectionUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/utils/CollectionUtils.kt new file mode 100644 index 0000000000..40aa7902ea --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/utils/CollectionUtils.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.core.utils + +/* + * Replace with [kotlin.collections.buildList] when experimental is removed + */ +inline fun buildList(builderAction: MutableList.() -> Unit): List = ArrayList().apply(builderAction) +inline fun buildList(capacity: Int, builderAction: MutableList.() -> Unit): List = ArrayList(capacity).apply(builderAction) + +/* + * Replace with [kotlin.collections.buildMap] when experimental is removed + */ +inline fun buildMap(builderAction: MutableMap.() -> Unit): Map = mutableMapOf().apply(builderAction) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/core/utils/DataContextUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/utils/DataContextUtils.kt new file mode 100644 index 0000000000..3cba6b8150 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/core/utils/DataContextUtils.kt @@ -0,0 +1,12 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.utils + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DataKey + +/** + * Returns the specified DataKey from the DataContext. If not found, it will throw an exception + */ +fun DataContext.getRequiredData(dataId: DataKey): T = this.getData(dataId) ?: throw IllegalStateException("Required dataId '${dataId.name}` was missing") diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/PathMapper.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/PathMapper.kt index 40ecccac9f..18361e5a99 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/PathMapper.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/PathMapper.kt @@ -5,10 +5,10 @@ package software.aws.toolkits.jetbrains.services import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.io.FileUtil +import com.intellij.util.io.isFile import com.intellij.xdebugger.XSourcePosition import com.jetbrains.python.debugger.PyLocalPositionConverter import com.jetbrains.python.debugger.PySourcePosition -import com.intellij.util.io.isFile import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.services.PathMapper.Companion.normalizeLocal diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/RoleValidation.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/RoleValidation.kt deleted file mode 100644 index 6869499370..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/RoleValidation.kt +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper - -object RoleValidation { - fun isRolePolicyValidForCloudDebug(rolePolicy: String): Boolean { - val jsonPolicy = jacksonObjectMapper().readTree(rolePolicy) - val validPolicyStatement = jsonPolicy["Statement"]?.firstOrNull { - it["Effect"]?.textValue() == "Allow" && - it["Action"]?.textValue() == "sts:AssumeRole" && - serviceContainsEcsTasks(it["Principal"]?.get("Service")) - } - - return validPolicyStatement != null - } - - private fun serviceContainsEcsTasks(node: JsonNode?): Boolean { - if (node == null) { - return false - } - - if (node.isArray) { - return node.any { serviceContainsEcsTasks(it) } - } else { - return node.textValue() == "ecs-tasks.amazonaws.com" - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/CwQChatAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/CwQChatAction.kt new file mode 100644 index 0000000000..8ccb3ce743 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/CwQChatAction.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.wm.ToolWindowManager +import icons.AwsIcons +import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory +import software.aws.toolkits.telemetry.UiTelemetry + +class CwQChatAction : AnAction(AwsIcons.Logos.AWS_Q) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(CommonDataKeys.PROJECT) + UiTelemetry.click(project, "q_openChat") + ToolWindowManager.getInstance(project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID)?.activate(null, true) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/QChatUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/QChatUtils.kt new file mode 100644 index 0000000000..d5d30eeb2f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/QChatUtils.kt @@ -0,0 +1,10 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq + +import com.intellij.openapi.application.ApplicationInfo + +fun isQSupportedInThisVersion(): Boolean = ApplicationInfo.getInstance().build.asStringWithoutProductCode() !in unSupportedIdeVersionInQ + +val unSupportedIdeVersionInQ = listOf("232.8660.185") diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AmazonQApp.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AmazonQApp.kt new file mode 100644 index 0000000000..814dcca121 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AmazonQApp.kt @@ -0,0 +1,29 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.apps + +import com.intellij.openapi.Disposable + +/** + * Base interface for the entry point for "apps" that are built using AmazonQ. + * + * Apps should implement this interface, and then register the implementing class in plugin.xml as an extension: + * + * + * + * + */ +interface AmazonQApp : Disposable { + + /** + * The types of tabs supported by this app. Messages will only be received by the app if they have a tabType that is contained in this list. + */ + val tabTypes: List + + /** + * This initializer function is called when the tool window is being setup. The app is passed an instance of [AmazonQAppInitContext], which contains the + * connections needed to communicate with the Amazon Q UI. + */ + fun init(context: AmazonQAppInitContext) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AmazonQAppFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AmazonQAppFactory.kt new file mode 100644 index 0000000000..1bf8143a88 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AmazonQAppFactory.kt @@ -0,0 +1,10 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.apps + +import com.intellij.openapi.project.Project + +interface AmazonQAppFactory { + fun createApp(project: Project): AmazonQApp +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AmazonQAppInitContext.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AmazonQAppInitContext.kt new file mode 100644 index 0000000000..ec9405cab1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AmazonQAppInitContext.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.apps + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.amazonq.commands.MessageTypeRegistry +import software.aws.toolkits.jetbrains.services.amazonq.messages.MessageListener +import software.aws.toolkits.jetbrains.services.amazonq.messages.MessagePublisher +import software.aws.toolkits.jetbrains.services.amazonq.webview.FqnWebviewAdapter + +/** + * Context object that is passed to each [AmazonQApp] during initialization. Contains the connections needed to communicate with the Amazon Q UI. + */ +data class AmazonQAppInitContext( + val project: Project, + val messagesFromAppToUi: MessagePublisher, + val messagesFromUiToApp: MessageListener, + val messageTypeRegistry: MessageTypeRegistry, + val fqnWebviewAdapter: FqnWebviewAdapter, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AppConnection.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AppConnection.kt new file mode 100644 index 0000000000..f3f2ea4ad9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/apps/AppConnection.kt @@ -0,0 +1,14 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.apps + +import software.aws.toolkits.jetbrains.services.amazonq.commands.MessageTypeRegistry +import software.aws.toolkits.jetbrains.services.amazonq.messages.MessageConnector + +data class AppConnection( + val app: AmazonQApp, + val messagesFromAppToUi: MessageConnector, + val messagesFromUiToApp: MessageConnector, + val messageTypeRegistry: MessageTypeRegistry, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/commands/MessageSerializer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/commands/MessageSerializer.kt new file mode 100644 index 0000000000..726ba1fb34 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/commands/MessageSerializer.kt @@ -0,0 +1,45 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.commands + +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.PropertyAccessor +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.MapperFeature +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonq.messages.UnknownMessageType +import software.aws.toolkits.jetbrains.services.amazonq.util.command + +class MessageSerializer @VisibleForTesting constructor() { + + private val objectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + fun toNode(json: String) = objectMapper.readTree(json) + + fun deserialize(node: JsonNode, registeredTypes: MessageTypeRegistry): AmazonQMessage { + val type = registeredTypes.get(node.command) ?: return UnknownMessageType(node.asText()) + return objectMapper.treeToValue(node, type.java) + } + + fun serialize(value: Any): String = objectMapper.writeValueAsString(value) + + // Provide singleton global access + companion object { + private val instance = MessageSerializer() + + fun getInstance() = instance + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/commands/MessageTypeRegistry.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/commands/MessageTypeRegistry.kt new file mode 100644 index 0000000000..9547312a24 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/commands/MessageTypeRegistry.kt @@ -0,0 +1,24 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.commands + +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import kotlin.reflect.KClass + +private typealias MessageClass = KClass + +/** + * This class allows an App to register the target class to use for deserialization of a particular command from the TypeScript code. + * Messages from TypeScript arrive as a JSON object that always has a "command" field. Apps can register the specific class an object with a particular command + * should deserialize as before they are sent out to the app's MessageListener. + */ +class MessageTypeRegistry { + private val registry = mutableMapOf() + + fun register(command: String, type: MessageClass) = registry.put(command, type) + fun register(vararg entries: Pair) = registry.putAll(entries) + + fun remove(command: String) = registry.remove(command) + fun get(command: String) = registry[command] +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/explorerActions/QLearnMoreAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/explorerActions/QLearnMoreAction.kt new file mode 100644 index 0000000000..b2d75d97ee --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/explorerActions/QLearnMoreAction.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.explorerActions + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.UiTelemetry + +class QLearnMoreAction : AnAction(message("q.learn.more"), "", AllIcons.Actions.Help) { + override fun actionPerformed(e: AnActionEvent) { + UiTelemetry.click(e.project, "q_learnMore") + BrowserUtil.browse("https://aws.amazon.com/q") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/explorerActions/ReauthenticateWithQ.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/explorerActions/ReauthenticateWithQ.kt new file mode 100644 index 0000000000..a2c8b0dbda --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/explorerActions/ReauthenticateWithQ.kt @@ -0,0 +1,16 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.explorerActions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.core.gettingstarted.reauthenticateWithQ +import software.aws.toolkits.resources.message + +class ReauthenticateWithQ : AnAction(message("q.reauthenticate")) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + reauthenticateWithQ(project) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/explorerActions/SignInToQAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/explorerActions/SignInToQAction.kt new file mode 100644 index 0000000000..b988ff6900 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/explorerActions/SignInToQAction.kt @@ -0,0 +1,58 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.explorerActions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.reauthConnectionIfNeeded +import software.aws.toolkits.jetbrains.core.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsForQ +import software.aws.toolkits.jetbrains.services.amazonq.gettingstarted.QGettingStartedVirtualFile +import software.aws.toolkits.jetbrains.settings.MeetQSettings +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.UiTelemetry + +class SignInToQAction : SignInToQActionBase(message("q.sign.in")) + +class EnableQAction : SignInToQActionBase(message("q.enable.text")) + +abstract class SignInToQActionBase(actionName: String) : DumbAwareAction(actionName, null, AllIcons.CodeWithMe.CwmAccess) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + UiTelemetry.click(project, "auth_start_Q") + val connectionManager = ToolkitConnectionManager.getInstance(project) + connectionManager.activeConnectionForFeature(QConnection.getInstance())?.let { + project.refreshCwQTree() + reauthConnectionIfNeeded(project, it) + } ?: run { + runInEdt { + if (requestCredentialsForQ(project)) { + project.refreshCwQTree() + val meetQSettings = MeetQSettings.getInstance() + if (!meetQSettings.shouldDisplayPage) { + return@runInEdt + } else { + FileEditorManager.getInstance( + project + ).openTextEditor( + OpenFileDescriptor( + project, + QGettingStartedVirtualFile() + ), + true + ) + meetQSettings.shouldDisplayPage = false + UiTelemetry.click(project, "toolkit_openedWelcomeToAmazonQPage") + } + } + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedContent.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedContent.kt new file mode 100644 index 0000000000..eb42f2b770 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedContent.kt @@ -0,0 +1,267 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.gettingstarted + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.ui.JBColor +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import com.intellij.ui.jcef.JCEFHtmlPanel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.handler.CefLoadHandlerAdapter +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindow +import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.EditorThemeAdapter +import software.aws.toolkits.jetbrains.services.codewhisperer.learn.LearnCodeWhispererEditorProvider +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.UiTelemetry +import java.util.Base64 +import java.util.function.Function + +class QGettingStartedContent(val project: Project) : Disposable { + val jcefBrowser: JBCefBrowserBase = JCEFHtmlPanel("about:blank") + val receiveMessageQuery = JBCefJSQuery.create(jcefBrowser) + + init { + jcefBrowser.jbCefClient.addLoadHandler( + object : CefLoadHandlerAdapter() { + override fun onLoadEnd(browser: CefBrowser, frame: CefFrame, httpStatusCode: Int) { + // only needs to be done once + jcefBrowser.jbCefClient.removeLoadHandler(this, browser) + + disposableCoroutineScope(this@QGettingStartedContent).launch { + EditorThemeAdapter().onThemeChange() + .distinctUntilChanged() + .onEach { + val js = if (it.darkMode) { + "document.body.classList.add('$darkThemeClass');document.body.classList.remove('$lightThemeClass');" + } else { + "document.body.classList.add('$lightThemeClass');document.body.classList.remove('$darkThemeClass');" + } + browser.executeJavaScript(js, browser.url, 0) + } + .launchIn(this) + } + } + }, + jcefBrowser.cefBrowser + ) + loadWebView() + val handler = Function { + val command = jacksonObjectMapper().readTree(it).get("command").asText() + when (command) { + "goToHelp" -> { + UiTelemetry.click(project, "amazonq_tryExamples") + LearnCodeWhispererEditorProvider.openEditor(project) + } + "sendToQ" -> { + UiTelemetry.click(project, "amazonq_meet_askq") + AmazonQToolWindow.getStarted(project) + } + } + null + } + receiveMessageQuery.addHandler(handler) + } + + fun component() = jcefBrowser.component + + private fun loadWebView() { + // load the web app + jcefBrowser.loadHTML(getWebviewHTML()) + } + + private fun getWebviewHTML(): String { + val colorMode = if (JBColor.isBright()) lightThemeClass else darkThemeClass + val bgLogoDark = getBase64EncodedImageString("/icons/logos/Amazon-Q-Icon_White_Medium.svg") + val qLogo = getBase64EncodedImageString("/icons/logos/Amazon-Q-Icon_Gradient_Medium.svg") + val bgLogoLight = getBase64EncodedImageString("/icons/logos/Amazon-Q-Icon_Squid-Ink_Medium.svg") + val cwLogoDark = getBase64EncodedImageString("/icons/logos/CW_InlineSuggestions_dark.svg") + val cwLogoLight = getBase64EncodedImageString("/icons/logos/CW_InlineSuggestions_light.svg") + val postMessageToJavaJsCode = receiveMessageQuery.inject("JSON.stringify(message)") + + return """ + + + + + + + + + + +

+
+ +

${message("q.onboarding.description")}

+
+ +
+ +
+
+ +
+
+ +
+
+

${message("q.onboarding.codewhisperer.description")}
Try examples

+
+
+
+
+ + + + """.trimIndent() + } + + private fun getBase64EncodedImageString(imageLocation: String) = QGettingStartedContent::class.java.getResourceAsStream(imageLocation).use { + Base64.getEncoder().encodeToString(it?.readAllBytes() ?: return@use null) + } + + private fun getImageSourceFromEncodedString(imageName: String?) = "data:image/svg+xml;base64,$imageName" + + override fun dispose() { + } + + companion object { + private const val darkThemeClass = "dark" + private const val lightThemeClass = "light" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedEditor.kt new file mode 100644 index 0000000000..4f7e429100 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedEditor.kt @@ -0,0 +1,48 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.gettingstarted + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.components.JBScrollPane +import java.beans.PropertyChangeListener +import javax.swing.JComponent + +class QGettingStartedEditor( + private val project: Project, + private val file: VirtualFile, +) : + UserDataHolderBase(), FileEditor { + override fun dispose() { + } + + override fun getComponent(): JComponent { + val panel = QGettingStartedPanel(project) + Disposer.register(this, panel) + + return JBScrollPane(panel.component) + } + + override fun getFile(): VirtualFile = file + + override fun getName(): String = file.name + + override fun getPreferredFocusedComponent(): JComponent? = null + + override fun setState(state: FileEditorState) {} + + override fun isModified(): Boolean = false + + override fun isValid(): Boolean = true + + override fun addPropertyChangeListener(listener: PropertyChangeListener) { + } + + override fun removePropertyChangeListener(listener: PropertyChangeListener) { + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedEditorProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedEditorProvider.kt new file mode 100644 index 0000000000..dde52f78fe --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedEditorProvider.kt @@ -0,0 +1,28 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.gettingstarted + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +class QGettingStartedEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile) = file is QGettingStartedVirtualFile + + override fun createEditor(project: Project, file: VirtualFile): FileEditor { + file as QGettingStartedVirtualFile + return QGettingStartedEditor(project, file) + } + + override fun getEditorTypeId() = EDITOR_TYPE + + override fun getPolicy() = FileEditorPolicy.HIDE_DEFAULT_EDITOR + + companion object { + const val EDITOR_TYPE = "QGettingStartedUxMainPanel" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedPanel.kt new file mode 100644 index 0000000000..94dac0b7ad --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedPanel.kt @@ -0,0 +1,48 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.gettingstarted + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.gridLayout.HorizontalAlign +import com.intellij.ui.dsl.gridLayout.VerticalAlign +import com.intellij.ui.jcef.JBCefApp + +class QGettingStartedPanel( + val project: Project +) : Disposable { + private val webviewContainer = Wrapper() + var browser: QGettingStartedContent? = null + private set + + val component = panel { + row { + cell(webviewContainer) + .horizontalAlign(HorizontalAlign.FILL) + .verticalAlign(VerticalAlign.FILL) + }.resizableRow() + } + + init { + if (!JBCefApp.isSupported()) { + // Fallback to an alternative browser-less solution + webviewContainer.add(JBTextArea("JCEF not supported")) + browser = null + } else { + browser = QGettingStartedContent(project).also { + webviewContainer.add(it.component()) + } + } + } + + override fun dispose() { + browser?.let { + Disposer.dispose(it) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedVirtualFile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedVirtualFile.kt new file mode 100644 index 0000000000..c49fcca2a1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/gettingstarted/QGettingStartedVirtualFile.kt @@ -0,0 +1,19 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.gettingstarted + +import com.intellij.testFramework.LightVirtualFile +import software.aws.toolkits.resources.message + +class QGettingStartedVirtualFile : LightVirtualFile( + message("q.onboarding.title") +) { + override fun toString() = "QGettingStartedVirtualFile[${getName()}]" + override fun getPath() = getName() + override fun isWritable() = false + override fun isDirectory() = false + + override fun hashCode() = toString().hashCode() + override fun equals(other: Any?) = other is QGettingStartedVirtualFile && name == other.name +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/messages/AmazonQMessages.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/messages/AmazonQMessages.kt new file mode 100644 index 0000000000..70c0e1641f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/messages/AmazonQMessages.kt @@ -0,0 +1,12 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.messages + +interface AmazonQMessage + +/** + * Message that is sent when a command is received that does not have a registered deserialization class. The content is the plain-text representation of the + * received JSON. + */ +data class UnknownMessageType(val content: String) : AmazonQMessage diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/messages/MessagePublisher.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/messages/MessagePublisher.kt new file mode 100644 index 0000000000..62b350c56f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/messages/MessagePublisher.kt @@ -0,0 +1,35 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.messages + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * MessagePublisher is used for sending outbound messages + */ +interface MessagePublisher { + suspend fun publish(message: AmazonQMessage) +} + +/** + * MessageListener is used to receive inbound messages + */ +interface MessageListener { + val flow: Flow +} + +/** + * A MessageConnector is a uni-directional channel for passing messages between Amazon Q and App implementations. It is provided as either a MessagePublisher or + * MessageListener depending on the intended direction of communication. + */ +class MessageConnector : MessagePublisher, MessageListener { + private val _messages = MutableSharedFlow(extraBufferCapacity = 10, replay = 10) + override val flow = _messages.asSharedFlow() + + override suspend fun publish(message: AmazonQMessage) { + _messages.emit(message) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/onboarding/OnboardingPageInteraction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/onboarding/OnboardingPageInteraction.kt new file mode 100644 index 0000000000..07983566c1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/onboarding/OnboardingPageInteraction.kt @@ -0,0 +1,17 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.onboarding + +import com.fasterxml.jackson.annotation.JsonValue +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage + +enum class OnboardingPageInteractionType( + @field:JsonValue val json: String +) { + CwcButtonClick("onboarding-page-cwc-button-clicked"), +} + +data class OnboardingPageInteraction( + val type: OnboardingPageInteractionType, +) : AmazonQMessage diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQPanel.kt new file mode 100644 index 0000000000..55b695b483 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQPanel.kt @@ -0,0 +1,64 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.toolwindow + +import com.intellij.openapi.Disposable +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.gridLayout.HorizontalAlign +import com.intellij.ui.dsl.gridLayout.VerticalAlign +import com.intellij.ui.jcef.JBCefApp +import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.services.amazonq.webview.Browser +import java.awt.event.ActionListener +import javax.swing.JButton + +class AmazonQPanel( + parent: Disposable, +) { + private val webviewContainer = Wrapper() + var browser: Browser? = null + private set + + val component = panel { + row { + cell(webviewContainer) + .horizontalAlign(HorizontalAlign.FILL) + .verticalAlign(VerticalAlign.FILL) + }.resizableRow() + + // Button to show the web debugger for debugging the UI: + if (AwsToolkit.isDeveloperMode()) { + row { + cell( + JButton("Show Web Debugger").apply { + addActionListener( + ActionListener { + // Code to be executed when the button is clicked + // Add your logic here + + browser?.jcefBrowser?.openDevtools() + }, + ) + }, + ) + .horizontalAlign(HorizontalAlign.CENTER) + .verticalAlign(VerticalAlign.BOTTOM) + } + } + } + + init { + if (!JBCefApp.isSupported()) { + // Fallback to an alternative browser-less solution + webviewContainer.add(JBTextArea("JCEF not supported")) + browser = null + } else { + browser = Browser(parent).also { + webviewContainer.add(it.component()) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt new file mode 100644 index 0000000000..36b738c751 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt @@ -0,0 +1,140 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.toolwindow + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.serviceContainer.NonInjectable +import kotlinx.coroutines.launch +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext +import software.aws.toolkits.jetbrains.services.amazonq.apps.AppConnection +import software.aws.toolkits.jetbrains.services.amazonq.commands.MessageTypeRegistry +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonq.messages.MessageConnector +import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteraction +import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteractionType +import software.aws.toolkits.jetbrains.services.amazonq.webview.BrowserConnector +import software.aws.toolkits.jetbrains.services.amazonq.webview.FqnWebviewAdapter +import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.EditorThemeAdapter +import software.aws.toolkits.jetbrains.services.codemodernizer.isCodeModernizerAvailable +import javax.swing.JComponent + +class AmazonQToolWindow @NonInjectable constructor( + private val project: Project, + private val appSource: AppSource, + private val browserConnector: BrowserConnector, + private val editorThemeAdapter: EditorThemeAdapter, +) : Disposable { + + private val panel = AmazonQPanel(parent = this) + + val component: JComponent + get() = panel.component + + private val appConnections = mutableListOf() + + private val scope = disposableCoroutineScope(this) + + constructor(project: Project) : this( + project = project, + appSource = AppSource(), + browserConnector = BrowserConnector(), + editorThemeAdapter = EditorThemeAdapter(), + ) + + init { + initConnections() + connectUi() + connectApps() + } + + private fun sendMessage(message: AmazonQMessage, tabType: String) { + appConnections.filter { it.app.tabTypes.contains(tabType) }.forEach { + scope.launch { + it.messagesFromUiToApp.publish(message) + } + } + } + + private fun initConnections() { + val apps = appSource.getApps(project) + apps.forEach { app -> + appConnections += AppConnection( + app = app, + messagesFromAppToUi = MessageConnector(), + messagesFromUiToApp = MessageConnector(), + messageTypeRegistry = MessageTypeRegistry(), + ) + } + } + + private fun connectApps() { + val browser = panel.browser ?: return + + val fqnWebviewAdapter = FqnWebviewAdapter(browser.jcefBrowser, browserConnector) + + appConnections.forEach { connection -> + val initContext = AmazonQAppInitContext( + project = project, + messagesFromAppToUi = connection.messagesFromAppToUi, + messagesFromUiToApp = connection.messagesFromUiToApp, + messageTypeRegistry = connection.messageTypeRegistry, + fqnWebviewAdapter = fqnWebviewAdapter, + ) + // Connect the app to the UI + connection.app.init(initContext) + // Dispose of the app when the tool window is disposed. + Disposer.register(this, connection.app) + } + } + + private fun connectUi() { + val browser = panel.browser ?: return + + browser.init( + isGumbyAvailable = isCodeModernizerAvailable(project) + ) + + scope.launch { + // Pipe messages from the UI to the relevant apps and vice versa + browserConnector.connect( + browser = browser, + connections = appConnections, + ) + } + + scope.launch { + // Update the theme in the UI when the IDE theme changes + browserConnector.connectTheme( + browser = browser, + themeSource = editorThemeAdapter.onThemeChange(), + ) + } + } + + companion object { + fun getInstance(project: Project): AmazonQToolWindow = project.service() + + fun getStarted(project: Project) { + // Make sure the window is shown + runInEdt { + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID) + toolWindow?.show() + } + + // Send the interaction message + val window = getInstance(project) + window.sendMessage(OnboardingPageInteraction(OnboardingPageInteractionType.CwcButtonClick), "cwc") + } + } + + override fun dispose() { + // Nothing to do + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt new file mode 100644 index 0000000000..15546ba4d8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt @@ -0,0 +1,35 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.toolwindow + +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import software.aws.toolkits.jetbrains.services.amazonq.isQSupportedInThisVersion +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend +import software.aws.toolkits.resources.message + +class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val contentManager = toolWindow.contentManager + val content = contentManager.factory.createContent(AmazonQToolWindow.getInstance(project).component, null, false).also { + it.isCloseable = true + it.isPinnable = true + } + contentManager.addContent(content) + toolWindow.activate(null) + contentManager.setSelectedContent(content) + } + + override fun init(toolWindow: ToolWindow) { + toolWindow.stripeTitle = message("q.window.title") + } + + override fun shouldBeAvailable(project: Project): Boolean = !isRunningOnRemoteBackend() && isQSupportedInThisVersion() + + companion object { + const val WINDOW_ID = "amazon.q.window" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowListener.kt new file mode 100644 index 0000000000..a4fbf57989 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowListener.kt @@ -0,0 +1,25 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.toolwindow + +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.openapi.wm.ex.ToolWindowManagerListener +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.TelemetryHelper + +class AmazonQToolWindowListener : ToolWindowManagerListener { + + override fun toolWindowShown(toolWindow: ToolWindow) { + TelemetryHelper.recordOpenChat() + } + + override fun stateChanged(toolWindowManager: ToolWindowManager, event: ToolWindowManagerListener.ToolWindowManagerEventType) { + val toolWindow = toolWindowManager.getToolWindow(AmazonQToolWindowFactory.WINDOW_ID) + toolWindow?.let { + if (event == ToolWindowManagerListener.ToolWindowManagerEventType.HideToolWindow && !it.isVisible) { + TelemetryHelper.recordCloseChat() + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AppSource.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AppSource.kt new file mode 100644 index 0000000000..94043a508f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AppSource.kt @@ -0,0 +1,13 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.toolwindow + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppFactory + +class AppSource { + private val extensionPointName = ExtensionPointName.create("aws.toolkit.amazonq.appFactory") + fun getApps(project: Project) = extensionPointName.extensionList.map { it.createApp(project) } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/util/JcefBrowserUtil.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/util/JcefBrowserUtil.kt new file mode 100644 index 0000000000..9cf9d9b97f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/util/JcefBrowserUtil.kt @@ -0,0 +1,22 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.util + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefBrowserBuilder + +fun createBrowser(parent: Disposable): JBCefBrowserBase { + val client = JBCefApp.getInstance().createClient() + + Disposer.register(parent, client) + + return JBCefBrowserBuilder() + .setClient(client) + // Setting OSR to false fixes multiple rendering and focus bugs with the browser + .setOffScreenRendering(false) + .build() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/util/JsonUtil.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/util/JsonUtil.kt new file mode 100644 index 0000000000..673396fa7e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/util/JsonUtil.kt @@ -0,0 +1,12 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.util + +import com.fasterxml.jackson.databind.JsonNode + +val JsonNode.command + get() = get("command").asText() + +val JsonNode.tabType + get() = get("tabType")?.asText() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/AssetResourceHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/AssetResourceHandler.kt new file mode 100644 index 0000000000..99cc46a1c6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/AssetResourceHandler.kt @@ -0,0 +1,94 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.webview + +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.callback.CefCallback +import org.cef.callback.CefSchemeHandlerFactory +import org.cef.handler.CefResourceHandler +import org.cef.misc.IntRef +import org.cef.misc.StringRef +import org.cef.network.CefRequest +import org.cef.network.CefResponse +import java.io.IOException +import java.net.URLConnection + +/** + * Loads assets from the /mynah-ui/assets/ directory on classpath if the CEF browser requests for + * any resource starting with the [AssetResourceHandler.LOCAL_RESOURCE_URL_PREFIX] defined + * below. The CEF Browser must register this scheme handler using [org.cef.CefApp.registerSchemeHandlerFactory] first. A + * new AssetResourceHandler instance is created for each request. + */ +class AssetResourceHandler(var data: ByteArray) : CefResourceHandler { + /** + * Factory class for [AssetResourceHandler]. Ignores any request that doesn't begin with + * [AssetResourceHandler.LOCAL_RESOURCE_URL_PREFIX] + */ + class AssetResourceHandlerFactory : CefSchemeHandlerFactory { + override fun create( + browser: CefBrowser?, + frame: CefFrame?, + schemeName: String?, + request: CefRequest?, + ): AssetResourceHandler? { + val resourceUri = request?.url ?: return null + if (!resourceUri.startsWith(LOCAL_RESOURCE_URL_PREFIX)) return null + + val resource = resourceUri.replace(LOCAL_RESOURCE_URL_PREFIX, "/mynah-ui/assets/") + val resourceInputStream = this.javaClass.getResourceAsStream(resource) + + try { + resourceInputStream.use { + if (resourceInputStream != null) { + return AssetResourceHandler(resourceInputStream.readAllBytes()) + } + return null + } + } catch (e: IOException) { + throw RuntimeException(e) + } + } + } + + private var offset = 0 + private var resourceUri: String? = null + override fun processRequest(cefRequest: CefRequest, cefCallback: CefCallback): Boolean { + resourceUri = cefRequest.url + cefCallback.Continue() + return true + } + + override fun getResponseHeaders(cefResponse: CefResponse, intRef: IntRef, stringRef: StringRef) { + intRef.set(data.size) + cefResponse.setHeaderByName("Access-Control-Allow-Origin", "*", true) + val mimeType = if (resourceUri?.endsWith(".wasm") == true) "application/wasm" else URLConnection.getFileNameMap().getContentTypeFor(resourceUri) + if (mimeType != null) cefResponse.mimeType = mimeType + cefResponse.status = 200 + } + + override fun readResponse( + outBuffer: ByteArray, + bytesToRead: Int, + bytesRead: IntRef, + cefCallback: CefCallback, + ): Boolean { + if (offset >= data.size) { + cefCallback.cancel() + return false + } + var lenToRead = Math.min(outBuffer.size, bytesToRead) + lenToRead = Math.min(data.size - offset, lenToRead) + System.arraycopy(data, offset, outBuffer, 0, lenToRead) + bytesRead.set(lenToRead) + offset = offset + lenToRead + return true + } + + override fun cancel() {} + + companion object { + private const val LOCAL_RESOURCE_URL_PREFIX = "http://mynah/" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt new file mode 100644 index 0000000000..04a082e264 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt @@ -0,0 +1,91 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.webview + +import com.intellij.openapi.Disposable +import com.intellij.ui.jcef.JBCefJSQuery +import org.cef.CefApp +import software.aws.toolkits.jetbrains.services.amazonq.util.createBrowser +import java.util.function.Function + +typealias MessageReceiver = Function + +/* +Displays the web view for the Amazon Q tool window + */ +class Browser(parent: Disposable) { + + val jcefBrowser = createBrowser(parent) + + val receiveMessageQuery = JBCefJSQuery.create(jcefBrowser) + + fun init(isGumbyAvailable: Boolean) { + // register the scheme handler to route http://mynah/ URIs to the resources/assets directory on classpath + CefApp.getInstance() + .registerSchemeHandlerFactory( + "http", + "mynah", + AssetResourceHandler.AssetResourceHandlerFactory(), + ) + + loadWebView(isGumbyAvailable) + } + + fun component() = jcefBrowser.component + + fun post(message: String) = + jcefBrowser + .cefBrowser + .executeJavaScript("window.postMessage(JSON.stringify($message))", jcefBrowser.cefBrowser.url, 0) + + // Load the chat web app into the jcefBrowser + private fun loadWebView(isGumbyAvailable: Boolean) { + // setup empty state. The message request handlers use this for storing state + // that's persistent between page loads. + jcefBrowser.setProperty("state", "") + // load the web app + jcefBrowser.loadHTML(getWebviewHTML(isGumbyAvailable)) + } + + /** + * Generate index.html for the web view + * @return HTML source + */ + private fun getWebviewHTML(isGumbyAvailable: Boolean): String { + val postMessageToJavaJsCode = receiveMessageQuery.inject("JSON.stringify(message)") + + val jsScripts = """ + + + """.trimIndent() + + return """ + + + + AWS Q + $jsScripts + + + + + """.trimIndent() + } + + companion object { + private const val WEB_SCRIPT_URI = "http://mynah/js/mynah-ui.js" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt new file mode 100644 index 0000000000..1c00247430 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt @@ -0,0 +1,85 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.webview + +import com.intellij.ui.jcef.JBCefJSQuery.Response +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import software.aws.toolkits.jetbrains.services.amazonq.apps.AppConnection +import software.aws.toolkits.jetbrains.services.amazonq.commands.MessageSerializer +import software.aws.toolkits.jetbrains.services.amazonq.util.command +import software.aws.toolkits.jetbrains.services.amazonq.util.tabType +import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.AmazonQTheme +import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.ThemeBrowserAdapter +import java.util.function.Function + +class BrowserConnector( + private val serializer: MessageSerializer = MessageSerializer.getInstance(), + private val themeBrowserAdapter: ThemeBrowserAdapter = ThemeBrowserAdapter(), +) { + val uiReady = CompletableDeferred() + + suspend fun connect( + browser: Browser, + connections: List, + ) = coroutineScope { + // Send browser messages to the outbound publisher + addMessageHook(browser) + .onEach { json -> + val node = serializer.toNode(json) + if (node.command == "ui-is-ready") { + uiReady.complete(true) + } + val tabType = node.tabType ?: return@onEach + connections.filter { connection -> connection.app.tabTypes.contains(tabType) }.forEach { connection -> + launch { + val message = serializer.deserialize(node, connection.messageTypeRegistry) + connection.messagesFromUiToApp.publish(message) + } + } + } + .launchIn(this) + + // Wait for UI ready before starting to send messages to the UI. + uiReady.await() + + // Send inbound messages to the browser + val inboundMessages = connections.map { it.messagesFromAppToUi.flow }.merge() + inboundMessages + .onEach { browser.post(serializer.serialize(it)) } + .launchIn(this) + } + + suspend fun connectTheme( + browser: Browser, + themeSource: Flow, + ) = coroutineScope { + uiReady.await() + themeSource + .distinctUntilChanged() + .onEach { themeBrowserAdapter.updateThemeInBrowser(browser.jcefBrowser.cefBrowser, it) } + .launchIn(this) + } + + private fun addMessageHook(browser: Browser) = callbackFlow { + val handler = Function { + trySend(it) + null + } + + browser.receiveMessageQuery.addHandler(handler) + + awaitClose { + browser.receiveMessageQuery.removeHandler(handler) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/FqnWebviewAdapter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/FqnWebviewAdapter.kt new file mode 100644 index 0000000000..6a613486a5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/FqnWebviewAdapter.kt @@ -0,0 +1,85 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.webview + +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.time.withTimeout +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import java.time.Duration + +/** + * Exposes the FQN (Fully Qualified Name) and import reading capabilities that are implemented in TypeScript via a Kotlin API. + */ +class FqnWebviewAdapter( + private val jcefBrowser: JBCefBrowserBase, + private val browserConnector: BrowserConnector, +) { + + private val namesExtractionResponses: Channel = Channel() + private val receiveNames: String + + init { + val receiveNamesFromBrowser = JBCefJSQuery.create(jcefBrowser) + receiveNamesFromBrowser.addHandler { names: String -> + namesExtractionResponses.trySend(names) + null + } + receiveNames = receiveNamesFromBrowser.inject("names") + } + + @Throws(TimeoutCancellationException::class) + suspend fun readImports(args: String): String { + browserConnector.uiReady.await() + + return try { + withTimeout(Duration.ofMillis(1250)) { + jcefBrowser.cefBrowser.executeJavaScript( + """ + window.fqnExtractor.readImports($args.fileContent, $args.language).then(result => { + const names = JSON.stringify(Array.from(result)); + $receiveNames + }); + """.trimIndent(), + jcefBrowser.cefBrowser.url, + 0, + ) + namesExtractionResponses.receive() + } + } catch (e: TimeoutCancellationException) { + logger.warn(e) { "Failed to read imports" } + "[]" + } + } + + suspend fun extractNames(args: String): String { + browserConnector.uiReady.await() + + return try { + withTimeout(Duration.ofMillis(1250)) { + jcefBrowser.cefBrowser.executeJavaScript( + """ + window.fqnExtractor.extractCodeQuery($args.fileContent, $args.language, $args.codeSelection).then(({codeQuery, namesWereTruncated}) => { + const names = codeQuery === undefined ? `{"simpleNames": [], "fullyQualifiedNames": {"used": []}}` : JSON.stringify(codeQuery); + $receiveNames + }); + """.trimIndent(), + jcefBrowser.cefBrowser.url, + 0, + ) + namesExtractionResponses.receive() + } + } catch (e: TimeoutCancellationException) { + logger.warn(e) { "Failed to extract fully qualified names" } + "{\"simpleNames\": [], \"fullyQualifiedNames\": {\"used\": []}}" + } + } + + companion object { + private val logger = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/AmazonQTheme.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/AmazonQTheme.kt new file mode 100644 index 0000000000..1c78cbc2ca --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/AmazonQTheme.kt @@ -0,0 +1,53 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.webview.theme + +import java.awt.Color +import java.awt.Font + +/** + * Data class that encapsulates the theme values we extract from the IDE. + */ +data class AmazonQTheme( + val darkMode: Boolean, + val font: Font, + + val defaultText: Color, + val inactiveText: Color, + val linkText: Color, + + val background: Color, + val border: Color, + val activeTab: Color, + + val checkboxBackground: Color, + val checkboxForeground: Color, + + val textFieldBackground: Color, + val textFieldForeground: Color, + + val buttonForeground: Color, + val buttonBackground: Color, + val secondaryButtonForeground: Color, + val secondaryButtonBackground: Color, + + val info: Color, + val success: Color, + val warning: Color, + val error: Color, + + val cardBackground: Color, + + val editorFont: Font, + val editorBackground: Color, + val editorForeground: Color, + val editorVariable: Color, + val editorOperator: Color, + val editorFunction: Color, + val editorComment: Color, + val editorKeyword: Color, + val editorString: Color, + val editorProperty: Color, + +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/CssVariable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/CssVariable.kt new file mode 100644 index 0000000000..c7c38ee5eb --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/CssVariable.kt @@ -0,0 +1,59 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.webview.theme + +/** + * Enumeration of CSS variables that used by MynahUi to theme the chat experience. + */ +enum class CssVariable( + val varName: String +) { + FontSize("--vscode-font-size"), + FontFamily("--mynah-font-family"), + + TextColorDefault("--mynah-color-text-default"), + TextColorStrong("--mynah-color-text-strong"), + TextColorWeak("--mynah-color-text-weak"), + TextColorLink("--mynah-color-text-link"), + TextColorInput("--mynah-color-text-input"), + + Background("--mynah-color-bg"), + BackgroundAlt("--mynah-color-bg-alt"), + TabActive("--mynah-color-tab-active"), + + ColorDeep("--mynah-color-deep"), + ColorDeepReverse("--mynah-color-deep-reverse"), + BorderDefault("--mynah-color-border-default"), + InputBackground("--mynah-color-input-bg"), + + SyntaxBackground("--mynah-color-syntax-bg"), + SyntaxVariable("--mynah-color-syntax-variable"), + SyntaxFunction("--mynah-color-syntax-function"), + SyntaxOperator("--mynah-color-syntax-operator"), + SyntaxAttributeValue("--mynah-color-syntax-attr-value"), + SyntaxAttribute("--mynah-color-syntax-attr"), + SyntaxProperty("--mynah-color-syntax-property"), + SyntaxComment("--mynah-color-syntax-comment"), + SyntaxCode("--mynah-color-syntax-code"), + SyntaxCodeFontFamily("--mynah-syntax-code-font-family"), + SyntaxCodeFontSize("--mynah-syntax-code-font-size"), + + StatusInfo("--mynah-color-status-info"), + StatusSuccess("--mynah-color-status-success"), + StatusWarning("--mynah-color-status-warning"), + StatusError("--mynah-color-status-error"), + + ButtonBackground("--mynah-color-button"), + ButtonForeground("--mynah-color-button-reverse"), + + SecondaryButtonBackground("--mynah-color-alternate"), + SecondaryButtonForeground("--mynah-color-alternate-reverse"), + + CodeText("--mynah-color-code-text"), + + MainBackground("--mynah-color-main"), + MainForeground("--mynah-color-main-reverse"), + + CardBackground("--mynah-card-bg") +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt new file mode 100644 index 0000000000..3727ea17bb --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt @@ -0,0 +1,163 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.webview.theme + +import com.intellij.ide.ui.LafManagerListener +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.colors.ColorKey +import com.intellij.openapi.editor.colors.EditorColorsListener +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.ui.JBColor +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import java.awt.Color + +/** + * Helper class that returns a Flow of [AmazonQTheme] instances based on the current IDE theme. + */ +class EditorThemeAdapter { + private val logger = getLogger() + + /** + * Returns a flow of [AmazonQTheme] instances. The current theme is emitted immediately, + * and a new theme is emitted whenever the look-and-feel of the IDE changes. + */ + fun onThemeChange() = callbackFlow { + // Register a listener for changes to the IDE LaF + val messageBus = ApplicationManager.getApplication().messageBus + val connection = messageBus.connect() + + // Listen to LaF changes (the overall IDE theme changes) + connection.subscribe( + LafManagerListener.TOPIC, + LafManagerListener { + // It's important to not throw exceptions from this listener. Throwing here will prevent the user's theme from being properly applied in the IDE + try { + trySend(getThemeFromIde()) + } catch (e: Exception) { + logger.error(e) { "Cannot construct Amazon Q theme from IDE colors" } + } + }, + ) + + // Also listen to EditorColors changes. This will be triggered if the editor's colors or fonts change. + connection.subscribe( + EditorColorsManager.TOPIC, + EditorColorsListener { + // It's important to not throw exceptions from this listener. Throwing here will prevent the user's theme from being properly applied in the IDE + try { + trySend(getThemeFromIde()) + } catch (e: Exception) { + logger.error(e) { "Cannot construct Amazon Q theme from IDE colors" } + } + }, + ) + + // Send an initial value for the current theme + send(getThemeFromIde()) + // Disconnect from the message bus when the flow collection is cancelled + awaitClose { connection.disconnect() } + } + + companion object { + // Returns a theme constructed from the current look-and-feel of the IDE + fun getThemeFromIde(): AmazonQTheme { + val currentScheme = EditorColorsManager.getInstance().schemeForCurrentUITheme + + val cardBackground = currentScheme.defaultBackground + val text = currentScheme.defaultForeground + val chatBackground = tryFindDifferentColor( + cardBackground, + "Panel.background", + "EditorPane.background", + "EditorPane.inactiveBackground", + "Editor.background", + "Content.background", + default = 0xF2F2F2, + darkDefault = 0x3C3F41, + ) + + return AmazonQTheme( + darkMode = !JBColor.isBright(), + font = UIUtil.getFont(UIUtil.FontSize.NORMAL, null), + + defaultText = text, + inactiveText = themeColor("TextField.inactiveForeground", default = 0x8C8C8C, darkDefault = 0x808080), + linkText = themeColor("link.foreground", "link", "Link.activeForeground", default = 0x589DF6), + + background = chatBackground, + border = getBorderColor(currentScheme), + activeTab = themeColor("EditorTabs.underlinedTabBackground", default = 0xFFFFFF, darkDefault = 0x4E5254), + + checkboxBackground = themeColor("CheckBox.background", default = 0xF2F2F2, darkDefault = 0x3C3F41), + checkboxForeground = themeColor("CheckBox.foreground", default = 0x000000, darkDefault = 0xBBBBBB), + + textFieldBackground = themeColor("TextField.background", default = 0xFFFFFF, darkDefault = 0x45494A), + textFieldForeground = themeColor("TextField.foreground", default = 0x000000, darkDefault = 0xBBBBBB), + + buttonBackground = themeColor("Button.default.startBackground", default = 0x528CC7, darkDefault = 0x365880), + buttonForeground = themeColor("Button.default.foreground", default = 0xFFFFFF, darkDefault = 0xBBBBBB), + secondaryButtonBackground = themeColor("Button.startBackground", default = 0xFFFFFF, darkDefault = 0x4C5052), + secondaryButtonForeground = themeColor("Button.foreground", default = 0x000000, darkDefault = 0xBBBBBB), + + info = themeColor("ProgressBar.progressColor", default = 0x1E82E6, darkDefault = 0xA0A0A0), + success = themeColor("ProgressBar.passedColor", default = 0x34B171, darkDefault = 0x008F50), + warning = themeColor("Component.warningFocusColor", default = 0xE2A53A), + error = themeColor("ProgressBar.failedColor", default = 0xD64F4F, darkDefault = 0xE74848), + + cardBackground = cardBackground, + + editorFont = currentScheme.getFont(EditorFontType.PLAIN), + editorBackground = chatBackground, + editorForeground = text, + editorVariable = currentScheme.foregroundColor(DefaultLanguageHighlighterColors.LOCAL_VARIABLE), + editorOperator = currentScheme.foregroundColor(DefaultLanguageHighlighterColors.OPERATION_SIGN), + editorFunction = currentScheme.foregroundColor(DefaultLanguageHighlighterColors.FUNCTION_DECLARATION), + editorComment = currentScheme.foregroundColor(DefaultLanguageHighlighterColors.LINE_COMMENT), + editorKeyword = currentScheme.foregroundColor(DefaultLanguageHighlighterColors.KEYWORD), + editorString = currentScheme.foregroundColor(DefaultLanguageHighlighterColors.STRING), + editorProperty = currentScheme.foregroundColor(DefaultLanguageHighlighterColors.INSTANCE_FIELD), + ) + } + + private fun themeColor(name: String, default: Int, darkDefault: Int = default) = JBColor.namedColor(name, JBColor(default, darkDefault)) + + private fun themeColor(name: String, vararg backups: String, default: Int, darkDefault: Int = default): Color { + var defaultColor = JBColor(default, darkDefault) + for (i in backups.indices.reversed()) { + defaultColor = JBColor.namedColor(backups[i], defaultColor) + } + return JBColor.namedColor(name, defaultColor) + } + + private fun getBorderColor(currentScheme: EditorColorsScheme) = currentScheme.getColor(ColorKey.find("INDENT_GUIDE")) ?: themeColor( + "Borders.color", + "Component.borderColor", + "EditorTabs.borderColor", + default = 0xC4C4C4, + darkDefault = 0x646464, + ) + + private fun tryFindDifferentColor(color: Color, vararg choices: String, default: Int, darkDefault: Int): Color { + for (choice in choices) { + val themeColor = JBColor.namedColor(choice) + if (themeColor != color) { + return themeColor + } + } + // None of them are different so just take the first defined value + return themeColor(choices.first(), *choices, default = default, darkDefault = darkDefault) + } + + // Not all values may be set in the current scheme. Use the default foreground color if not specified. + private fun EditorColorsScheme.foregroundColor(key: TextAttributesKey) = getAttributes(key).foregroundColor ?: defaultForeground + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/ThemeBrowserAdapter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/ThemeBrowserAdapter.kt new file mode 100644 index 0000000000..d076604a76 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/ThemeBrowserAdapter.kt @@ -0,0 +1,101 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.webview.theme + +import org.cef.browser.CefBrowser +import java.awt.Color +import java.awt.Font + +// The className must match what's in the mynah-ui package. +const val DARK_MODE_CLASS = "vscode-dark" + +/** + * Takes a [AmazonQTheme] instance and uses it to update CSS variables in the Webview UI. + */ +class ThemeBrowserAdapter { + fun updateThemeInBrowser(browser: CefBrowser, theme: AmazonQTheme) { + val codeToUpdateTheme = buildJsCodeToUpdateTheme(theme) + browser.executeJavaScript(codeToUpdateTheme, browser.url, 0) + } + + private fun buildJsCodeToUpdateTheme(theme: AmazonQTheme) = buildString { + appendDarkMode(theme.darkMode) + + append("{\n") + append("const rootElement = document.querySelector(':root');\n") + + append(CssVariable.FontSize, theme.font.toCssSize()) + append(CssVariable.FontFamily, theme.font.toCssFontFamily()) + + append(CssVariable.TextColorDefault, theme.defaultText) + append(CssVariable.TextColorStrong, theme.textFieldForeground) + append(CssVariable.TextColorInput, theme.textFieldForeground) + append(CssVariable.TextColorLink, theme.linkText) + append(CssVariable.TextColorWeak, theme.inactiveText) + + append(CssVariable.Background, theme.background) + append(CssVariable.BackgroundAlt, theme.background) + append(CssVariable.CardBackground, theme.cardBackground) + append(CssVariable.BorderDefault, theme.border) + append(CssVariable.TabActive, theme.activeTab) + + append(CssVariable.InputBackground, theme.textFieldBackground) + + append(CssVariable.ButtonBackground, theme.buttonBackground) + append(CssVariable.ButtonForeground, theme.buttonForeground) + append(CssVariable.SecondaryButtonBackground, theme.secondaryButtonBackground) + append(CssVariable.SecondaryButtonForeground, theme.secondaryButtonForeground) + + append(CssVariable.StatusInfo, theme.info) + append(CssVariable.StatusSuccess, theme.success) + append(CssVariable.StatusWarning, theme.warning) + append(CssVariable.StatusError, theme.error) + + append(CssVariable.ColorDeep, theme.checkboxBackground) + append(CssVariable.ColorDeepReverse, theme.checkboxForeground) + + append(CssVariable.SyntaxCodeFontFamily, theme.editorFont.toCssFontFamily("monospace")) + append(CssVariable.SyntaxCodeFontSize, theme.editorFont.toCssSize()) + append(CssVariable.SyntaxBackground, theme.editorBackground) + append(CssVariable.SyntaxVariable, theme.editorVariable) + append(CssVariable.SyntaxOperator, theme.editorOperator) + append(CssVariable.SyntaxFunction, theme.editorFunction) + append(CssVariable.SyntaxComment, theme.editorComment) + append(CssVariable.SyntaxAttributeValue, theme.editorKeyword) + append(CssVariable.SyntaxAttribute, theme.editorString) + append(CssVariable.SyntaxProperty, theme.editorProperty) + append(CssVariable.SyntaxCode, theme.editorForeground) + + append(CssVariable.MainBackground, theme.buttonBackground) + append(CssVariable.MainForeground, theme.buttonForeground) + + append("}") + } + + private fun StringBuilder.append(variable: CssVariable, value: Color) = append(variable, value.toCss()) + + private fun StringBuilder.append(variable: CssVariable, value: String) { + append("rootElement.style.setProperty('") + append(variable.varName) + append("', '") + append(value) + append("');\n") + } + + private fun StringBuilder.appendDarkMode(isDarkMode: Boolean) { + if (isDarkMode) { + // classList acts as a set, so we don't need to worry about calling add multiple times + append("document.body.classList.add('$DARK_MODE_CLASS');\n") + } else { + append("document.body.classList.remove('$DARK_MODE_CLASS');\n") + } + } + + private fun Color.toCss() = "rgba($red,$green,$blue,$alpha)" + + private fun Font.toCssSize() = "${size}px" + + // Some font names have characters that require them to be wrapped in quotes in the CSS variable, for example if they have spaces or a period. + private fun Font.toCssFontFamily(fallback: String = "system-ui") = "\"$family\", $fallback" +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/AppRunnerExplorerNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/AppRunnerExplorerNode.kt new file mode 100644 index 0000000000..c0cee63810 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/AppRunnerExplorerNode.kt @@ -0,0 +1,39 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner + +import com.intellij.openapi.project.Project +import icons.AwsIcons +import software.amazon.awssdk.services.apprunner.AppRunnerClient +import software.amazon.awssdk.services.apprunner.model.ServiceSummary +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 +import software.aws.toolkits.jetbrains.services.apprunner.resources.AppRunnerResources +import software.aws.toolkits.jetbrains.utils.toHumanReadable +import software.aws.toolkits.resources.message + +class AppRunnerNode(project: Project, service: AwsExplorerServiceNode) : + CacheBackedAwsExplorerServiceRootNode(project, service, AppRunnerResources.LIST_SERVICES) { + override fun displayName() = message("explorer.node.apprunner") + override fun toNode(child: ServiceSummary): AwsExplorerNode<*> = AppRunnerServiceNode(nodeProject, child) +} + +class AppRunnerServiceNode( + project: Project, + val service: ServiceSummary +) : AwsExplorerResourceNode( + project, + AppRunnerClient.SERVICE_NAME, + service.serviceName(), + AwsIcons.Resources.APPRUNNER_SERVICE +) { + override fun resourceType(): String = "service" + override fun resourceArn(): String = service.serviceArn() + override fun statusText(): String = service.status().toString().toHumanReadable() + + override fun isAlwaysShowPlus(): Boolean = false + override fun isAlwaysLeaf(): Boolean = true +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/CreateServiceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/CreateServiceAction.kt new file mode 100644 index 0000000000..d15309f135 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/CreateServiceAction.kt @@ -0,0 +1,16 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.apprunner.ui.CreationDialog +import software.aws.toolkits.resources.message + +class CreateServiceAction : DumbAwareAction(message("apprunner.action.create.service")) { + override fun actionPerformed(e: AnActionEvent) { + CreationDialog(e.getRequiredData(PlatformDataKeys.PROJECT)).show() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/DeleteServiceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/DeleteServiceAction.kt new file mode 100644 index 0000000000..cc29d7fe99 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/DeleteServiceAction.kt @@ -0,0 +1,20 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.actions + +import software.amazon.awssdk.services.apprunner.AppRunnerClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.explorer.actions.DeleteResourceAction +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.services.apprunner.AppRunnerServiceNode +import software.aws.toolkits.jetbrains.services.apprunner.resources.AppRunnerResources +import software.aws.toolkits.resources.message + +class DeleteServiceAction : DeleteResourceAction(message("apprunner.action.delete.service")) { + override fun performDelete(selected: AppRunnerServiceNode) { + val client = selected.nodeProject.awsClient() + client.deleteService { it.serviceArn(selected.resourceArn()) } + selected.nodeProject.refreshAwsTree(AppRunnerResources.LIST_SERVICES) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/DeployAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/DeployAction.kt new file mode 100644 index 0000000000..4d35f9e381 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/DeployAction.kt @@ -0,0 +1,83 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAware +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import software.amazon.awssdk.services.apprunner.AppRunnerClient +import software.amazon.awssdk.services.apprunner.model.AppRunnerException +import software.amazon.awssdk.services.apprunner.model.ServiceStatus +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient +import software.aws.toolkits.core.utils.error +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.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.apprunner.AppRunnerServiceNode +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.checkIfLogStreamExists +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.ApprunnerTelemetry +import software.aws.toolkits.telemetry.Result + +class DeployAction : SingleResourceNodeAction(message("apprunner.action.deploy")), DumbAware { + override fun update(selected: AppRunnerServiceNode, e: AnActionEvent) { + e.presentation.isVisible = selected.service.status() == ServiceStatus.RUNNING + } + + override fun actionPerformed(selected: AppRunnerServiceNode, e: AnActionEvent) { + val project = selected.nodeProject + ProgressManager.getInstance().run( + object : Task.Backgroundable(project, message("apprunner.action.deploy.starting"), false, ALWAYS_BACKGROUND) { + override fun run(indicator: ProgressIndicator) = runBlocking { + deploy(selected, project.awsClient(), project.awsClient()) + } + } + ) + } + + internal suspend fun deploy(selected: AppRunnerServiceNode, client: AppRunnerClient, cloudwatchClient: CloudWatchLogsClient) { + val project = selected.nodeProject + val deployment = try { + client.startDeployment { it.serviceArn(selected.resourceArn()) } + } catch (e: Exception) { + notifyError( + project = project, + title = message("apprunner.action.deploy.failed"), + content = if (e is AppRunnerException) e.awsErrorDetails().errorMessage() else message("apprunner.action.deploy.failed") + ) + LOG.error(e) { "Failed to deploy new AppRunner service revision" } + ApprunnerTelemetry.startDeployment(project, Result.Failed) + return + } + ApprunnerTelemetry.startDeployment(project, Result.Succeeded) + val logGroup = "/aws/apprunner/${selected.service.serviceName()}/${selected.service.serviceId()}/service" + val logStream = "deployment/${deployment.operationId()}" + val logWindow = CloudWatchLogWindow.getInstance(project) + // Try 15 times. If it's not made by 15 seconds, there is probably another issue, so show an error message + repeat(15) { _ -> + if (runCatching { cloudwatchClient.checkIfLogStreamExists(logGroup, logStream) }.getOrNull() == true) { + LOG.info { "Found log stream for deployment $logStream" } + logWindow.showLogStream(logGroup, logStream, streamLogs = true) + return + } + delay(1000) + } + notifyError( + project = project, + content = message("apprunner.action.deploy.unableToFindLogStream", logStream) + ) + LOG.error { "Unable to find log stream for deployment $logStream" } + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/PauseServiceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/PauseServiceAction.kt new file mode 100644 index 0000000000..50f8ccf333 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/PauseServiceAction.kt @@ -0,0 +1,96 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.ui.dsl.builder.panel +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.apprunner.AppRunnerClient +import software.amazon.awssdk.services.apprunner.model.AppRunnerException +import software.amazon.awssdk.services.apprunner.model.ServiceStatus +import software.amazon.awssdk.services.apprunner.model.ServiceSummary +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.services.apprunner.AppRunnerServiceNode +import software.aws.toolkits.jetbrains.services.apprunner.resources.AppRunnerResources +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.ApprunnerTelemetry +import software.aws.toolkits.telemetry.Result +import javax.swing.JComponent + +class PauseServiceAction : + SingleResourceNodeAction(message("apprunner.action.pause")), + DumbAware { + override fun update(selected: AppRunnerServiceNode, e: AnActionEvent) { + e.presentation.isVisible = selected.service.status() == ServiceStatus.RUNNING + } + + override fun actionPerformed(selected: AppRunnerServiceNode, e: AnActionEvent) { + val dialog = object : DialogWrapper(selected.nodeProject) { + init { + init() + setOKButtonText(message("apprunner.action.pause.confirm")) + } + + override fun getTitle(): String = message("apprunner.action.pause") + override fun getHelpId(): String = HelpIds.APPRUNNER_PAUSE_RESUME.id + override fun createCenterPanel(): JComponent = panel { + row { + label(message("apprunner.pause.warning", selected.service.serviceName())).applyToComponent { + icon = Messages.getWarningIcon() + iconTextGap = 8 + } + } + } + } + if (dialog.showAndGet()) { + val scope = projectCoroutineScope(selected.nodeProject) + scope.launch { + performPause(selected.nodeProject, selected.nodeProject.awsClient(), selected.service) + } + } else { + ApprunnerTelemetry.pauseService(selected.nodeProject, Result.Cancelled) + } + } + + internal fun performPause(project: Project, client: AppRunnerClient, service: ServiceSummary) = try { + client.pauseService { it.serviceArn(service.serviceArn()) } + notifyInfo( + project = project, + title = message("apprunner.action.pause"), + content = message("apprunner.pause.succeeded", service.serviceName()) + ) + ApprunnerTelemetry.pauseService(project, Result.Succeeded) + } catch (e: Exception) { + val m = if (e is AppRunnerException) { + e.awsErrorDetails()?.errorMessage() ?: "" + } else { + "" + } + notifyError( + title = message("apprunner.action.pause"), + project = project, + content = message("apprunner.pause.failed", service.serviceName(), m) + ) + LOG.error(e) { "Exception thrown while pausing AppRunner service" } + ApprunnerTelemetry.pauseService(project, Result.Failed) + } finally { + project.refreshAwsTree(AppRunnerResources.LIST_SERVICES) + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/ResumeServiceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/ResumeServiceAction.kt new file mode 100644 index 0000000000..039505b37b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/ResumeServiceAction.kt @@ -0,0 +1,98 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.ui.dsl.builder.panel +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.apprunner.AppRunnerClient +import software.amazon.awssdk.services.apprunner.model.AppRunnerException +import software.amazon.awssdk.services.apprunner.model.ServiceStatus +import software.amazon.awssdk.services.apprunner.model.ServiceSummary +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.services.apprunner.AppRunnerServiceNode +import software.aws.toolkits.jetbrains.services.apprunner.resources.AppRunnerResources +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.ApprunnerTelemetry +import software.aws.toolkits.telemetry.Result +import javax.swing.JComponent + +class ResumeServiceAction : + SingleResourceNodeAction(message("apprunner.action.resume")), + DumbAware { + override fun update(selected: AppRunnerServiceNode, e: AnActionEvent) { + e.presentation.isVisible = selected.service.status() == ServiceStatus.PAUSED + } + + override fun actionPerformed(selected: AppRunnerServiceNode, e: AnActionEvent) { + val dialog = object : DialogWrapper(selected.nodeProject) { + init { + init() + setOKButtonText(message("apprunner.action.resume.confirm")) + } + + override fun getTitle(): String = message("apprunner.action.resume") + override fun getHelpId(): String = HelpIds.APPRUNNER_PAUSE_RESUME.id + override fun createCenterPanel(): JComponent = panel { + row { + label(message("apprunner.resume.warning", selected.service.serviceName())).applyToComponent { + icon = Messages.getWarningIcon() + iconTextGap = 8 + } + } + } + } + + if (dialog.showAndGet()) { + val scope = projectCoroutineScope(selected.nodeProject) + scope.launch { + performResume(selected.nodeProject, selected.nodeProject.awsClient(), selected.service) + } + } else { + ApprunnerTelemetry.pauseService(selected.nodeProject, Result.Cancelled) + } + } + + internal fun performResume(project: Project, client: AppRunnerClient, service: ServiceSummary) = try { + client.resumeService { it.serviceArn(service.serviceArn()) } + notifyInfo( + project = project, + title = message("apprunner.action.resume"), + content = message("apprunner.resume.succeeded", service.serviceName()) + ) + project.refreshAwsTree(AppRunnerResources.LIST_SERVICES) + ApprunnerTelemetry.resumeService(project, Result.Succeeded) + } catch (e: Exception) { + val m = if (e is AppRunnerException) { + e.awsErrorDetails()?.errorMessage() ?: "" + } else { + "" + } + notifyError( + project = project, + title = message("apprunner.action.resume"), + content = message("apprunner.resume.failed", service.serviceName(), m) + ) + LOG.error(e) { "Exception thrown while resuming AppRunner service" } + ApprunnerTelemetry.resumeService(project, Result.Failed) + } finally { + project.refreshAwsTree(AppRunnerResources.LIST_SERVICES) + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/ServiceUrlActions.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/ServiceUrlActions.kt new file mode 100644 index 0000000000..8176a36e04 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/ServiceUrlActions.kt @@ -0,0 +1,30 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.actions + +import com.intellij.ide.browsers.BrowserLauncher +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.DumbAware +import software.amazon.awssdk.services.apprunner.model.ServiceSummary +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.apprunner.AppRunnerServiceNode +import software.aws.toolkits.telemetry.ApprunnerTelemetry +import java.awt.datatransfer.StringSelection + +class CopyServiceUrlAction : SingleResourceNodeAction(), DumbAware { + override fun actionPerformed(selected: AppRunnerServiceNode, e: AnActionEvent) { + CopyPasteManager.getInstance().setContents(StringSelection(selected.service.urlWithProtocol())) + ApprunnerTelemetry.copyServiceUrl(selected.nodeProject) + } +} + +class OpenServiceUrlAction : SingleResourceNodeAction(), DumbAware { + override fun actionPerformed(selected: AppRunnerServiceNode, e: AnActionEvent) { + BrowserLauncher.instance.browse(selected.service.urlWithProtocol(), project = selected.nodeProject) + ApprunnerTelemetry.openServiceUrl(selected.nodeProject) + } +} + +private fun ServiceSummary.urlWithProtocol() = "https://${serviceUrl()}" diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/ViewLogsAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/ViewLogsAction.kt new file mode 100644 index 0000000000..1ba8edd749 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/actions/ViewLogsAction.kt @@ -0,0 +1,65 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import icons.AwsIcons +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.apprunner.AppRunnerServiceNode +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.checkIfLogGroupExists +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.ApprunnerTelemetry + +class ViewSystemLogsAction : + SingleResourceNodeAction(message("apprunner.view_service_log_streams"), null, AwsIcons.Resources.CloudWatch.LOGS), + DumbAware { + override fun actionPerformed(selected: AppRunnerServiceNode, e: AnActionEvent) { + val scope = projectCoroutineScope(selected.nodeProject) + scope.launch { + try { + viewLogGroup(selected, "service") + } finally { + ApprunnerTelemetry.viewServiceLogs(selected.nodeProject) + } + } + } +} + +class ViewApplicationLogsAction : + SingleResourceNodeAction(message("apprunner.view_application_log_streams"), null, AwsIcons.Resources.CloudWatch.LOGS), + DumbAware { + override fun actionPerformed(selected: AppRunnerServiceNode, e: AnActionEvent) { + val scope = projectCoroutineScope(selected.nodeProject) + scope.launch { + try { + viewLogGroup(selected, "application") + } finally { + ApprunnerTelemetry.viewApplicationLogs(selected.nodeProject) + } + } + } +} + +internal fun viewLogGroup(selected: AppRunnerServiceNode, logSuffix: String) { + val project = selected.nodeProject + val logGroup = "/aws/apprunner/${selected.service.serviceName()}/${selected.service.serviceId()}/$logSuffix" + try { + val client = project.awsClient() + if (client.checkIfLogGroupExists(logGroup)) { + val window = CloudWatchLogWindow.getInstance(project) + window.showLogGroup(logGroup) + } else { + notifyError(project = project, content = message("apprunner.view_service_log_streams.error_not_created")) + } + } catch (e: Exception) { + notifyError(project = project, content = message("apprunner.view_service_log_streams.error", logGroup)) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/resources/AppRunnerPolicies.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/resources/AppRunnerPolicies.kt new file mode 100644 index 0000000000..34e299938d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/resources/AppRunnerPolicies.kt @@ -0,0 +1,9 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.resources + +// Policy name and content from https://docs.aws.amazon.com/apprunner/latest/dg/security_iam_service-with-iam.html +const val APPRUNNER_ECR_DEFAULT_ROLE_NAME = "AppRunnerECRAccessRole" +const val APPRUNNER_ECR_MANAGED_POLICY = "service-role/AWSAppRunnerServicePolicyForECRAccess" +const val APPRUNNER_SERVICE_ROLE_URI = "build.apprunner.amazonaws.com" diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/resources/AppRunnerResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/resources/AppRunnerResources.kt new file mode 100644 index 0000000000..762770e94b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/resources/AppRunnerResources.kt @@ -0,0 +1,17 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.resources + +import software.amazon.awssdk.services.apprunner.AppRunnerClient +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource + +object AppRunnerResources { + val LIST_SERVICES = ClientBackedCachedResource(AppRunnerClient::class, "apprunner.listServices") { + listServicesPaginator { }.toList().flatMap { it.serviceSummaryList() } + } + + val LIST_CONNECTIONS = ClientBackedCachedResource(AppRunnerClient::class, "apprunner.listConnections") { + listConnectionsPaginator { }.toList().flatMap { it.connectionSummaryList() } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/ui/CreationDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/ui/CreationDialog.kt new file mode 100644 index 0000000000..b754471ece --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/ui/CreationDialog.kt @@ -0,0 +1,167 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.ui + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.apprunner.AppRunnerClient +import software.amazon.awssdk.services.apprunner.model.AppRunnerException +import software.amazon.awssdk.services.apprunner.model.ConfigurationSource +import software.amazon.awssdk.services.apprunner.model.CreateServiceRequest +import software.amazon.awssdk.services.apprunner.model.ImageRepositoryType +import software.amazon.awssdk.services.apprunner.model.SourceCodeVersionType +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.services.apprunner.resources.AppRunnerResources +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AppRunnerServiceSource +import software.aws.toolkits.telemetry.ApprunnerTelemetry +import software.aws.toolkits.telemetry.Result +import javax.swing.JComponent + +class CreationDialog(private val project: Project, ecrUri: String? = null) : + DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + val panel = CreationPanel(project, ecrUri) + + init { + super.init() + title = message("apprunner.creation.title") + setOKButtonText(message("general.create_button")) + } + + override fun createCenterPanel(): JComponent = panel.component + + override fun doCancelAction() { + ApprunnerTelemetry.createService(project, Result.Cancelled, deploymentTypeFromPanel(panel)) + super.doCancelAction() + } + + override fun doOKAction() { + if (!isOKActionEnabled) { + return + } + isOKActionEnabled = false + setOKButtonText(message("general.create_in_progress")) + panel.component.apply() + coroutineScope.launch { + try { + val client = project.awsClient() + val request = buildRequest(panel) + // TODO use the operation id to allow opening logs? Unfortunately, it takes up to 30 + // seconds to create so maybe not? + client.createService(request) + notifyInfo( + project = project, + title = message("apprunner.creation.started.title"), + content = message("apprunner.creation.started") + ) + withContext(getCoroutineUiContext()) { + close(OK_EXIT_CODE) + project.refreshAwsTree(AppRunnerResources.LIST_SERVICES) + } + ApprunnerTelemetry.createService(project, Result.Succeeded, deploymentTypeFromPanel(panel)) + } catch (e: Exception) { + if (e is AppRunnerException) { + setErrorText(e.awsErrorDetails()?.errorMessage() ?: message("apprunner.creation.failed")) + } else { + setErrorText(message("apprunner.creation.failed")) + } + LOG.error(e) { "Exception thrown while creating AppRunner Service" } + ApprunnerTelemetry.createService(project, Result.Failed, deploymentTypeFromPanel(panel)) + } finally { + isOKActionEnabled = true + setOKButtonText(message("general.create_button")) + } + } + } + + internal fun buildRequest(panel: CreationPanel): CreateServiceRequest { + val request = CreateServiceRequest.builder() + .serviceName(panel.name) + .instanceConfiguration { + it.cpu(panel.cpu) + it.memory(panel.memory) + } + when { + panel.ecr.isSelected -> { + request.sourceConfiguration { source -> + source.autoDeploymentsEnabled(panel.automaticDeployment.isSelected) + source.imageRepository { image -> + image.imageRepositoryType(ImageRepositoryType.ECR) + image.imageIdentifier(panel.containerUri) + image.imageConfiguration { + it.port(panel.port.toString()) + it.runtimeEnvironmentVariables(panel.environmentVariables.envVars) + panel.startCommand?.let { s -> it.startCommand(s) } + } + } + source.authenticationConfiguration { + it.accessRoleArn(panel.ecrPolicy.selected()?.arn) + } + } + } + panel.ecrPublic.isSelected -> { + request.sourceConfiguration { source -> + source.imageRepository { image -> + image.imageRepositoryType(ImageRepositoryType.ECR_PUBLIC) + image.imageIdentifier(panel.containerUri) + image.imageConfiguration { + it.port(panel.port.toString()) + it.runtimeEnvironmentVariables(panel.environmentVariables.envVars) + panel.startCommand?.let { s -> it.startCommand(s) } + } + } + } + } + panel.repo.isSelected -> { + request.sourceConfiguration { source -> + source.autoDeploymentsEnabled(panel.automaticDeployment.isSelected) + source.codeRepository { repo -> + repo.codeConfiguration { + if (panel.manualDeployment.isSelected) { + it.configurationSource(ConfigurationSource.REPOSITORY) + } else { + it.configurationSource(ConfigurationSource.API) + it.codeConfigurationValues { codeConfig -> + codeConfig.runtime(panel.runtime) + codeConfig.port(panel.port.toString()) + codeConfig.buildCommand(panel.buildCommand) + codeConfig.startCommand(panel.startCommand) + codeConfig.runtimeEnvironmentVariables(panel.environmentVariables.envVars) + } + } + } + repo.repositoryUrl(panel.repository) + repo.sourceCodeVersion { + it.type(SourceCodeVersionType.BRANCH) + it.value(panel.branch) + } + } + source.authenticationConfiguration { it.connectionArn(panel.connection.selected()?.connectionArn()) } + } + } + else -> throw IllegalStateException("AppRunner creation dialog had no type selected!") + } + return request.build() + } + + private fun deploymentTypeFromPanel(panel: CreationPanel) = when { + panel.repo.isSelected -> AppRunnerServiceSource.Repository + panel.ecrPublic.isSelected -> AppRunnerServiceSource.EcrPublic + panel.ecr.isSelected -> AppRunnerServiceSource.Ecr + else -> AppRunnerServiceSource.Unknown + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/ui/CreationPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/ui/CreationPanel.kt new file mode 100644 index 0000000000..f678c96e4a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/apprunner/ui/CreationPanel.kt @@ -0,0 +1,273 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.apprunner.ui + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogPanel +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBRadioButton +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.bindIntText +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.layout.or +import com.intellij.ui.layout.selected +import software.amazon.awssdk.services.apprunner.model.ConnectionSummary +import software.amazon.awssdk.services.apprunner.model.Runtime +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.services.apprunner.resources.APPRUNNER_ECR_DEFAULT_ROLE_NAME +import software.aws.toolkits.jetbrains.services.apprunner.resources.APPRUNNER_ECR_MANAGED_POLICY +import software.aws.toolkits.jetbrains.services.apprunner.resources.APPRUNNER_SERVICE_ROLE_URI +import software.aws.toolkits.jetbrains.services.apprunner.resources.AppRunnerResources +import software.aws.toolkits.jetbrains.services.iam.CreateIamServiceRoleDialog +import software.aws.toolkits.jetbrains.services.iam.IamResources +import software.aws.toolkits.jetbrains.services.iam.IamRole +import software.aws.toolkits.jetbrains.ui.KeyValueTextField +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.jetbrains.utils.toHumanReadable +import software.aws.toolkits.jetbrains.utils.ui.installOnParent +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message +import javax.swing.DefaultComboBoxModel + +class CreationPanel(private val project: Project, ecrUri: String? = null) { + internal companion object { + // These are not in the model so we unfortunately have to keep our own list + val memoryValues = listOf("2 GB", "3 GB", "4 GB") + val cpuValues = listOf("1 vCPU", "2 vCPU") + } + + val environmentVariables = KeyValueTextField() + lateinit var connection: ResourceSelector + private set + lateinit var ecrPolicy: ResourceSelector + private set + var name: String = "" + private set + var cpu: String = cpuValues.first() + private set + var memory: String = memoryValues.first() + private set + var port: Int = 80 + private set + var containerUri: String = ecrUri ?: "" + private set + var startCommand: String? = null + internal set(value) { + field = value?.ifBlank { null } + } + var repository: String = "" + internal set(value) { + // Remove the trailing slash or else it will show up in the service with two // + field = value.trim().removeSuffix("/") + } + var branch: String = "" + internal set + var runtime: Runtime? = null + internal set + var buildCommand: String = "" + private set + + internal val ecr = JBRadioButton(message("apprunner.creation.panel.source.ecr"), true) + internal val ecrPublic = JBRadioButton(message("apprunner.creation.panel.source.ecr_public"), false) + internal val repo = JBRadioButton(message("apprunner.creation.panel.source.repository"), false) + + internal val automaticDeployment = JBRadioButton(message("apprunner.creation.panel.deployment.automatic"), true).apply { + toolTipText = message("apprunner.creation.panel.deployment.automatic.tooltip") + } + internal val manualDeployment = JBRadioButton(message("apprunner.creation.panel.deployment.manual"), false).apply { + toolTipText = message("apprunner.creation.panel.deployment.manual.tooltip") + } + + internal val repoConfigFromSettings = JBRadioButton(message("apprunner.creation.panel.repository.api"), true).apply { + toolTipText = message("apprunner.creation.panel.repository.api.tooltip") + } + internal val repoConfigFromFile = JBRadioButton(message("apprunner.creation.panel.repository.file"), false).apply { + toolTipText = message("apprunner.creation.panel.repository.file.tooltip") + } + + val imagePanel = panel { + row(message("apprunner.creation.panel.image.uri")) { + textField() + .apply { component.emptyText.text = "111111111111.dkr.ecr.us-east-1.amazonaws.com/name:tag" } + .bindText(::containerUri) + .errorOnApply(message("apprunner.creation.panel.image.uri.missing")) { it.text.isBlank() } + .align(AlignX.FILL) + } + + row(message("apprunner.creation.panel.start_command")) { + textField() + .apply { component.toolTipText = message("apprunner.creation.panel.start_command.image.tooltip") } + .bindText({ startCommand ?: "" }, { startCommand = it }) + .align(AlignX.FILL) + } + + row(message("apprunner.creation.panel.port")) { + intTextField(range = IntRange(1, 65535)).bindIntText(::port) + .align(AlignX.FILL) + } + + row { + label(message("apprunner.creation.panel.image.access_role")) + .apply { + component.toolTipText = message("apprunner.creation.panel.image.access_role.tooltip") + } + .visibleIf(ecr.selected) + ecrPolicy = ResourceSelector.builder() + .resource { IamResources.LIST_ALL } + .awsConnection(project) + .build() + .apply { + selectedItem { it.name == APPRUNNER_ECR_DEFAULT_ROLE_NAME } + toolTipText = message("apprunner.creation.panel.image.access_role.tooltip") + } + cell(ecrPolicy) + .errorOnApply(message("apprunner.creation.panel.image.access_role.missing")) { it.selected() == null && ecr.isSelected } + .visibleIf(ecr.selected) + .columns(40) + button(message("general.create_button")) { + val iamRoleDialog = CreateIamServiceRoleDialog( + project, + project.awsClient(), + APPRUNNER_SERVICE_ROLE_URI, + APPRUNNER_ECR_MANAGED_POLICY, + APPRUNNER_ECR_DEFAULT_ROLE_NAME + ) + if (iamRoleDialog.showAndGet()) { + iamRoleDialog.name.let { newRole -> + ecrPolicy.reload(forceFetch = true) + ecrPolicy.selectedItem { role -> role.name == newRole } + } + } + }.visibleIf(ecr.selected) + } + row { + label(message("apprunner.creation.panel.cpu")) + comboBox(DefaultComboBoxModel(CreationPanel.cpuValues.toTypedArray())).bindItem({ cpu }, { it?.let { cpu = it } }).errorOnApply( + message("apprunner.creation.panel.cpu.missing") + ) { it.selected() == null } + label(message("apprunner.creation.panel.memory")) + comboBox(DefaultComboBoxModel(CreationPanel.memoryValues.toTypedArray())).bindItem({ memory }, { it?.let { memory = it } }) + .errorOnApply(message("apprunner.creation.panel.memory.missing")) { it.selected() == null } + } + } + + val repoSettings = panel { + row(message("apprunner.creation.panel.repository.runtime")) { + comboBox(DefaultComboBoxModel(Runtime.knownValues().toTypedArray())).bindItem({ runtime }, { runtime = it }) + .apply { + component.toolTipText = message("apprunner.creation.panel.repository.runtime.tooltip") + component.renderer = SimpleListCellRenderer.create("") { it?.toString()?.toHumanReadable() } + } + .errorOnApply(message("apprunner.creation.panel.repository.runtime.missing")) { it.selected() == null } + .columns(35) + + label(message("apprunner.creation.panel.port")) + intTextField(range = IntRange(1, 65535)).bindIntText(::port) + } + row(message("apprunner.creation.panel.repository.build_command")) { + textField().bindText(::buildCommand) + .apply { component.toolTipText = message("apprunner.creation.panel.repository.build_command.tooltip") } + .errorOnApply(message("apprunner.creation.panel.repository.build_command.missing")) { it.text.isBlank() } + .resizableColumn() + .align(Align.FILL) + } + row(message("apprunner.creation.panel.start_command")) { + textField().bindText({ startCommand ?: "" }, { startCommand = it }) + .apply { component.toolTipText = message("apprunner.creation.panel.start_command.repo.tooltip") } + .errorOnApply(message("apprunner.creation.panel.start_command.missing")) { it.text.isBlank() } + .resizableColumn() + .align(Align.FILL) + }.bottomGap(BottomGap.MEDIUM) + } + + val repoPanel = panel { + row { + label(message("apprunner.creation.panel.repository.connection")) + connection = ResourceSelector.builder() + .resource { AppRunnerResources.LIST_CONNECTIONS } + .customRenderer(SimpleListCellRenderer.create("") { "${it.connectionName()} (${it.providerTypeAsString().toHumanReadable()})" }) + .awsConnection(project) + .build() + cell(connection) + .errorOnApply(message("apprunner.creation.panel.repository.connection.missing")) { it.isLoading || it.selected() == null } + .resizableColumn() + .align(Align.FILL) + }.contextHelp(message("apprunner.creation.panel.repository.connection.help")) + row { + label(message("apprunner.creation.panel.repository.url")).apply { + component.toolTipText = message("apprunner.creation.panel.repository.url.tooltip") + } + textField().bindText(::repository).columns(20) + label(message("apprunner.creation.panel.repository.branch")) + textField().bindText(::branch).columns(15) + } + buttonsGroup { + row(message("apprunner.creation.panel.repository.configuration")) { + cell(repoConfigFromSettings) + cell(repoConfigFromFile) + } + } + + row { + cell(repoSettings) + .installOnParent { repoConfigFromSettings.isSelected } + .visibleIf(repoConfigFromSettings.selected) + } + row { + label(message("apprunner.creation.panel.cpu")) + comboBox(DefaultComboBoxModel(CreationPanel.cpuValues.toTypedArray())).bindItem({ cpu }, { it?.let { cpu = it } }) + .errorOnApply(message("apprunner.creation.panel.cpu.missing")) { it.selected() == null } + .resizableColumn().align(Align.FILL) + label(message("apprunner.creation.panel.memory")) + comboBox(DefaultComboBoxModel(CreationPanel.memoryValues.toTypedArray())).bindItem({ memory }, { it?.let { memory = it } }) + .errorOnApply(message("apprunner.creation.panel.memory.missing")) { it.selected() == null } + } + } + + val component: DialogPanel = panel { + row(message("apprunner.creation.panel.name")) { + textField().bindText(::name) + .errorOnApply(message("apprunner.creation.panel.name.missing")) { it.text.isNullOrBlank() } + .columns(40) + } + buttonsGroup { + row(message("apprunner.creation.panel.source")) { + cell(ecr) + cell(ecrPublic) + cell(repo) + } + } + + buttonsGroup { + // ECR public disables selecting deployment options + row(message("apprunner.creation.panel.deployment")) { + cell(manualDeployment) + cell(automaticDeployment) + } + }.visibleIf(ecr.selected.or(repo.selected)) + + row { + cell(repoPanel) + .installOnParent { repo.isSelected } + .visibleIf(repo.selected) + .resizableColumn() + .align(Align.FILL) + + cell(imagePanel) + .installOnParent { ecr.isSelected || ecrPublic.isSelected } + .visibleIf(ecr.selected.or(ecrPublic.selected)) + .resizableColumn() + .align(Align.FILL) + } + row(message("apprunner.creation.panel.environment")) { + cell(environmentVariables) + .resizableColumn().align(Align.FILL) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/awsq/toolwindow/AppSource.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/awsq/toolwindow/AppSource.kt new file mode 100644 index 0000000000..a1f2dc94bd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/awsq/toolwindow/AppSource.kt @@ -0,0 +1,13 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.awsq.toolwindow + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppFactory + +class AppSource { + private val extensionPointName = ExtensionPointName.create("aws.toolkit.amazonq.appFactory") + fun getApps(project: Project) = extensionPointName.extensionList.map { it.createApp(project) } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsClientCustomizer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsClientCustomizer.kt new file mode 100644 index 0000000000..5e9ed071af --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsClientCustomizer.kt @@ -0,0 +1,84 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import com.intellij.openapi.util.registry.Registry +import com.intellij.util.text.nullize +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider +import software.amazon.awssdk.awscore.client.builder.AwsClientBuilder +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.core.interceptor.Context +import software.amazon.awssdk.core.interceptor.ExecutionAttributes +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClientBuilder +import software.amazon.awssdk.services.codecatalyst.model.CodeCatalystException +import software.aws.toolkits.core.ToolkitClientCustomizer +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.core.utils.warn +import java.net.URI + +class CawsClientCustomizer : ToolkitClientCustomizer { + override fun customize( + credentialProvider: AwsCredentialsProvider?, + tokenProvider: SdkTokenProvider?, + regionId: String, + builder: AwsClientBuilder<*, *>, + clientOverrideConfiguration: ClientOverrideConfiguration.Builder + ) { + if (builder is CodeCatalystClientBuilder) { + val endpointOverride = Registry.get("aws.codecatalyst.endpoint").asString().nullize(true) + if (endpointOverride != null) { + tryOrNull { + val uri = URI.create(endpointOverride) + if (uri.scheme == null || uri.authority == null) { + null + } else { + uri + } + }?.let { + builder.endpointOverride(it) + } + } + + clientOverrideConfiguration.addExecutionInterceptor(object : ExecutionInterceptor { + override fun onExecutionFailure(context: Context.FailedExecution, executionAttributes: ExecutionAttributes) { + val exception = context.exception() + if (exception is CodeCatalystException) { + context.httpResponse().ifPresent { response -> + response.firstMatchingHeader("x-amzn-served-from").ifPresent { + LOG.warn { "Hit service exception. ${exception.requestId()} was served from $it" } + } + + LOG.debug { + val headers = response.headers() + .filter { header -> relevantHeaders.any { it.equals(header.key, ignoreCase = true) } } + + "Additional headers for ${exception.requestId()}: $headers" + } + } + } + } + }) + } + } + + companion object { + private val LOG = getLogger() + private val relevantHeaders = listOf( + "x-amz-apigw-id", + "x-amz-cf-id", + "x-amz-cf-pop", + "x-amzn-remapped-content-length", + "x-amzn-remapped-x-amzn-requestid", + "x-amzn-requestid", + "x-amzn-served-from", + "x-amzn-trace-id", + "x-cache", + "x-request-id" + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsClientUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsClientUtils.kt new file mode 100644 index 0000000000..48b8fee62e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsClientUtils.kt @@ -0,0 +1,16 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.amazon.awssdk.services.codecatalyst.model.ListProjectsRequest + +fun CodeCatalystClient.listAccessibleProjectsPaginator(listProjectsRequest: (ListProjectsRequest.Builder) -> Unit) = listProjectsPaginator { + it.filters({ filter -> + filter.key("hasAccessTo") + filter.values("true") + }) + + listProjectsRequest(it) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsCloneDialogComponent.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsCloneDialogComponent.kt new file mode 100644 index 0000000000..8e0ab2b283 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsCloneDialogComponent.kt @@ -0,0 +1,200 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import com.intellij.collaboration.ui.CollaborationToolsUIUtil +import com.intellij.dvcs.repo.ClonePathProvider +import com.intellij.dvcs.ui.CloneDvcsValidationUtils +import com.intellij.dvcs.ui.SelectChildTextFieldWithBrowseButton +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.vcs.CheckoutProvider +import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtensionComponent +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.SearchTextField +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBList +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.layout.listCellRenderer +import com.intellij.util.ui.StatusText +import com.intellij.util.ui.UIUtil +import git4idea.checkout.GitCheckoutProvider +import git4idea.commands.Git +import git4idea.remote.GitRememberedInputs +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.utils.createParentDirectories +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.AwsResourceCache +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.credentials.sono.lazilyGetUserId +import software.aws.toolkits.jetbrains.services.caws.pat.generateAndStorePat +import software.aws.toolkits.jetbrains.services.caws.pat.patExists +import software.aws.toolkits.jetbrains.ui.connection.SonoLoginOverlay +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodecatalystTelemetry +import java.net.URI +import java.nio.file.Paths +import javax.swing.JComponent +import software.aws.toolkits.telemetry.Result as TelemetryResult + +class CawsCloneDialogComponent( + private val project: Project, + private val modalityState: ModalityState +) : VcsCloneDialogExtensionComponent() { + private lateinit var client: CodeCatalystClient + private lateinit var cawsConnectionSettings: ClientConnectionSettings<*> + + private val repoListModel = CollectionComboBoxModel() + private val repoList = JBList(repoListModel).apply { + setPaintBusy(true) + setEmptyText(message("loading_resource.loading")) + cellRenderer = listCellRenderer { value, _, _ -> text = value.presentableString } + + addListSelectionListener { _ -> + selectedValue?.let { + browseButton.trySetChildPath(it.name) + dialogStateListener.onOkActionEnabled(true) + } + } + } + + private val browseButton = SelectChildTextFieldWithBrowseButton( + ClonePathProvider.defaultParentDirectoryPath(project, GitRememberedInputs.getInstance()) + ).apply { + val chooserDescriptor = FileChooserDescriptorFactory.createSingleFolderDescriptor() + addBrowseFolderListener(message("caws.clone_dialog_title"), message("caws.clone_dialog_description"), project, chooserDescriptor) + } + + override fun doClone(checkoutListener: CheckoutProvider.Listener) { + val repository = repoList.selectedValue ?: throw RuntimeException("Repository was not selected") + ApplicationManager.getApplication().executeOnPooledThread { + val userId = lazilyGetUserId() + try { + // TODO: show progress bar here so it doesn't look like we're stuck + val url = AwsResourceCache.getInstance().getResource(CawsResources.cloneUrls(repository), cawsConnectionSettings).toCompletableFuture().get() + + val user = URI(url).userInfo.trim('@') + if (!patExists(user)) { + // TODO: prompt if this is OK before generating and storing + // TODO: we should check that the current client's "identity" matches the desired user, but the REST client doesn't return + // that information like the graphql endpoint does + generateAndStorePat(client, user) + } + + val destination = Paths.get(browseButton.text).toAbsolutePath() + destination.createParentDirectories() + val parentDirectory = destination.parent + val parentDirectoryVfs = VfsUtil.findFile(parentDirectory, true) + ?: throw RuntimeException("VFS could not find specified directory: $parentDirectory") + val directoryName = destination.fileName.toString() + runInEdt { + GitCheckoutProvider.clone(project, Git.getInstance(), checkoutListener, parentDirectoryVfs, url, directoryName, parentDirectory.toString()) + } + // GitCheckoutProvider.clone is async, but assume any issues is with JB instead of us + CodecatalystTelemetry.localClone(project = null, userId = userId, result = TelemetryResult.Succeeded) + } catch (e: Exception) { + CodecatalystTelemetry.localClone(project = null, userId = userId, result = TelemetryResult.Failed) + throw e + } + } + } + + override fun doValidateAll(): List { + if (repoList.selectedValue == null) { + return listOf(ValidationInfo(message("caws.workspace.details.repository_validation"), repoList)) + } + + val directoryValidation = CloneDvcsValidationUtils.checkDirectory(browseButton.text, browseButton.textField) + if (directoryValidation != null) { + return listOf(directoryValidation) + } + + return emptyList() + } + + private fun drawPanel(connectionSettings: ClientConnectionSettings<*>): JComponent { + cawsConnectionSettings = connectionSettings + client = AwsClientManager.getInstance().getClient(cawsConnectionSettings) + + val panel = panel { + row { + val searchField = SearchTextField(false) + CollaborationToolsUIUtil.attachSearch(repoList, searchField) { + it.presentableString + } + val label = CawsLetterBadge(connectionSettings) + cell(searchField.textEditor).resizableColumn().align(Align.FILL) + cell(label).align(AlignX.RIGHT) + } + + row { + scrollCell(repoList).resizableColumn().align(Align.FILL) + }.resizableRow() + + row(message("caws.clone_dialog_directory")) { + cell(browseButton).align(Align.FILL) + } + } + + disposableCoroutineScope(this).launch { + try { + val cache = AwsResourceCache.getInstance() + val projects = cache.getResource(CawsResources.ALL_PROJECTS, cawsConnectionSettings).await() + projects.forEach { cawsProject -> + repoListModel.add(cache.getResource(CawsResources.codeRepositories(cawsProject), cawsConnectionSettings).await()) + } + + with(getCoroutineUiContext()) { + repoList.setEmptyText(StatusText.getDefaultEmptyText()) + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to load repositories" } + with(getCoroutineUiContext()) { + val emptyText = repoList.emptyText + emptyText.clear() + emptyText.appendLine( + AllIcons.General.Error, + message("caws.clone_dialog_repository_loading_error"), + SimpleTextAttributes.REGULAR_ATTRIBUTES, + null + ) + } + } finally { + with(getCoroutineUiContext()) { + repoList.setPaintBusy(false) + } + } + } + + return panel + } + + override fun getView(): JComponent = + SonoLoginOverlay(project, this) { drawPanel(it) } + .apply { + border = IdeBorderFactory.createEmptyBorder(UIUtil.PANEL_REGULAR_INSETS) + } + + override fun onComponentSelected() { + } + + companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsCloneDialogExtension.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsCloneDialogExtension.kt new file mode 100644 index 0000000000..a9da9561dc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsCloneDialogExtension.kt @@ -0,0 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtension +import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogExtensionComponent +import icons.AwsIcons +import software.aws.toolkits.resources.message +import javax.swing.Icon + +class CawsCloneDialogExtension : VcsCloneDialogExtension { + override fun createMainComponent(project: Project): VcsCloneDialogExtensionComponent { + throw RuntimeException("Should never be called") + } + + override fun createMainComponent(project: Project, modalityState: ModalityState): VcsCloneDialogExtensionComponent = + CawsCloneDialogComponent(project, modalityState) + + override fun getIcon(): Icon = AwsIcons.Logos.CODE_CATALYST_MEDIUM + + override fun getName(): String = message("caws.title") +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsCodeRepository.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsCodeRepository.kt new file mode 100644 index 0000000000..99781dc5f4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsCodeRepository.kt @@ -0,0 +1,12 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +data class CawsCodeRepository( + val space: String, + val project: String, + val name: String +) { + val presentableString by lazy { "$space/$project/$name" } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsConstants.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsConstants.kt new file mode 100644 index 0000000000..77ef61d168 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsConstants.kt @@ -0,0 +1,16 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +object CawsConstants { + const val CAWS_ENV_PROJECT_DIR = "/projects" + const val CAWS_ENV_IDE_BACKEND_DIR = "/aws/mde/ide-runtimes/jetbrains/runtime/" + const val DEFAULT_CAWS_ENV_API_ENDPOINT = "http://127.0.0.1:1339" + const val CAWS_ENV_API_ENDPOINT = "__MDE_ENVIRONMENT_API" + const val CAWS_ENV_AUTH_TOKEN_VAR = "__MDE_ENV_API_AUTHORIZATION_TOKEN" + const val CAWS_ENV_ORG_NAME_VAR = "__DEV_ENVIRONMENT_ORGANIZATION_NAME" + const val CAWS_ENV_PROJECT_NAME_VAR = "__DEV_ENVIRONMENT_PROJECT_NAME" + const val CAWS_ENV_ID_VAR = "__DEV_ENVIRONMENT_ID" + const val DEVFILE_YAML_NAME = "devfile.yaml" +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsEndpoints.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsEndpoints.kt new file mode 100644 index 0000000000..8e70ee2264 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsEndpoints.kt @@ -0,0 +1,42 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import software.aws.toolkits.core.utils.tryOrNull +import java.net.URI + +object CawsEndpoints { + const val CAWS_DOCS = "https://docs.aws.amazon.com/codecatalyst/latest/userguide/welcome.html" + const val CAWS_DEV_ENV_MARKETING = "https://codecatalyst.aws/explore/dev-environments" + const val TOOLKIT_CAWS_DOCS = "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/codecatalyst-service.html" + const val CAWS_SPACES_DOC = "https://docs.aws.amazon.com/codecatalyst/latest/userguide/spaces.html" + + private const val CAWS_PROD_CONSOLE_BASE = "https://codecatalyst.aws/" + + private val CAWS_PROD_GIT_PATTERN = """git\..*?\.codecatalyst.aws""".toRegex(RegexOption.IGNORE_CASE) + private val CAWS_GAMMA_GIT_PATTERN = """git\..*?\.aws.dev""".toRegex(RegexOption.IGNORE_CASE) + fun isCawsGit(url: String): Boolean { + val uri = tryOrNull { + URI.create(url) + } ?: return false + + return uri.host?.let { + it.matches(CAWS_PROD_GIT_PATTERN) || it.matches(CAWS_GAMMA_GIT_PATTERN) + } ?: false + } + + object ConsoleFactory { + fun baseUrl() = CAWS_PROD_CONSOLE_BASE + private fun space(space: String) = baseUrl() + "spaces/$space/" + private fun project(project: CawsProject) = space(project.space) + "projects/${project.project}" + + fun marketing() = baseUrl() + "explore" + fun pricing() = baseUrl() + "explore/pricing" + fun userHome() = baseUrl() + "user/view" + + fun devWorkspaceHome(project: CawsProject) = project(project) + "/dev-environments" + fun projectHome(project: CawsProject) = project(project) + "/view" + fun repositoryHome(project: CawsProject) = project(project) + "/source-repositories" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsHttpAuthProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsHttpAuthProvider.kt new file mode 100644 index 0000000000..f1da860952 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsHttpAuthProvider.kt @@ -0,0 +1,53 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.MessageDialogBuilder +import com.intellij.util.AuthData +import git4idea.remote.GitHttpAuthDataProvider +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.sono.SonoCredentialManager +import software.aws.toolkits.jetbrains.services.caws.pat.generateAndStorePat +import software.aws.toolkits.jetbrains.services.caws.pat.getPat +import software.aws.toolkits.jetbrains.utils.computeOnEdt +import software.aws.toolkits.resources.message +import java.time.Instant +import java.util.concurrent.atomic.AtomicLong + +class CawsHttpAuthProvider : GitHttpAuthDataProvider { + // globally only offer to make a new PAT at most once every 15s + private val lastRefreshPrompt = AtomicLong(0) + + // framework will actually call this twice on first reject + override fun forgetPassword(project: Project, url: String, authData: AuthData) { + synchronized(this) { + val now = Instant.now().epochSecond + if (now - lastRefreshPrompt.getAndSet(now) < 15) { + return + } + } + + val yesNo = computeOnEdt(ModalityState.defaultModalityState()) { + MessageDialogBuilder.yesNo( + message("caws.clone.invalid_pat"), + message("caws.clone.invalid_pat.help") + ) + .ask(project) + } + + if (yesNo) { + generateAndStorePat(SonoCredentialManager.getInstance(project).getSettingsAndPromptAuth().awsClient(), authData.login) + } + } + + override fun getAuthData(project: Project, url: String, login: String): AuthData? { + if (CawsEndpoints.isCawsGit(url)) { + return getPat(login)?.let { AuthData(login, it.getPasswordAsString()) } + } + + return null + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsLetterBadge.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsLetterBadge.kt new file mode 100644 index 0000000000..251002c841 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsLetterBadge.kt @@ -0,0 +1,127 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.MacUIUtil +import org.jdesktop.swingx.graphics.ColorUtilities +import software.amazon.awssdk.services.codecatalyst.model.CodeCatalystException +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import java.awt.Color +import java.awt.Dimension +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.geom.Ellipse2D +import javax.swing.JLabel +import javax.swing.SwingConstants + +class CawsLetterBadge(connectionSettings: ClientConnectionSettings<*>) : JLabel() { + private val displayName: String + init { + val (displayName, email) = try { + AwsResourceCache.getInstance().getResourceNow(CawsResources.PERSON, connectionSettings).let { + it.displayName() to it.primaryEmail().email() + } + } catch (e: CodeCatalystException) { + LOG.warn(e) { "Exception occurred while fetching user email" } + "" to "" + } + this.displayName = displayName + + text = getInitials(displayName) + font = JBFont.h3().asBold() + foreground = JBColor(Color.WHITE, Color.BLACK) + horizontalAlignment = SwingConstants.CENTER + isOpaque = false + preferredSize = Dimension(32, 32) + + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent?) { + JBPopupFactory.getInstance() + .createActionGroupPopup( + email, + ActionManager.getInstance().getAction("aws.toolkit.sono.id.actions") as ActionGroup, + DataManager.getInstance().getDataContext(this@CawsLetterBadge), + null, + false + ) + .showUnderneathOf(this@CawsLetterBadge) + } + }) + } + + private val color by lazy { + color(displayName) + } + + override fun paintComponent(g: Graphics) { + g as Graphics2D + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g.setRenderingHint( + RenderingHints.KEY_STROKE_CONTROL, + if (MacUIUtil.USE_QUARTZ) RenderingHints.VALUE_STROKE_PURE else RenderingHints.VALUE_STROKE_NORMALIZE + ) + g.color = color + + // we can probably do math to figure out the center of the letter but that is too much work right now + val circle = Ellipse2D.Double(0.0, 0.0, width.toDouble(), height.toDouble()) + g.fill(circle) + + ui.paint(g, this) + } + + companion object { + private val LOG = getLogger() + + // shamelessly stolen from Avatar defined in CAWSUIComponents + private val nameReplaceRegex = Regex("[&/\\#,+()$~%.'\":*?<>{}0-9]") + private fun getInitials(name: String): String { + val names = name.replace(nameReplaceRegex, "") + .split(" ") + .filter { it.length > 0 } + + if (names.size == 0) { + return "${name.firstOrNull() ?: ""}" + } + val firstInitial = names.first().first() + if (names.size == 1) { + return "$firstInitial" + } + val lastInitial = names.last().first() + + return "$firstInitial$lastInitial" + } + + private fun hue(string: String): Int { + val hash = string.fold(7) { hash, char -> + (hash shl 5) - hash + char.toString().codePointAt(0) + } + + return (Math.abs(hash) % 36) * 10 + 10 + } + + private const val saturation = 0.65f + private const val luminosity = 0.35f + private const val luminosity_dark = 0.75f + private fun color(string: String): Color { + val hue = hue(string) / 360f + + return JBColor( + ColorUtilities.HSLtoRGB(hue, saturation, luminosity), + ColorUtilities.HSLtoRGB(hue, saturation, luminosity_dark) + ) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsParameterDescriptions.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsParameterDescriptions.kt new file mode 100644 index 0000000000..ba063052ea --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsParameterDescriptions.kt @@ -0,0 +1,71 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.amazon.awssdk.services.codecatalyst.model.InstanceType + +private val descriptions: ParameterDescriptions by lazy { + ParameterDescriptions::class.java.getResourceAsStream("parameterDescriptions.json")?.use { + jacksonObjectMapper() + .enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(it) + } ?: throw IllegalStateException("Failed to locate parameterDescriptions.json") +} + +fun loadParameterDescriptions(): ParameterDescriptions = descriptions + +data class ParameterDescriptions( + @JsonProperty("environment") + val environmentParameters: EnvironmentParameters +) + +data class EnvironmentParameters( + @JsonProperty("instanceType") + val instanceTypes: Map, + val persistentStorageSize: List +) + +data class InstanceInfo( + @JsonProperty("vcpus") + val vCpus: Int, + val ram: Ram, + val arch: String +) + +data class Ram( + val value: Int, + val unit: String +) + +fun isSubscriptionFreeTier( + client: CodeCatalystClient, + space: String? +): Boolean { + val subscriptionTier = if (space != null) { + client.getSubscription { + it.spaceName(space) + }.subscriptionType() + } else { + return true + } + + return subscriptionTier == "FREE" +} + +fun InstanceType.isSupportedInFreeTier() = + when (this) { + InstanceType.DEV_STANDARD1_SMALL -> true + else -> false + } + +fun Int.isSupportedInFreeTier() = + when (this) { + 16 -> true + else -> false + } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsProject.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsProject.kt new file mode 100644 index 0000000000..ebad6c2b5b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsProject.kt @@ -0,0 +1,9 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +data class CawsProject( + val space: String, + val project: String +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsProjectListRenderer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsProjectListRenderer.kt new file mode 100644 index 0000000000..0072d39b7d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsProjectListRenderer.kt @@ -0,0 +1,79 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import com.intellij.ui.CellRendererPanel +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.SeparatorWithText +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.components.panels.OpaquePanel +import java.awt.BorderLayout +import java.awt.Component +import javax.accessibility.AccessibleContext +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.border.Border + +class CawsProjectListRenderer(private val loadingRenderer: ListCellRenderer) : ColoredListCellRenderer() { + override fun getListCellRendererComponent(list: JList?, value: CawsProject?, index: Int, selected: Boolean, hasFocus: Boolean): Component { + val c = super.getListCellRendererComponent(list, value, index, selected, hasFocus) + list ?: return c + if (list.model.size == 0) { + // probably still loading + return loadingRenderer.getListCellRendererComponent(list, value, index, selected, hasFocus) + } + + val component = c as? SimpleColoredComponent ?: return c + value ?: return c + + if (index == -1) { + // if not a popup + return c + } + + val panel = object : CellRendererPanel() { + init { + layout = BorderLayout() + } + + private val myContext: AccessibleContext = component.getAccessibleContext() + override fun getAccessibleContext(): AccessibleContext { + return myContext + } + + override fun setBorder(border: Border?) { + // we do not want to outer UI to add a border to that JPanel + // see com.intellij.ide.ui.laf.darcula.ui.DarculaComboBoxUI.CustomComboPopup#customizeListRendererComponent + component.border = border + } + } + + component.isOpaque = true + panel.isOpaque = true + panel.background = list.background + panel.add(component, BorderLayout.CENTER) + + if (index == 0 || list.model.getElementAt(index - 0).space != value.space) { + val separator = SeparatorWithText() + separator.caption = value.space + val wrapper = OpaquePanel(BorderLayout()) + wrapper.add(separator, BorderLayout.CENTER) + wrapper.background = list.background + + panel.add(wrapper, BorderLayout.NORTH) + } + + return panel + } + + override fun customizeCellRenderer(list: JList, value: CawsProject?, index: Int, selected: Boolean, hasFocus: Boolean) { + value ?: return + if (index == -1) { + // if not a popup + append("${value.space} - ${value.project}") + } else { + append(value.project) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsResources.kt new file mode 100644 index 0000000000..667ea00462 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsResources.kt @@ -0,0 +1,63 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource +import java.time.Duration + +object CawsResources { + val ID = ClientBackedCachedResource(CodeCatalystClient::class, "caws.person.id", Duration.ofDays(1)) { + val session = verifySession {} + + session.identity() + } + + val PERSON = ClientBackedCachedResource(CodeCatalystClient::class, "caws.person", Duration.ofDays(1)) { + val session = verifySession {} + + getUserDetails { it.id(session.identity()) } + } + + val ALL_SPACES = ClientBackedCachedResource(CodeCatalystClient::class, "caws.orgs", Duration.ofDays(1)) { + listSpacesPaginator {} + .items() + .map { it.name() } + } + + val ALL_PROJECTS = ClientBackedCachedResource(CodeCatalystClient::class, "caws.projects", Duration.ofDays(1)) { + val spaces = listSpacesPaginator {} + .items() + .map { it.name() } + + spaces.flatMap { space -> + listAccessibleProjectsPaginator { it.spaceName(space) } + .items() + .map { CawsProject(space = space, project = it.name()) } + } + } + + fun codeRepositories(cawsProject: CawsProject) = + ClientBackedCachedResource(CodeCatalystClient::class, "caws.codeRepos.${cawsProject.space}.${cawsProject.project}", Duration.ofDays(1)) { + listSourceRepositoriesPaginator { + it.spaceName(cawsProject.space) + it.projectName(cawsProject.project) + }.items().map { + CawsCodeRepository(cawsProject.space, cawsProject.project, it.name()) + } + } + + fun cloneUrls(cawsCodeRepository: CawsCodeRepository) = + ClientBackedCachedResource( + CodeCatalystClient::class, + "caws.codeRepos.${cawsCodeRepository.space}.${cawsCodeRepository.project}.${cawsCodeRepository.name}.cloneUrls", + Duration.ofDays(1) + ) { + getSourceRepositoryCloneUrls { + it.spaceName(cawsCodeRepository.space) + it.projectName(cawsCodeRepository.project) + it.sourceRepositoryName(cawsCodeRepository.name) + }.https() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsSpaceProjectInfo.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsSpaceProjectInfo.kt new file mode 100644 index 0000000000..35fa6a224d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/CawsSpaceProjectInfo.kt @@ -0,0 +1,53 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.caws + +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.StatusBarWidgetFactory +import com.intellij.util.Consumer +import java.awt.Component +import java.awt.event.MouseEvent + +private const val WIDGET_ID = "CawsSpaceProjectInfo" + +class CawsStatusBarInstaller : StatusBarWidgetFactory { + private val spaceName: String? = System.getenv(CawsConstants.CAWS_ENV_ORG_NAME_VAR) + private val projectName: String? = System.getenv(CawsConstants.CAWS_ENV_PROJECT_NAME_VAR) + + override fun getId(): String = WIDGET_ID + + override fun getDisplayName(): String = "$spaceName/$projectName" + + override fun isAvailable(project: Project): Boolean = spaceName != null && projectName != null + + override fun createWidget(project: Project): StatusBarWidget = CawsSpaceProjectInfo(spaceName, projectName) + + override fun disposeWidget(widget: StatusBarWidget) { + Disposer.dispose(widget) + } + + override fun canBeEnabledOn(statusBar: StatusBar): Boolean = true +} + +private class CawsSpaceProjectInfo(val spaceName: String?, val projectName: String?) : + StatusBarWidget, + StatusBarWidget.TextPresentation { + + override fun ID(): String = WIDGET_ID + + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this + + override fun getTooltipText(): String = "$spaceName/$projectName" + override fun getClickConsumer(): Consumer? = null + + override fun getText(): String = "$spaceName/$projectName" + + override fun getAlignment(): Float = Component.CENTER_ALIGNMENT + + override fun dispose() {} + + override fun install(statusBar: StatusBar) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/DevFileSchemaProviderFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/DevFileSchemaProviderFactory.kt new file mode 100644 index 0000000000..c4d9c100cf --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/DevFileSchemaProviderFactory.kt @@ -0,0 +1,32 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.jsonSchema.extension.JsonSchemaFileProvider +import com.jetbrains.jsonSchema.extension.JsonSchemaProviderFactory +import com.jetbrains.jsonSchema.extension.SchemaType +import com.jetbrains.jsonSchema.impl.JsonSchemaVersion +import com.jetbrains.jsonSchema.remote.JsonFileResolver +import software.aws.toolkits.resources.message + +class DevFileSchemaProviderFactory : JsonSchemaProviderFactory { + override fun getProviders(project: Project): List = listOf( + object : JsonSchemaFileProvider { + override fun getName(): String = message("caws.devfile.schema") + + override fun isAvailable(file: VirtualFile): Boolean = file.name.matches(Regex("devfile\\.y[a]?ml", RegexOption.IGNORE_CASE)) + + override fun getSchemaFile(): VirtualFile? = JsonFileResolver.urlToFile(schemaUrl) + + override fun getSchemaType(): SchemaType = SchemaType.remoteSchema + + override fun getSchemaVersion(): JsonSchemaVersion = JsonSchemaVersion.SCHEMA_7 + } + ) + private companion object { + const val schemaUrl: String = "https://raw.githubusercontent.com/devfile/api/v2.2.1/schemas/latest/devfile.json" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/InactivityTimeout.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/InactivityTimeout.kt new file mode 100644 index 0000000000..6ca3fb27b3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/InactivityTimeout.kt @@ -0,0 +1,37 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws + +import software.aws.toolkits.resources.message +import java.time.Duration + +data class InactivityTimeout(val duration: Duration) : Comparable { + fun displayText() = if (duration.isZero) { + message("caws.workspace.details.no_timeout") + } else if (duration.toMinutesPart() == 0) { + message("date.in.n.hours", duration.toHours()) + } else { + message("date.in.n.minutes", duration.toMinutes()) + } + + fun asMinutes() = duration.toMinutes().toInt() + + override fun compareTo(other: InactivityTimeout): Int = duration.compareTo(other.duration) + + companion object { + val DEFAULT_TIMEOUT = InactivityTimeout(Duration.ofMinutes(15)) + val DEFAULT_VALUES = arrayOf( + InactivityTimeout(Duration.ofMinutes(0)), + DEFAULT_TIMEOUT, + InactivityTimeout(Duration.ofMinutes(30)), + InactivityTimeout(Duration.ofMinutes(45)), + InactivityTimeout(Duration.ofHours(1)), + InactivityTimeout(Duration.ofHours(2)), + InactivityTimeout(Duration.ofHours(4)), + InactivityTimeout(Duration.ofHours(8)) + ) + + fun fromMinutes(minutes: Int) = InactivityTimeout(Duration.ofMinutes(minutes.toLong())) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/actions/OpenCawsProfileAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/actions/OpenCawsProfileAction.kt new file mode 100644 index 0000000000..c985c13786 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/actions/OpenCawsProfileAction.kt @@ -0,0 +1,15 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.actions + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.caws.CawsEndpoints + +class OpenCawsProfileAction : DumbAwareAction() { + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(CawsEndpoints.ConsoleFactory.userHome()) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/CawsEnvironmentClient.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/CawsEnvironmentClient.kt new file mode 100644 index 0000000000..afc4c2f107 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/CawsEnvironmentClient.kt @@ -0,0 +1,142 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.envclient + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.util.text.nullize +import org.apache.http.client.methods.CloseableHttpResponse +import org.apache.http.client.methods.HttpGet +import org.apache.http.client.methods.HttpPost +import org.apache.http.client.methods.HttpPut +import org.apache.http.client.methods.HttpUriRequest +import org.apache.http.entity.ContentType +import org.apache.http.entity.StringEntity +import org.apache.http.impl.client.CloseableHttpClient +import org.apache.http.impl.client.HttpClientBuilder +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.services.caws.CawsConstants +import software.aws.toolkits.jetbrains.services.caws.envclient.models.CreateDevfileRequest +import software.aws.toolkits.jetbrains.services.caws.envclient.models.CreateDevfileResponse +import software.aws.toolkits.jetbrains.services.caws.envclient.models.GetActivityResponse +import software.aws.toolkits.jetbrains.services.caws.envclient.models.GetStatusResponse +import software.aws.toolkits.jetbrains.services.caws.envclient.models.StartDevfileRequest +import software.aws.toolkits.jetbrains.services.caws.envclient.models.UpdateActivityRequest +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message + +@Service +class CawsEnvironmentClient( + private val endpoint: String = System.getenv(CawsConstants.CAWS_ENV_API_ENDPOINT).nullize(true) ?: CawsConstants.DEFAULT_CAWS_ENV_API_ENDPOINT, + private val httpClient: CloseableHttpClient = HttpClientBuilder.create().build() +) : Disposable { + init { + LOG.info { "Initialized with endpoint: $endpoint" } + } + + private val objectMapper = jacksonObjectMapper().also { + it.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + it.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + } + + private val authToken: String? by lazy { + System.getenv(CawsConstants.CAWS_ENV_AUTH_TOKEN_VAR) + } + + /** + * Create a devfile for the project. + */ + fun createDevfile(request: CreateDevfileRequest): CreateDevfileResponse { + val body = objectMapper.writeValueAsString(request) + val httpRequest = HttpPost("$endpoint/devfile/create").also { + it.entity = StringEntity(body, ContentType.APPLICATION_JSON) + } + val response = execute(httpRequest) + return objectMapper.readValue(response.entity.content) + } + + /** + * Start an action with the payload. + */ + fun startDevfile(request: StartDevfileRequest) { + val body = objectMapper.writeValueAsString(request) + val httpRequest = HttpPost("$endpoint/start").also { + it.entity = StringEntity(body, ContentType.APPLICATION_JSON) + } + + // currently response will never be read if the call succeeds since the env restarts. the api impl, however does try to return 200 + try { + execute(httpRequest).use { + if (it.statusLine.statusCode != 200) { + val message = try { + message("caws.rebuild.devfile.failed_server", objectMapper.readTree(it.entity.content).get("message").asText()) + } catch (e: Exception) { + LOG.error(e) { "Couldn't parse response from /start API" } + message("caws.rebuild.devfile.failed", request.location ?: "null") + } + + notifyError(message("caws.rebuild.failed.title"), message) + } + } + } catch (e: Exception) { + throw IllegalStateException(message("caws.rebuild.failed.title"), e) + } + } + + /** + * Get status and action type + */ + fun getStatus(): GetStatusResponse { + val request = HttpGet("$endpoint/status") + val response = execute(request) + return objectMapper.readValue(response.entity.content) + } + + fun getActivity(): GetActivityResponse? = try { + val request = HttpGet("$endpoint/activity") + val response = execute(request) + if (response.statusLine.statusCode == 400) { + LOG.error { "Inactivity tracking may not enabled" } + null + } else { + objectMapper.readValue(response.entity.content) + } + } catch (e: Exception) { + LOG.error(e) { "Couldn't parse response from /activity API" } + null + } + + fun putActivityTimestamp(request: UpdateActivityRequest) { + try { + val body = objectMapper.writeValueAsString(request) + val httpRequest = HttpPut("$endpoint/activity").also { + it.entity = StringEntity(body, ContentType.APPLICATION_JSON) + } + val response = execute(httpRequest).use {} + } catch (e: Exception) { + LOG.error(e) { "Couldn't execute /activity API" } + } + } + + private fun execute(request: HttpUriRequest): CloseableHttpResponse { + request.addHeader("Authorization", authToken) + return httpClient.execute(request) + } + + override fun dispose() { + httpClient.close() + } + + companion object { + fun getInstance() = service() + + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/CreateDevfileRequest.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/CreateDevfileRequest.kt new file mode 100644 index 0000000000..cddd53845f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/CreateDevfileRequest.kt @@ -0,0 +1,12 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.envclient.models + +/** + * Project to create a devfile from. + * @param path + */ +data class CreateDevfileRequest( + val path: String +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/CreateDevfileResponse.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/CreateDevfileResponse.kt new file mode 100644 index 0000000000..56408b8c51 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/CreateDevfileResponse.kt @@ -0,0 +1,12 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.envclient.models + +/** + * Devfile object construct. + * @param location + */ +data class CreateDevfileResponse( + val location: String +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/Error.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/Error.kt new file mode 100644 index 0000000000..c751587f2f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/Error.kt @@ -0,0 +1,12 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.envclient.models + +/** + * @param message A description of the error condition + */ +data class Error( + /* A description of the error condition */ + val message: String? = null +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/GetActivityResponse.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/GetActivityResponse.kt new file mode 100644 index 0000000000..836c10f122 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/GetActivityResponse.kt @@ -0,0 +1,8 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.envclient.models + +data class GetActivityResponse( + val timestamp: String +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/GetStatusResponse.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/GetStatusResponse.kt new file mode 100644 index 0000000000..ba739af573 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/GetStatusResponse.kt @@ -0,0 +1,33 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.envclient.models + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue +import com.fasterxml.jackson.annotation.JsonValue + +/** + * Status object construct. + * @param actionId + * @param status + * @param message + */ +data class GetStatusResponse( + val actionId: String? = null, + val status: Status? = null, + val message: String? = null, + val location: String? = null +) { + /** + * Values: PENDING,STABLE,CHANGED,IMAGES-UPDATE-AVAILABLE + */ + enum class Status(@JsonValue val value: kotlin.String) { + PENDING("PENDING"), + STABLE("STABLE"), + CHANGED("CHANGED"), + IMAGES_UPDATE_AVAILABLE("IMAGES-UPDATE-AVAILABLE"), + + @JsonEnumDefaultValue + UNKNOWN("UNKNOWN") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/StartDevfileRequest.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/StartDevfileRequest.kt new file mode 100644 index 0000000000..39bb3b316a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/StartDevfileRequest.kt @@ -0,0 +1,9 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.envclient.models + +data class StartDevfileRequest( + val location: String? = null, + val recreateHomeVolumes: Boolean? = null +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/UpdateActivityRequest.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/UpdateActivityRequest.kt new file mode 100644 index 0000000000..1984358e8f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/models/UpdateActivityRequest.kt @@ -0,0 +1,8 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.envclient.models + +data class UpdateActivityRequest( + val timestamp: String? = null +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/pat/CawsPatUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/pat/CawsPatUtils.kt new file mode 100644 index 0000000000..20cc7ae9a3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/pat/CawsPatUtils.kt @@ -0,0 +1,37 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.pat + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.generateServiceName +import com.intellij.ide.passwordSafe.PasswordSafe +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.amazon.awssdk.services.codecatalyst.model.ServiceQuotaExceededException +import software.aws.toolkits.resources.message + +private val SUBSYSTEM = "AWS Toolkit - ${message("code.aws")} PAT" + +private fun credentialAttributes(user: String) = CredentialAttributes(generateServiceName(SUBSYSTEM, user)) + +fun getPat(user: String) = + PasswordSafe.instance.get(credentialAttributes(user)) + +fun patExists(user: String) = getPat(user) != null + +fun generateAndStorePat(cawsClient: CodeCatalystClient, user: String) { + // ideally we invalidate any existing PAT but we don't have that information + + val pat = try { + cawsClient.createAccessToken { + it.name("$user-AwsJetBrainsToolkit-${System.currentTimeMillis()}") + }.secret() + } catch (e: ServiceQuotaExceededException) { + // warn user has too many PATs + throw e + } + + val credentialAttributes = credentialAttributes(user) + PasswordSafe.instance.set(credentialAttributes, Credentials(user, pat)) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/projectstate/CawsProjectSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/projectstate/CawsProjectSettings.kt new file mode 100644 index 0000000000..7cc8a53d4c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/projectstate/CawsProjectSettings.kt @@ -0,0 +1,23 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.caws.projectstate + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.caws.CawsCodeRepository +import software.aws.toolkits.jetbrains.services.caws.CawsProject + +class CawsProjectSettings(private val project: Project) { + // TODO: state needs a message bus so we can clean up random reads everywhere + val state = CawsProjectSettingsState() + + companion object { + fun getInstance(project: Project) = project.service() + } +} + +data class CawsProjectSettingsState( + var cawsProject: CawsProject? = null, + var codeRepo: CawsCodeRepository? = null +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CliOutputParser.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CliOutputParser.kt deleted file mode 100644 index c6f8f63a46..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CliOutputParser.kt +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug - -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import org.slf4j.event.Level -import software.aws.toolkits.core.utils.error -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.splitNoBlank - -object CliOutputParser { - private val LOG_EVENT_REGEX = "\\W*(?:\\[.*m)?\\[\\d+:\\d+:\\d+-([A-Z])\\W\\S+]\\W*(?:\\[0m)?\\W(.*)".toRegex(RegexOption.DOT_MATCHES_ALL) - private val LOG = getLogger() - private val objectMapper: ObjectMapper by lazy { - jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - } - - fun parseErrorOutput(output: String): ErrorOutput? = parseWithLogging(output.splitNoBlank('\n').last()) // Take the last line of the output - - fun parseInstrumentResponse(output: String): InstrumentResponse? = parseWithLogging(output) - - fun parseLogEvent(event: String): LogEvent { - val match = LOG_EVENT_REGEX.matchEntire(event.trimStart()) ?: return LogEvent(event, null) - val (rawLevel, text) = match.destructured - val level = when (rawLevel.toUpperCase()) { - "E" -> Level.ERROR - "D" -> Level.DEBUG - "W" -> Level.WARN - "I" -> Level.INFO - else -> null - } - return LogEvent(text, level) - } - - private inline fun parseWithLogging(str: String): T? = try { - objectMapper.readValue(str) - } catch (e: Exception) { - LOG.error(e) { "Failed to parse response: $str" } - null - } -} - -fun String.asLogEvent(): LogEvent = CliOutputParser.parseLogEvent(this) - -data class ErrorOutput(val errors: List) -data class InstrumentResponse(val target: String) -data class LogEvent(val text: String, val level: Level?) - -/* TODO uncomment this when the cli conforms to the contract - data class InstrumentResponse(val targets: List) - data class Target(val name: String, val target: String) -*/ diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugConstants.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugConstants.kt deleted file mode 100644 index 1628629535..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugConstants.kt +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug - -import software.aws.toolkits.core.utils.AttributeBagKey - -object CloudDebugConstants { - const val CLOUD_DEBUG_RESOURCE_PREFIX = "cloud-debug-" - const val INSTRUMENTED_STATUS = "ENABLED" - const val CLOUD_DEBUG_SIDECAR_CONTAINER_NAME = "${CLOUD_DEBUG_RESOURCE_PREFIX}sidecar-container" - const val DEFAULT_REMOTE_DEBUG_PORT = 20020 - const val REMOTE_DEBUG_PORT_ENV = "REMOTE_DEBUG_PORT" - val INSTRUMENT_IAM_ROLE_KEY: AttributeBagKey = AttributeBagKey.create("instrumentIAMRoleKey") - val RUNTIMES_REQUIRING_BEFORE_TASK = listOf( - CloudDebuggingPlatform.JVM - ) -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugResolver.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugResolver.kt deleted file mode 100644 index 3fe2496038..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugResolver.kt +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug - -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.SystemInfo -import com.intellij.util.text.SemVer -import org.apache.commons.codec.digest.DigestUtils -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.ExecutableType -import software.aws.toolkits.jetbrains.core.getTextFromUrl -import software.aws.toolkits.jetbrains.core.saveFileFromUrl -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.MessageEmitter -import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.CloudDebugCliValidate -import software.aws.toolkits.jetbrains.services.clouddebug.resources.CloudDebuggingResources -import software.aws.toolkits.jetbrains.utils.ZipDecompressor -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.net.URL -import java.nio.file.Files -import java.time.Duration -import java.time.Instant - -object CloudDebugResolver { - private const val URL_PREFIX = "https://cloud-debug.amazonwebservices.com/release/metadata" - private const val URL_LATEST_VERSION = "latest-version" - private const val URL_RELEASE_JSON = "release-metadata.json" - - private val mapper by lazy { jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) } - - /** - * Checks the current cloud-debug CLI version, and updates the version of a newer version is available. - * If a user specifies their own executable, we will not attempt the download and still use it, even if the version is invalid - * (invalid versions will still be met with errors by any validating code) - * - * @param project: Project - * @param messageEmitter: MessageEmitter (remove this in favor of a message emitter in the object?) - * @param context: Context (optional), used by steps to set attributes for other steps in the workflows - */ - fun validateOrUpdateCloudDebug(project: Project, messageEmitter: MessageEmitter, context: Context?) { - var currentExecutable: ExecutableInstance.Executable? = null - var currentVersion: String? = null - - // do we already have an executable? If so, get it so we can check version against it - try { - currentExecutable = CloudDebugCliValidate.validateAndLoadCloudDebugExecutable() - context?.putAttribute(CloudDebugCliValidate.EXECUTABLE_ATTRIBUTE, currentExecutable) - currentVersion = currentExecutable.version - messageEmitter.emitMessage(message("cloud_debug.step.clouddebug.validate.success", currentVersion), false) - } catch (e: RuntimeException) { - // existing executable had some sort of an error - // swallow it and attempt an update; doesn't matter if this is broken if we can give them something that works - } - - // if we have an executable and it's not autoresolved (read: a user manually set it), always default to that. - // NOTE: If a user is purposefully using an invalid version, currentExecutable will be null - // This will trigger the install process and, if successful, the user will be reverted to the new, autoresolved version - if (currentExecutable != null && !currentExecutable.autoResolved) { - messageEmitter.emitMessage(message("cloud_debug.step.clouddebug.resolution.auto"), false) - return - } - attemptToUpdateCloudDebug(project, messageEmitter, currentExecutable, currentVersion, context) - } - - private fun attemptToUpdateCloudDebug( - project: Project, - messageEmitter: MessageEmitter, - currentExecutable: ExecutableInstance.Executable?, - currentVersion: String?, - context: Context? - ) { - try { - messageEmitter.emitMessage(message("cloud_debug.step.clouddebug.update_check"), false) - val upstreamExecutableVersion = getUpstreamCloudDebugVersion() - // install if we don't have an executable or if we're out of date - if (currentExecutable == null || upstreamExecutableVersion > SemVer.parseFromText(currentVersion)) { - // If we are out of date, also run shutdown - if (currentExecutable != null && upstreamExecutableVersion > SemVer.parseFromText(currentVersion)) { - CloudDebuggingResources.shutdownCloudDebugDispatcher() - } - - val manifest = getCloudDebugManifestForVersion(upstreamExecutableVersion) - messageEmitter.emitMessage(message("cloud_debug.step.clouddebug.install.initiate", manifest.version), false) - installCloudDebugCli( - manifest, - project, - currentVersion - ) - - // clear the executable cache after install - ExecutableManager.getInstance().removeExecutable(ExecutableType.getInstance()) - context?.putAttribute(CloudDebugCliValidate.EXECUTABLE_ATTRIBUTE, CloudDebugCliValidate.validateAndLoadCloudDebugExecutable()) - messageEmitter.emitMessage(message("cloud_debug.step.clouddebug.install.success"), false) - } else { - messageEmitter.emitMessage(message("cloud_debug.step.clouddebug.up_to_date"), false) - return - } - } catch (e: Exception) { - // throw an error if the user doesn't have an existing executable AND couldn't reach the manifest - // otherwise, use existing executable - messageEmitter.emitMessage(e.message.toString(), true) - if (currentExecutable != null) { - messageEmitter.emitMessage("cloud_debug.step.clouddebug.update_check.fail_with_existing", false) - } else { - throw e - } - } - } - - /** - * Validates and installs a cloud debug executable from a given URL - * @param manifest: CloudDebugManifest - * TODO: add a progress indicator - */ - private fun installCloudDebugCli(manifest: CloudDebugManifest, project: Project, oldVersion: String?) { - val startTime = Instant.now() - try { - // TODO: Consider a way to handle input stream directly? HttpRequests/Decompressor can do this if we have a tar.gz - val zipFile = Files.createTempFile("cloud-debug", ".zip") - // TODO: add progress indicator, preferably to this implementation as this is synchronous. - saveFileFromUrl(manifest.location, zipFile, null) - - // checksum checker - val zipSignature = DigestUtils.sha256Hex(Files.readAllBytes(zipFile)) - if (zipSignature != manifest.checksum) { - throw RuntimeException(message("cloud_debug.step.clouddebug.checksum.fail")) - } - - val directory = ExecutableType.EXECUTABLE_DIRECTORY.toFile() - ZipDecompressor(zipFile.toFile()).use { - it.extract(directory) - } - emitInstallMetric( - Result.Succeeded, - project, - startTime, - manifest.version, - oldVersion - ) - } catch (e: Exception) { - emitInstallMetric(Result.Failed, project, startTime, null, oldVersion) - throw e - } - } - - private fun generateCloudDebugManifestUrl(suffix: String): String { - if (!SystemInfo.is64Bit) { - throw RuntimeException("Cloud Debugging requires a 64-bit OS") - } - val os = when { - SystemInfo.isWindows -> "windows_amd64" - SystemInfo.isMac -> "darwin_amd64" - SystemInfo.isLinux -> "linux_amd64" - else -> throw RuntimeException("Unrecognized OS!") - } - return "$URL_PREFIX/$os/$suffix" - } - - private fun getLatestMinorVersionUrl(majorVersion: String): String = - generateCloudDebugManifestUrl("$majorVersion/$URL_LATEST_VERSION") - - private fun getLatestPatchVersionUrl(majorVersion: String, majorAndMinorVersion: String): String = - generateCloudDebugManifestUrl("$majorVersion/$majorAndMinorVersion/$URL_LATEST_VERSION") - - private fun getExecutableManifestUrl(executableVersion: SemVer): String = - generateCloudDebugManifestUrl( - "${executableVersion.major}/${executableVersion.major}.${executableVersion.minor}/$executableVersion/$URL_RELEASE_JSON" - ) - - /** - * Pulls the latest valid version - * A valid version matches the major and minor versions of the minimum version, while looking for the latest patch version - */ - private fun getUpstreamCloudDebugVersion(): SemVer { - val validMajor = "${CloudDebugExecutable.MIN_VERSION.major}" - val validMajorAndMinor = getTextFromUrl(getLatestMinorVersionUrl(validMajor)) - return SemVer.parseFromText(getTextFromUrl(getLatestPatchVersionUrl(validMajor, validMajorAndMinor))) - ?: throw RuntimeException(message("cloud_debug.step.clouddebug.update_check.fail")) - } - - /** - * Pulls the manifest data for the given version - * @param version: SemVer version to pull data for - */ - private fun getCloudDebugManifestForVersion(version: SemVer): CloudDebugManifest = mapper.readValue( - URL(getExecutableManifestUrl(version)), - CloudDebugManifest::class.java - ) - - private fun emitInstallMetric( - result: Result, - project: Project, - startTime: Instant, - newVersion: String?, - oldVersion: String? - ) { - ClouddebugTelemetry.install( - project, - version = newVersion.toString(), - oldversion = oldVersion.toString(), - result = result, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble(), - createTime = startTime - ) - } - - data class CloudDebugManifest( - val name: String, - val location: String, - val platform: String, - val version: String, - val checksum: String, - @JsonProperty("cloud-debug-commit") val commit: String - ) -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugStartupCommand.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugStartupCommand.kt deleted file mode 100644 index 9f4a46a217..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebugStartupCommand.kt +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug - -import com.intellij.execution.configurations.RuntimeConfigurationError -import com.intellij.openapi.project.Project -import software.aws.toolkits.jetbrains.services.ecs.execution.ArtifactMapping -import software.aws.toolkits.resources.message - -/** - * Startup command for ECS CloudDebug. - * A command to launch a debugger process on a remote Docker instance. - */ -open class CloudDebugStartupCommand(val platform: CloudDebuggingPlatform) { - - open val isStartCommandAutoFillSupported: Boolean = false - - /** - * Update a startup command after selecting an artifact to use to run debugger. - * - * On a regular basis we should use one of Artifact mapping to provide a valid path to a debugger and assembly to run inside a remote container. - * This method provide a way to map one of specified Artifact mapping value to generate startup command automatically. - * - * @param project - [com.intellij.openapi.project.Project] instance - * @param originalCommand - Original command from Startup Command text field from ECS CloudDebug run configuration - * @param artifact - Selected Artifact mapping to use for updating startup command. - * @param onCommandGet - Callback to run when command is ready. - */ - open fun updateStartupCommand(project: Project, originalCommand: String, artifact: ArtifactMapping, onCommandGet: (String) -> Unit) { } - - /** - * Hint text is shown inside empty Startup Command text field. - */ - open fun getStartupCommandTextFieldHintText(): String = "" - - /** - * Validate startup command against run configuration. - * - * @throws [RuntimeConfigurationError] on errors in startup command for a particular platform. - */ - @Throws(RuntimeConfigurationError::class) - open fun validateStartupCommand(command: String, containerName: String) { - if (command.isBlank()) { - throw RuntimeConfigurationError(message("cloud_debug.run_configuration.missing.start_command", containerName)) - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebuggingExplorerProcessor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebuggingExplorerProcessor.kt deleted file mode 100644 index dd561b95aa..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebuggingExplorerProcessor.kt +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug - -import com.intellij.icons.AllIcons -import com.intellij.ide.projectView.PresentationData -import com.intellij.ui.RowIcon -import com.intellij.ui.SimpleTextAttributes -import software.aws.toolkits.jetbrains.core.explorer.AwsExplorerNodeProcessor -import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants.CLOUD_DEBUG_RESOURCE_PREFIX -import software.aws.toolkits.jetbrains.services.ecs.EcsServiceNode -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils - -class CloudDebuggingExplorerProcessor : AwsExplorerNodeProcessor { - override fun postProcessPresentation(node: AwsExplorerNode<*>, presentation: PresentationData) { - when (node) { - is EcsServiceNode -> - if (EcsUtils.isInstrumented(node.resourceArn())) { - presentation.setIcon(RowIcon(presentation.getIcon(true), AllIcons.Actions.StartDebugger)) - } else { - // grey out instrumented original resources - if (node.parent.children.map { (it as? EcsServiceNode)?.displayName() == "$CLOUD_DEBUG_RESOURCE_PREFIX${node.displayName()}" }.any { it }) { - presentation.clearText() - presentation.addText(EcsUtils.serviceArnToName(node.resourceArn()), SimpleTextAttributes.GRAYED_ATTRIBUTES) - } - } - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebuggingExplorerTreeStructureProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebuggingExplorerTreeStructureProvider.kt deleted file mode 100644 index 86ee870750..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebuggingExplorerTreeStructureProvider.kt +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug - -import com.intellij.ide.projectView.ViewSettings -import com.intellij.ide.util.treeView.AbstractTreeNode -import software.aws.toolkits.jetbrains.core.explorer.AwsExplorerTreeStructureProvider -import software.aws.toolkits.jetbrains.services.ecs.EcsClusterNode -import software.aws.toolkits.jetbrains.services.ecs.EcsServiceNode -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils - -class CloudDebuggingExplorerTreeStructureProvider : AwsExplorerTreeStructureProvider { - override fun modify( - parent: AbstractTreeNode<*>, - children: MutableCollection>, - settings: ViewSettings? - ): MutableCollection> = - when (parent) { - is EcsClusterNode -> children - .sortedWith(Comparator { x, y -> - val service1 = (x as? EcsServiceNode)?.resourceArn()?.toLowerCase() ?: "" - val service2 = (y as? EcsServiceNode)?.resourceArn()?.toLowerCase() ?: "" - val value = EcsUtils.originalServiceName(service1).compareTo(EcsUtils.originalServiceName(service2)) - if (value != 0) { - value - } else { - // Always put the instrumented service first - if (EcsUtils.isInstrumented(service1)) { - -1 - } else { - 1 - } - } - }) - .toMutableList() - else -> children - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebuggingPlatform.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebuggingPlatform.kt deleted file mode 100644 index 262c315d13..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/CloudDebuggingPlatform.kt +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug - -enum class CloudDebuggingPlatform { - JVM, - PYTHON, - NODE, - DOTNET -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/DebuggerSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/DebuggerSupport.kt deleted file mode 100644 index 6f53f4a857..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/DebuggerSupport.kt +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug - -import com.intellij.execution.ExecutorRegistry -import com.intellij.execution.ProgramRunnerUtil -import com.intellij.execution.RunnerAndConfigurationSettings -import com.intellij.execution.configurations.RuntimeConfigurationError -import com.intellij.execution.executors.DefaultDebugExecutor -import com.intellij.execution.filters.TextConsoleBuilderFactory -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.runners.ExecutionEnvironmentBuilder -import com.intellij.execution.ui.ConsoleView -import com.intellij.execution.ui.RunContentDescriptor -import com.intellij.execution.ui.RunnerLayoutUi -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.extensions.ExtensionPointName -import com.intellij.util.execution.ParametersListUtil -import com.intellij.util.io.isDirectory -import com.intellij.util.io.isFile -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.ResourceTransferStep -import software.aws.toolkits.jetbrains.services.ecs.execution.ImmutableArtifactMapping -import software.aws.toolkits.jetbrains.services.ecs.execution.ImmutableContainerOptions -import software.aws.toolkits.resources.message -import java.io.File -import java.nio.file.Paths -import java.util.EnumMap -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CompletionStage - -abstract class DebuggerSupport { - abstract val platform: CloudDebuggingPlatform - abstract val debuggerPath: DebuggerPath? - - open val numberOfDebugPorts: Int = 1 - - /** - * Attach a debugger to the given environment. - * - * Returns a [CompletionStage] that: - * - completes with the [RunContentDescriptor] associated with that debugger - * - completes with null if an exception occurs during attachment - * - completes exceptionally if an error occurs before attachment - */ - abstract fun attachDebugger( - context: Context, - containerName: String, - containerOptions: ImmutableContainerOptions, - environment: ExecutionEnvironment, - ports: List, - displayName: String - ): CompletableFuture - - fun automaticallyAugmentable(input: String): Boolean { - val parameters = ParametersListUtil.parse(input, true, false) - // If there is only one argument, there is nothing to augment - if (parameters.size < 2) { - return false - } - if (parameters.first().trim().first() == '\'') { - throw RuntimeConfigurationError(message("cloud_debug.run_configuration.augment.single_quote")) - } - return automaticallyAugmentable(parameters) - } - - open fun createDebuggerUploadStep(context: Context, containerName: String): ResourceTransferStep? { - val debugPath = debuggerPath ?: return null - return ResourceTransferStep(debugPath.getDebuggerPath(), debugPath.getRemoteDebuggerPath(), containerName) - } - - protected abstract fun automaticallyAugmentable(input: List): Boolean - - /** - * Alter the start string to add debugger arguments - */ - protected abstract fun attachDebuggingArguments(input: List, ports: List, debuggerPath: String): String - - open fun augmentStatement(input: String, ports: List, debuggerPath: String): String { - if (ports.isEmpty()) { - throw IllegalStateException(message("cloud_debug.step.augment_statement.missing_debug_port")) - } - - return "env ${CloudDebugConstants.REMOTE_DEBUG_PORT_ENV}=${ports.first()} ${attachDebuggingArguments( - input = ParametersListUtil.parse(input, true, false), - ports = ports, - debuggerPath = debuggerPath - )}" - } - - /** - * Assuming the debugger implementation is per-language and not per-IDE, return the console that should be used for process output - */ - open fun getConsoleView(environment: ExecutionEnvironment, layout: RunnerLayoutUi): ConsoleView = - layout.findContent("ConsoleContent")?.component as? ConsoleView ?: createLogConsole(environment, layout) - - open fun startupCommand(): CloudDebugStartupCommand = CloudDebugStartupCommand(platform) - - interface DebuggerPath { - /** - * Get the path to the debugger if we need to copy it. For every runtime we need it for, the IDE ships with - * an appropriate debugger so this function just returns a string to it. - */ - fun getDebuggerPath(): String - - /** - * Get the path to the debugger on the remote machine. This corresponds to the actual entry point of the debugger (e.x. pydevd.py for python) - */ - fun getDebuggerEntryPoint(): String - - /** - * Get the path for where a remote debugger will be put - */ - fun getRemoteDebuggerPath(): String - } - - companion object { - const val LOCALHOST_NAME = "localhost" - val EP_NAME = ExtensionPointName("aws.toolkit.clouddebug.debuggerSupport") - - @JvmStatic - fun debuggers(): EnumMap { - val items = EP_NAME.extensions.map { - it.platform to it - } - return if (items.isEmpty()) { - EnumMap(CloudDebuggingPlatform::class.java) - } else { - EnumMap(items.toMap()) - } - } - - @JvmStatic - fun debugger(platform: CloudDebuggingPlatform) = debuggers()[platform] - ?: throw IllegalStateException(message("cloud_debug.step.attach_debugger.unknown_platform", platform)) - - fun executeConfiguration(environment: ExecutionEnvironment, runSettings: RunnerAndConfigurationSettings): CompletableFuture { - val env = ExecutionEnvironmentBuilder.create(DefaultDebugExecutor.getDebugExecutorInstance(), runSettings) - .contentToReuse(null) - .dataContext(environment.dataContext) - .activeTarget() - .build() - - val future = CompletableFuture() - ApplicationManager.getApplication().executeOnPooledThread { - val executorRegistry = ExecutorRegistry.getInstance() - while (executorRegistry.isStarting(environment)) { - // only one session for a given triple can be started at a time - Thread.sleep(100) - } - - runInEdt { - ProgramRunnerUtil.executeConfigurationAsync(env, true, true) { future.complete(it) } - } - } - - return future - } - - fun createLogConsole(environment: ExecutionEnvironment, layout: RunnerLayoutUi): ConsoleView { - val consoleBuilder = TextConsoleBuilderFactory.getInstance().createBuilder(environment.project) - consoleBuilder.setViewer(true) - val console = consoleBuilder.console - console.allowHeavyFilters() - val content = layout.createContent("CloudDebugLogsTab", console.component, message("cloud_debug.execution.logs.title"), null, null) - layout.addContent(content) - - return console - } - } - - fun convertArtifactMappingsToPathMappings( - artifactMappings: List, - debuggerPath: DebuggerPath? - ): List> { - val mappings = mutableSetOf>() - artifactMappings.forEach { a -> - assert(!a.localPath.isBlank() && !a.remotePath.isBlank()) - mappings.add(createPathMapping(a.localPath, a.remotePath)) - } - if (debuggerPath != null) { - mappings.add(Pair(debuggerPath.getDebuggerPath(), debuggerPath.getRemoteDebuggerPath())) - } - return mappings.toList() - } - - private fun createPathMapping(localPath: String, remotePath: String): Pair { - val localPathObj = Paths.get(localPath) - - val modifiedRemotePath = when { - // folder itself is being copied, map it to the resulting remote path - localPathObj.isDirectory() && !localPath.endsWith(File.separatorChar) -> "$remotePath/${localPathObj.fileName}" - // remote path is a file; remote path should be a directory - localPathObj.isFile() && !remotePath.endsWith('/') -> remotePath.substringBeforeLast('/') - // is a file or contents of files are being copied to the remote path - else -> remotePath - } - return Pair(localPath, modifiedRemotePath) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/ConfirmNonProductionDialog.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/ConfirmNonProductionDialog.form deleted file mode 100644 index 26eeeca4e3..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/ConfirmNonProductionDialog.form +++ /dev/null @@ -1,39 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/ConfirmNonProductionDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/ConfirmNonProductionDialog.kt deleted file mode 100644 index aa07a255b4..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/ConfirmNonProductionDialog.kt +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.actions - -import com.intellij.openapi.Disposable -import com.intellij.openapi.ui.Messages -import com.intellij.ui.components.JBLabel -import software.aws.toolkits.resources.message -import javax.swing.JCheckBox -import javax.swing.JPanel - -class ConfirmNonProductionDialog(serviceName: String) : Disposable { - lateinit var content: JPanel - lateinit var confirmProceed: JCheckBox - lateinit var warning: JBLabel - - init { - warning.text = message("cloud_debug.instrument.production_warning.text") - warning.icon = Messages.getWarningIcon() - warning.iconTextGap = 8 - confirmProceed.text = message("cloud_debug.instrument.production_warning.checkbox_label", serviceName) - } - - override fun dispose() {} -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentDialog.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentDialog.form deleted file mode 100644 index 9e93f88ed6..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentDialog.form +++ /dev/null @@ -1,31 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentDialog.kt deleted file mode 100644 index 238389124c..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentDialog.kt +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.actions - -import com.intellij.openapi.Disposable -import com.intellij.ui.components.JBLabel -import java.text.MessageFormat -import javax.swing.JPanel - -class DeinstrumentDialog(serviceName: String) : Disposable { - lateinit var content: JPanel - lateinit var warningMessage: JBLabel - - init { - warningMessage.text = MessageFormat.format(warningMessage.text, serviceName) - } - - override fun dispose() {} -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentDialogWrapper.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentDialogWrapper.kt deleted file mode 100644 index 6857450d34..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentDialogWrapper.kt +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.actions - -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.DialogWrapper -import software.aws.toolkits.jetbrains.core.help.HelpIds -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.resources.message -import javax.swing.JComponent - -class DeinstrumentDialogWrapper(val project: Project, val serviceArn: String) : DialogWrapper(project) { - val view: DeinstrumentDialog = DeinstrumentDialog(EcsUtils.originalServiceName(serviceArn)) - - init { - init() - title = message("cloud_debug.instrument_resource.disable") - isOKActionEnabled = true - centerRelativeToParent() - } - - override fun createCenterPanel(): JComponent? = view.content - - override fun getHelpId(): String? = HelpIds.CLOUD_DEBUG_ENABLE.id -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentResourceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentResourceAction.kt deleted file mode 100644 index 27922e48eb..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/DeinstrumentResourceAction.kt +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.actions - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.PlatformDataKeys -import com.intellij.openapi.project.Project -import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.jetbrains.services.ecs.EcsServiceNode -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant - -// TODO: generify for anything cloud debuggable -class DeinstrumentResourceFromExplorerAction : SingleResourceNodeAction( - message("cloud_debug.instrument_resource.disable") -) { - override fun actionPerformed(selected: EcsServiceNode, e: AnActionEvent) { - val project = e.getRequiredData(PlatformDataKeys.PROJECT) - - val dialog = DeinstrumentDialogWrapper(project, selected.resourceArn()) - if (dialog.showAndGet()) { - performAction(project, selected.clusterArn(), selected.resourceArn(), selected) - } - } - - override fun update(selected: EcsServiceNode, e: AnActionEvent) { - // If there are no supported debuggers, showing this will just be confusing - e.presentation.isVisible = if (DebuggerSupport.debuggers().isEmpty()) { - false - } else { - EcsUtils.isInstrumented(selected.resourceArn()) - } - } - - companion object { - fun performAction( - project: Project, - clusterArn: String, - instrumentedResourceName: String, - selected: EcsServiceNode?, - callback: ((Boolean) -> Unit)? = null - ) { - val originalServiceName = EcsUtils.originalServiceName(instrumentedResourceName) - DeinstrumentAction( - project, - EcsUtils.clusterArnToName(clusterArn), - originalServiceName, - message("cloud_debug.instrument_resource.disable"), - message("cloud_debug.instrument_resource.disable.success", originalServiceName), - message("cloud_debug.instrument_resource.disable.failed", originalServiceName) - ).runAction(selected, callback) - } - } -} - -/** - * Implements the "Disable Cloud Debugging" action in the ECS tree of AWS Explorer. - */ -internal class DeinstrumentAction( - project: Project, - private val clusterName: String, - private val serviceName: String, - name: String, - successMessage: String, - failureMessage: String -) : PseCliAction(project, name, successMessage, failureMessage) { - override fun buildCommandLine(cmd: GeneralCommandLine) { - cmd - .withParameters("--verbose") - .withParameters("--json") - .withParameters("revert") - .withParameters("ecs") - .withParameters("service") - .withParameters("--cluster") - .withParameters(clusterName) - .withParameters("--service") - .withParameters(serviceName) - } - - override fun produceTelemetry(startTime: Instant, result: Result, version: String?) { - ClouddebugTelemetry.deinstrument( - project, - result, - version, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble(), - createTime = startTime - ) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialog.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialog.form deleted file mode 100644 index 722b847584..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialog.form +++ /dev/null @@ -1,68 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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 deleted file mode 100644 index 9fdbd04f5f..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialog.kt +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.actions - -import com.intellij.icons.AllIcons -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.project.Project -import com.intellij.ui.components.JBLabel -import software.amazon.awssdk.services.ecs.EcsClient -import software.amazon.awssdk.services.iam.IamClient -import software.amazon.awssdk.services.iam.model.PolicyEvaluationDecisionType -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.AwsConnectionManager -import software.aws.toolkits.jetbrains.core.help.HelpIds -import software.aws.toolkits.jetbrains.services.RoleValidation -import software.aws.toolkits.jetbrains.services.iam.IamResources -import software.aws.toolkits.jetbrains.services.iam.IamRole -import software.aws.toolkits.jetbrains.ui.ResourceSelector -import software.aws.toolkits.resources.message -import java.awt.event.ItemEvent -import java.net.URLDecoder -import javax.swing.JPanel - -class InstrumentDialog(private val project: Project, val clusterArn: String, val serviceArn: String) : Disposable { - lateinit var content: JPanel - lateinit var iamRole: ResourceSelector - lateinit var roleNotValidWarning: JBLabel - - init { - roleNotValidWarning.isVisible = false - roleNotValidWarning.setCopyable(true) - roleNotValidWarning.setAllowAutoWrapping(true) - roleNotValidWarning.icon = AllIcons.General.Warning - } - - private fun createUIComponents() { - val credentials = AwsConnectionManager.getInstance(project).activeCredentialProvider - val region = AwsConnectionManager.getInstance(project).activeRegion - - iamRole = ResourceSelector.builder(project) - .resource { IamResources.LIST_ALL } - .awsConnection { Pair(region, credentials) } - .build() - - iamRole.addItemListener { - onIamRoleSelectionChanged(it) - } - - // In the background, attempt to select a role - ApplicationManager.getApplication().executeOnPooledThread { - attemptSelectRole() - } - } - - private fun onIamRoleSelectionChanged(itemEvent: ItemEvent) { - if (itemEvent.stateChange == ItemEvent.DESELECTED) { - return - } - - val iamRole = itemEvent.item - ApplicationManager.getApplication().executeOnPooledThread { - var roleValidationWarning = message("cloud_debug.instrument_resource.role.not.valid", HelpIds.CLOUD_DEBUG_ENABLE.url) - var roleValidationVisible = true - - try { - if (iamRole is IamRole) { - roleValidationVisible = !isRoleValid(iamRole) - } - } catch (e: Exception) { - roleValidationWarning = message("cloud_debug.instrument_resource.role.could.not.validate", e.localizedMessage) - roleValidationVisible = true - } - - runInEdt(ModalityState.any()) { - roleNotValidWarning.text = roleValidationWarning - roleNotValidWarning.isVisible = roleValidationVisible - } - } - } - - private fun isRoleValid(iamRole: IamRole): Boolean { - try { - val iamClient = project.awsClient() - val actionsAllowed = canSimulateCloudDebugActions(iamClient, iamRole.arn) - if (!actionsAllowed) { - return false - } - - return iamRole.name?.let { - isRolePolicyValid(iamClient, it) - } ?: throw Exception("This role does not have a name") - } catch (e: Exception) { - LOG.warn(e) { "Unable to validate role for Cloud Debugging" } - throw e - } - } - - private fun isRolePolicyValid(iamClient: IamClient, roleName: String): Boolean { - val role = iamClient.getRole { - it.roleName(roleName) - }.role() - - val encodedRolePolicy = role.assumeRolePolicyDocument() - val rolePolicy = URLDecoder.decode(encodedRolePolicy, "UTF-8") - - return RoleValidation.isRolePolicyValidForCloudDebug(rolePolicy) - } - - private fun canSimulateCloudDebugActions( - iamClient: IamClient, - roleArn: String - ): Boolean = iamClient.simulatePrincipalPolicy { - it - .policySourceArn(roleArn) - .actionNames(CLOUD_DEBUG_ACTIONS_TO_SIMULATE) - .build() - }.evaluationResults().all { - it.evalDecision() == PolicyEvaluationDecisionType.ALLOWED - } - - // Auto-select task-role (if it exists in the task-definition). Runs on a background thread. - private fun attemptSelectRole() = - try { - val client: EcsClient = AwsClientManager.getInstance(project).getClient() - val service = client.describeServices { - it.cluster(clusterArn) - it.services(serviceArn) - } - val taskDefinition = service.services().first().taskDefinition() - val taskDefinitionDescription = client.describeTaskDefinition { - it.taskDefinition(taskDefinition) - } - val roleArn = taskDefinitionDescription.taskDefinition().taskRoleArn() - iamRole.selectedItem { - it.arn == roleArn - } - } catch (e: Exception) { - LOG.warn(e) { "Unable to retrieve task role for cluster $clusterArn service $serviceArn" } - } - - override fun dispose() {} - - private companion object { - val LOG = getLogger() - val CLOUD_DEBUG_ACTIONS_TO_SIMULATE = listOf( - "ssmmessages:CreateControlChannel", - "ssmmessages:CreateDataChannel", - "ssmmessages:OpenControlChannel", - "ssmmessages:OpenDataChannel" - ) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialogWrapper.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialogWrapper.kt deleted file mode 100644 index 80739b01fb..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentDialogWrapper.kt +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.actions - -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.DialogWrapper -import software.aws.toolkits.jetbrains.core.help.HelpIds -import software.aws.toolkits.jetbrains.settings.CloudDebugSettings -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.awt.GridBagLayout -import javax.swing.JCheckBox -import javax.swing.JComponent -import javax.swing.JPanel - -class InstrumentDialogWrapper(val project: Project, clusterArn: String, private val serviceArn: String) : DialogWrapper(project) { - val view: InstrumentDialog = InstrumentDialog(project, clusterArn, serviceArn) - private val settings = CloudDebugSettings.getInstance() - - init { - init() - title = message("cloud_debug.instrument") - isOKActionEnabled = false - view.iamRole.addActionListener { - isOKActionEnabled = view.iamRole.selected() != null - } - centerRelativeToParent() - } - - override fun createCenterPanel(): JComponent? = view.content - - override fun getHelpId(): String? = HelpIds.CLOUD_DEBUG_ENABLE.id - - override fun doOKAction() { - if (!settings.showEnableDebugWarning || ConfirmNonProductionDialogWrapper(project, serviceArn.substringAfterLast("service/")).showAndGet()) { - super.doOKAction() - } - } -} - -class ConfirmNonProductionDialogWrapper(private val project: Project, serviceName: String) : DialogWrapper(project) { - private val view = ConfirmNonProductionDialog(serviceName) - private val doNotShowAgain = JCheckBox(message("notice.suppress")) - private val settings = CloudDebugSettings.getInstance() - - init { - init() - isOKActionEnabled = false - title = message("cloud_debug.instrument.production_warning.title") - createTitlePane() - view.confirmProceed.addActionListener { isOKActionEnabled = view.confirmProceed.isSelected } - } - - override fun createCenterPanel(): JComponent? = view.content - - override fun createSouthAdditionalPanel() = JPanel(GridBagLayout()).apply { add(doNotShowAgain) } - - override fun doOKAction() { - ClouddebugTelemetry.confirmNotProduction(project, Result.Succeeded) - if (doNotShowAgain.isSelected) { - settings.showEnableDebugWarning = false - } - super.doOKAction() - } - - override fun doCancelAction() { - ClouddebugTelemetry.confirmNotProduction(project, Result.Cancelled) - super.doCancelAction() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentResourceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentResourceAction.kt deleted file mode 100644 index 639fbd5f08..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/InstrumentResourceAction.kt +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.actions - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.PlatformDataKeys -import com.intellij.openapi.project.Project -import software.aws.toolkits.jetbrains.core.credentials.activeRegion -import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.jetbrains.services.ecs.EcsServiceNode -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.jetbrains.utils.cloudDebugIsAvailable -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant - -class InstrumentResourceFromExplorerAction : - SingleResourceNodeAction(message("cloud_debug.instrument"), null) { - override fun actionPerformed(selected: EcsServiceNode, e: AnActionEvent) { - val clusterArn = selected.clusterArn() - val serviceArn = selected.resourceArn() - - InstrumentResourceAction( - clusterArn = clusterArn, - serviceArn = serviceArn, - selected = selected - ).actionPerformed(e) - } - - override fun update(selected: EcsServiceNode, e: AnActionEvent) { - val activeRegion = e.getRequiredData(PlatformDataKeys.PROJECT).activeRegion() - // If there are no supported debuggers, showing this will just be confusing - e.presentation.isVisible = if (DebuggerSupport.debuggers().isEmpty()) { - false - } else { - !EcsUtils.isInstrumented(selected.resourceArn()) && cloudDebugIsAvailable(activeRegion) - } - } -} - -/** - * Implements the "Enable Cloud Debugging" action in the ECS tree of AWS Explorer. - */ -class InstrumentResourceAction( - private val clusterArn: String? = null, - private val serviceArn: String? = null, - private val selected: EcsServiceNode? = null -) : AnAction() { - override fun actionPerformed(e: AnActionEvent) { - val project = e.getRequiredData(PlatformDataKeys.PROJECT) - performAction(project) - } - - fun performAction(project: Project) { - clusterArn ?: return - serviceArn ?: return - - val dialog = InstrumentDialogWrapper(project, clusterArn, serviceArn) - if (dialog.showAndGet()) { - val role = dialog.view.iamRole.selected() ?: throw IllegalStateException("Dialog failed to validate that a role was selected.") - performAction(project, clusterArn, serviceArn, role.arn, selected) - } - } - - companion object { - fun performAction( - project: Project, - clusterArn: String, - serviceArn: String, - roleArn: String, - selected: EcsServiceNode?, - callback: ((Boolean) -> Unit)? = null - ) { - InstrumentAction( - project, - EcsUtils.clusterArnToName(clusterArn), - EcsUtils.serviceArnToName(serviceArn), - roleArn, - message("cloud_debug.instrument_resource.enable"), - message("cloud_debug.instrument_resource.enable.success", EcsUtils.serviceArnToName(serviceArn)), - message("cloud_debug.instrument_resource.enable.fail", EcsUtils.serviceArnToName(serviceArn)) - ).runAction(selected, callback) - } - } -} - -internal class InstrumentAction( - project: Project, - val clusterName: String, - val serviceName: String, - private val roleArn: String, - name: String, - successMessage: String, - failureMessage: String -) : PseCliAction(project, name, successMessage, failureMessage) { - override fun buildCommandLine(cmd: GeneralCommandLine) { - cmd - .withParameters("--verbose") - .withParameters("--json") - .withParameters("instrument") - .withParameters("ecs") - .withParameters("service") - .withParameters("--cluster") - .withParameters(clusterName) - .withParameters("--service") - .withParameters(serviceName) - .withParameters("--iam-role") - .withParameters(roleArn) - } - - override fun produceTelemetry(startTime: Instant, result: Result, version: String?) { - ClouddebugTelemetry.instrument( - project, - result, - version, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble(), - createTime = startTime - ) - } -} 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 deleted file mode 100644 index c8d296dedd..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/PseCliAction.kt +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.actions - -import com.intellij.build.BuildViewManager -import com.intellij.build.DefaultBuildDescriptor -import com.intellij.build.events.impl.FailureResultImpl -import com.intellij.build.events.impl.FinishBuildEventImpl -import com.intellij.build.events.impl.StartBuildEventImpl -import com.intellij.build.events.impl.SuccessResultImpl -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.process.CapturingProcessHandler -import com.intellij.execution.process.ProcessAdapter -import com.intellij.execution.process.ProcessEvent -import com.intellij.execution.process.ProcessOutputTypes -import com.intellij.ide.util.treeView.AbstractTreeNode -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.components.ServiceManager -import com.intellij.openapi.progress.PerformInBackgroundOption -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Key -import com.intellij.openapi.wm.ToolWindowId -import com.intellij.openapi.wm.ToolWindowManager -import org.slf4j.event.Level -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.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 -import software.aws.toolkits.jetbrains.core.executables.ExecutableManager -import software.aws.toolkits.jetbrains.core.executables.getExecutable -import software.aws.toolkits.jetbrains.core.explorer.ExplorerToolWindow -import software.aws.toolkits.jetbrains.services.clouddebug.CliOutputParser -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugResolver -import software.aws.toolkits.jetbrains.services.clouddebug.asLogEvent -import software.aws.toolkits.jetbrains.services.clouddebug.execution.DefaultMessageEmitter -import software.aws.toolkits.jetbrains.services.clouddebug.resources.CloudDebuggingResources -import software.aws.toolkits.jetbrains.services.ecs.EcsClusterNode -import software.aws.toolkits.jetbrains.services.ecs.EcsServiceNode -import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources.describeService -import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources.listServiceArns -import software.aws.toolkits.jetbrains.utils.notifyError -import software.aws.toolkits.jetbrains.utils.notifyInfo -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.Result -import java.time.Instant -import java.util.concurrent.atomic.AtomicReference - -// TODO refactor this whole file -abstract class PseCliAction(val project: Project, val actionName: String, private val successMessage: String, private val failureMessage: String) { - abstract fun buildCommandLine(cmd: GeneralCommandLine) - protected abstract fun produceTelemetry(startTime: Instant, result: Result, version: String?) - - fun runAction(selectedNode: AbstractTreeNode<*>? = null, callback: ((Boolean) -> Unit)? = null) { - ProgressManager.getInstance().run( - object : Task.Backgroundable( - project, - actionName, - false, - PerformInBackgroundOption.ALWAYS_BACKGROUND - ) { - override fun run(indicator: ProgressIndicator) { - - val startTime = Instant.now() - val buildViewManager = ServiceManager.getService(project, BuildViewManager::class.java) - val descriptor = DefaultBuildDescriptor( - actionName, - actionName, - "", - System.currentTimeMillis() - ) - val messageEmitter = DefaultMessageEmitter.createRoot(buildViewManager, actionName) - buildViewManager.onEvent(actionName, StartBuildEventImpl(descriptor, "")) - - val toolWindowManager = ToolWindowManager.getInstance(project) - - runInEdt { - // Safe access because it is possible to close the window before this completes - toolWindowManager.getToolWindow(ToolWindowId.BUILD)?.show(null) - } - // validate CLI - CloudDebugResolver.validateOrUpdateCloudDebug(project, messageEmitter, null) - - 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) { - it - } else { - val error = (it as? ExecutableInstance.BadExecutable)?.validationError ?: message("general.unknown_error") - val errorMessage = message("cloud_debug.step.clouddebug.install.fail", error) - val exception = Exception(errorMessage) - LOG.error(exception) { "Setting cloud-debug executable failed" } - notifyError(message("aws.notification.title"), errorMessage, project) - produceTelemetry(startTime, Result.Failed, null) - messageEmitter.finishExceptionally(exception) - null - } - }.toCompletableFuture().join() ?: run { - callback?.invoke(false) - return - } - - val cmd = clouddebug.getCommandLine() - - cmd - .withEnvironment(region) - .withEnvironment(credentials) - - buildCommandLine(cmd) - - val handler = CapturingProcessHandler(cmd) - - handler.addProcessListener(object : ProcessAdapter() { - val cliOutput = AtomicReference() - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - if (outputType == ProcessOutputTypes.STDOUT) { - cliOutput.set(event.text) - } else { - val (text, level) = event.text.asLogEvent() - @Suppress("DEPRECATION") - messageEmitter.emitMessage(text, level == Level.ERROR) - indicator.text2 = text - // output to the log for diagnostic and integrations tests - LOG.debug { event.text.trim() } - } - } - - override fun processTerminated(event: ProcessEvent) { - val result = if (event.exitCode == 0) { - SuccessResultImpl() - } else { - // TODO: really need to refactor this and steps - it's getting a bit crazy - messageEmitter.emitMessage("Error details:\n", true) - cliOutput.get()?.let { CliOutputParser.parseErrorOutput(it) }?.errors?.forEach { - messageEmitter.emitMessage("\t- $it\n", true) - // output to the log for diagnostic and integrations tests - LOG.debug { "Error details:\n $it" } - } - FailureResultImpl() - } - buildViewManager.onEvent(actionName, FinishBuildEventImpl(actionName, null, System.currentTimeMillis(), "", result)) - } - }) - - val exit = handler.runProcess().exitCode - if (exit == 0) { - notifyInfo( - actionName, - successMessage, - project - ) - // reset the cache - AwsResourceCache.getInstance(project).clear(CloudDebuggingResources.LIST_INSTRUMENTED_RESOURCES) - callback?.invoke(true) - } else { - notifyError( - actionName, - failureMessage, - project - ) - callback?.invoke(false) - } - - // Redraw cluster level if the action was taken from a node - if (selectedNode is EcsServiceNode) { - val parent = selectedNode.parent - if (parent is EcsClusterNode) { - // dump cached values relating to altered service - AwsResourceCache.getInstance(project).clear(describeService(parent.resourceArn(), selectedNode.resourceArn())) - AwsResourceCache.getInstance(project).clear(listServiceArns(parent.resourceArn())) - runInEdt { - // redraw explorer from the cluster downwards - val explorer = ExplorerToolWindow.getInstance(project) - explorer.invalidateTree(parent) - } - } - // If this wasn't run through a node, just redraw the whole tree - // Open to suggestions to making this smarter. - } else { - AwsResourceCache.getInstance(project).clear() - runInEdt { - // redraw explorer from the cluster downwards - val explorer = ExplorerToolWindow.getInstance(project) - explorer.invalidateTree() - } - } - - val result = if (exit == 0) Result.Succeeded else Result.Failed - produceTelemetry(startTime, result, clouddebug.version) - } - } - ) - } - - companion object { - val LOG = getLogger() - } -} 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 deleted file mode 100644 index 8b8e95aad2..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/actions/StartRemoteShellAction.kt +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.actions - -import com.intellij.execution.process.CapturingProcessHandler -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task -import com.intellij.openapi.project.Project -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 -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 -import software.aws.toolkits.jetbrains.services.clouddebug.InstrumentResponse -import software.aws.toolkits.jetbrains.services.clouddebug.resources.CloudDebuggingResources -import software.aws.toolkits.jetbrains.services.ecs.ContainerDetails -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.jetbrains.utils.notifyError -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant - -class StartRemoteShellAction(private val project: Project, private val container: ContainerDetails) : AnAction( - message("cloud_debug.ecs.remote_shell.start"), - null, - TerminalIcons.OpenTerminal_13x13 -) { - - private val disabled by lazy { - !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()) - } - - override fun update(e: AnActionEvent) { - if (disabled) { - e.presentation.isEnabled = false - e.presentation.isVisible = false - return - } - } - - override fun actionPerformed(e: AnActionEvent) { - val containerName = container.containerDefinition.name() - val cluster = container.service.clusterArn() - val service = container.service.serviceArn() - val title = message("cloud_debug.ecs.remote_shell.start") - val startTime = Instant.now() - - ExecutableManager.getInstance().getExecutable().thenAccept { cloudDebugExecutable -> - ProgressManager.getInstance().run(object : Task.Backgroundable(project, title, false) { - override fun run(indicator: ProgressIndicator) { - if (cloudDebugExecutable !is ExecutableInstance.Executable) { - val error = (cloudDebugExecutable as? ExecutableInstance.BadExecutable)?.validationError ?: message("general.unknown_error") - - runInEdt { - notifyError(message("cloud_debug.step.clouddebug.install.fail", error)) - } - throw Exception("cloud debug executable not found") - } - - 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")) - } - throw RuntimeException("Resource somehow became de-instrumented?") - } - val role = description.taskRole - - val target = try { - runInstrument(project, cloudDebugExecutable, cluster, service, role).target - } catch (e: Exception) { - e.notifyError(title) - null - } ?: return - - val cmdLine = buildBaseCmdLine(project, cloudDebugExecutable) - .withParameters("exec") - .withParameters("--target") - .withParameters(target) - /* TODO remove this when the cli conforms to the contract */ - .withParameters("--selector") - .withParameters(containerName) - .withParameters("--tty") - .withParameters("/aws/cloud-debug/common/busybox", "sh", "-i") - - val runner = object : LocalTerminalDirectRunner(project) { - override fun getCommand(envs: MutableMap?) = cmdLine.getCommandLineList(null).toTypedArray() - } - - runInEdt { - TerminalView.getInstance(project).createNewSession(runner, TerminalTabState().also { it.myTabName = containerName }) - } - } - - override fun onSuccess() { - recordTelemetry(Result.Succeeded) - } - - override fun onThrowable(error: Throwable) { - recordTelemetry(Result.Failed) - } - - private fun recordTelemetry(result: Result) { - ClouddebugTelemetry.shell( - project, - // TODO clean up with executable manager changes - version = (cloudDebugExecutable as? ExecutableInstance.Executable)?.version, - result = result, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble(), - createTime = startTime - ) - } - }) - } - } - - private fun buildBaseCmdLine(project: Project, executable: ExecutableInstance.Executable) = executable.getCommandLine() - .withEnvironment(project.activeRegion().toEnvironmentVariables()) - .withEnvironment(project.activeCredentialProvider().resolveCredentials().toEnvironmentVariables()) - - private fun runInstrument(project: Project, executable: ExecutableInstance.Executable, cluster: String, service: String, role: String): InstrumentResponse { - // first instrument to grab the instrumentation target and ensure connection - val instrumentCmd = buildBaseCmdLine(project, executable) - .withParameters("instrument") - .withParameters("ecs") - .withParameters("service") - .withParameters("--cluster") - .withParameters(EcsUtils.clusterArnToName(cluster)) - .withParameters("--service") - .withParameters(EcsUtils.originalServiceName(service)) - .withParameters("--iam-role") - .withParameters(role) - - return CapturingProcessHandler(instrumentCmd).runProcess().stdout.let { - CliOutputParser.parseInstrumentResponse(it) - } ?: throw RuntimeException("CLI provided no response") - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/CloudDebugProcessHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/CloudDebugProcessHandler.kt deleted file mode 100644 index 195271305f..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/CloudDebugProcessHandler.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.services.clouddebug.execution - -import com.intellij.execution.process.ProcessHandler -import java.io.OutputStream - -class CloudDebugProcessHandler(private val context: Context) : ProcessHandler() { - override fun getProcessInput(): OutputStream? = null - - override fun detachIsDefault() = false - - override fun detachProcessImpl() { - destroyProcessImpl() - } - - override fun destroyProcessImpl() { - context.cancel() - } - - public override fun notifyProcessTerminated(exitCode: Int) { - super.notifyProcessTerminated(exitCode) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/CloudDebugRunState.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/CloudDebugRunState.kt deleted file mode 100644 index 56ed9d8860..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/CloudDebugRunState.kt +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution - -import com.intellij.build.BuildDescriptor -import com.intellij.build.BuildView -import com.intellij.build.DefaultBuildDescriptor -import com.intellij.build.ViewManager -import com.intellij.build.events.impl.FailureResultImpl -import com.intellij.build.events.impl.FinishBuildEventImpl -import com.intellij.build.events.impl.StartBuildEventImpl -import com.intellij.build.events.impl.SuccessResultImpl -import com.intellij.execution.DefaultExecutionResult -import com.intellij.execution.ExecutionResult -import com.intellij.execution.Executor -import com.intellij.execution.configurations.RunProfileState -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.runners.ProgramRunner -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.progress.ProcessCanceledException -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.info -import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.RootStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.SetUpPortForwarding -import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.StopApplications -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result - -class CloudDebugRunState( - private val environment: ExecutionEnvironment, - val settings: EcsServiceCloudDebuggingRunSettings -) : RunProfileState { - override fun execute(executor: Executor?, runner: ProgramRunner<*>): ExecutionResult? { - val project = environment.project - val descriptor = DefaultBuildDescriptor( - runConfigId(), - message("cloud_debug.execution.title"), - "/unused/location", - System.currentTimeMillis() - ) - - val buildView = BuildView(project, descriptor, null, object : ViewManager { - override fun isConsoleEnabledByDefault() = false - - override fun isBuildContentView() = true - }) - - val rootStep = RootStep(settings, environment) - val context = Context(project) - val processHandler = CloudDebugProcessHandler(context) - - ApplicationManager.getApplication().executeOnPooledThread { - val messageEmitter = DefaultMessageEmitter.createRoot(buildView, runConfigId()) - var result = Result.Succeeded - try { - startRunConfiguration(descriptor, buildView) - rootStep.run(context, messageEmitter) - finishedSuccessfully(descriptor, processHandler, buildView) - } catch (e: Throwable) { - result = Result.Failed - finishedExceptionally(descriptor, processHandler, buildView, e) - } - - try { - StopApplications(settings, isCleanup = true).run(context, messageEmitter, ignoreCancellation = true) - } catch (e: Exception) { - LOG.info(e) { "Always-run stopping applications step failed with message]: ${e.message}" } - } - - try { - SetUpPortForwarding(settings, enable = false).run(context, messageEmitter, ignoreCancellation = true) - } catch (e: Exception) { - LOG.info(e) { "Always-run stopping port forwarding failed with: ${e.message}" } - } - - ClouddebugTelemetry.startRemoteDebug(project, result, context.workflowToken) - } - - return DefaultExecutionResult(buildView, processHandler) - } - - private fun startRunConfiguration(descriptor: BuildDescriptor, buildView: BuildView) { - buildView.onEvent(descriptor, StartBuildEventImpl(descriptor, message("cloud_debug.execution.running"))) - } - - private fun finishedSuccessfully(descriptor: BuildDescriptor, processHandler: CloudDebugProcessHandler, buildView: BuildView) { - buildView.onEvent( - descriptor, - FinishBuildEventImpl( - runConfigId(), - null, - System.currentTimeMillis(), - message("cloud_debug.execution.success"), - SuccessResultImpl() - ) - ) - - processHandler.notifyProcessTerminated(0) - } - - private fun finishedExceptionally(descriptor: BuildDescriptor, processHandler: CloudDebugProcessHandler, buildView: BuildView, e: Throwable) { - val message = if (e is ProcessCanceledException) { - message("cloud_debug.execution.cancelled") - } else { - message("cloud_debug.execution.failed") - } - - buildView.onEvent( - descriptor, - FinishBuildEventImpl( - runConfigId(), - null, - System.currentTimeMillis(), - message, - FailureResultImpl() - ) - ) - - processHandler.notifyProcessTerminated(1) - } - - private fun runConfigId() = "${environment.runProfile.name}-${environment.executionId}" - - companion object { - private val LOG = getLogger() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/CloudDebuggingRunner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/CloudDebuggingRunner.kt deleted file mode 100644 index 7b55472a8f..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/CloudDebuggingRunner.kt +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution - -import com.intellij.execution.configurations.RunProfile -import com.intellij.execution.configurations.RunProfileState -import com.intellij.execution.configurations.RunnerSettings -import com.intellij.execution.executors.DefaultDebugExecutor -import com.intellij.execution.runners.AsyncProgramRunner -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.runners.RunContentBuilder -import com.intellij.execution.ui.RunContentDescriptor -import org.jetbrains.concurrency.AsyncPromise -import org.jetbrains.concurrency.Promise -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsCloudDebugRunConfiguration - -class CloudDebuggingRunner : AsyncProgramRunner() { - override fun getRunnerId(): String = "CloudDebuggingRunner" - - override fun canRun(executorId: String, profile: RunProfile): Boolean { - if (profile !is EcsCloudDebugRunConfiguration) { - return false - } - - if (DefaultDebugExecutor.EXECUTOR_ID == executorId) { - // Always true so that the debug icon is shown, error is then told to user that runtime doesnt work if it doesn't - return true - } - - return false - } - - override fun execute(environment: ExecutionEnvironment, state: RunProfileState): Promise { - val runPromise = AsyncPromise() - val runContentDescriptor = state.execute(environment.executor, this)?.let { - RunContentBuilder(it, environment).showRunContent(environment.contentToReuse) - } - - runPromise.setResult(runContentDescriptor) - - return runPromise - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/MessageEmitter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/MessageEmitter.kt deleted file mode 100644 index 8f56ebc2a5..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/MessageEmitter.kt +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution - -import com.intellij.build.BuildProgressListener -import com.intellij.build.events.impl.FailureResultImpl -import com.intellij.build.events.impl.FinishEventImpl -import com.intellij.build.events.impl.OutputBuildEventImpl -import com.intellij.build.events.impl.StartEventImpl -import com.intellij.build.events.impl.SuccessResultImpl - -interface MessageEmitter { - fun startStep() - fun emitMessage(message: String, isError: Boolean) - fun finishSuccessfully() - fun finishExceptionally(e: Throwable) - fun createChild(stepName: String, hidden: Boolean = false): MessageEmitter -} - -class DefaultMessageEmitter private constructor( - private val buildListener: BuildProgressListener, - private val rootObject: Any, - private val parentId: String, - private val stepName: String, - private val hidden: Boolean, - private val parent: MessageEmitter? -) : MessageEmitter { - - override fun createChild(stepName: String, hidden: Boolean): DefaultMessageEmitter { - val (childParent, childStepName) = if (hidden) { - parentId to this.stepName - } else { - this.stepName to stepName - } - return DefaultMessageEmitter(buildListener, rootObject, childParent, childStepName, hidden, this) - } - - override fun startStep() { - if (hidden) return - buildListener.onEvent( - rootObject, - StartEventImpl( - stepName, - parentId, - System.currentTimeMillis(), - stepName - ) - ) - } - - override fun finishSuccessfully() { - if (hidden) return - buildListener.onEvent( - rootObject, - FinishEventImpl( - stepName, - parentId, - System.currentTimeMillis(), - stepName, - SuccessResultImpl() - ) - ) - } - - override fun finishExceptionally(e: Throwable) { - emitMessage("$stepName finished exceptionally: $e", true) - if (hidden) return - buildListener.onEvent( - rootObject, - FinishEventImpl( - stepName, - parentId, - System.currentTimeMillis(), - stepName, - FailureResultImpl() - ) - ) - } - - override fun emitMessage(message: String, isError: Boolean) { - parent?.emitMessage(message, isError) - if (hidden) return - buildListener.onEvent( - rootObject, - OutputBuildEventImpl( - stepName, - message, - !isError - ) - ) - } - - companion object { - fun createRoot(buildListener: BuildProgressListener, rootStepName: String, hidden: Boolean = false): MessageEmitter = - DefaultMessageEmitter(buildListener, rootStepName, rootStepName, rootStepName, hidden, null) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/Steps.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/Steps.kt deleted file mode 100644 index 7c598faeee..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/Steps.kt +++ /dev/null @@ -1,237 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.process.CapturingProcessAdapter -import com.intellij.execution.process.OSProcessHandler -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.application.ApplicationManager -import com.intellij.openapi.progress.ProcessCanceledException -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Key -import org.slf4j.event.Level -import software.aws.toolkits.core.utils.AttributeBag -import software.aws.toolkits.core.utils.AttributeBagKey -import software.aws.toolkits.core.utils.debug -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.info -import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.services.clouddebug.CliOutputParser -import software.aws.toolkits.jetbrains.services.clouddebug.asLogEvent -import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.CloudDebugCliValidate -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.Result -import java.time.Instant -import java.util.UUID -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CompletionException -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference - -abstract class Step { - protected abstract val stepName: String - protected open val hidden: Boolean = false - - fun run(context: Context, parentEmitter: MessageEmitter, ignoreCancellation: Boolean = false) { - if (!ignoreCancellation) { - context.throwIfCancelled() - } - - // If we are not hidden, we will create a new factory so that the parent node is correct, else pass the current factory so in effect - // this node does not exist in the hierarchy - val stepEmitter = parentEmitter.createChild(stepName, hidden) - - stepEmitter.startStep() - try { - execute(context, stepEmitter, ignoreCancellation) - - stepEmitter.finishSuccessfully() - } catch (e: Throwable) { - LOG.info(e) { "Step $stepName failed" } - stepEmitter.finishExceptionally(e) - throw e - } - } - - protected abstract fun execute( - context: Context, - messageEmitter: MessageEmitter, - ignoreCancellation: Boolean - ) - - private companion object { - val LOG = getLogger() - } -} - -/** - * [Step] that creates multiple child steps and runs them in parallel waiting on the result. - */ -abstract class ParallelStep : Step() { - private inner class ChildStep(val future: CompletableFuture<*>, val step: Step) - - private val listOfChildTasks = mutableListOf() - - override val hidden = true - - protected abstract fun buildChildSteps(context: Context): List - - final override fun execute( - context: Context, - messageEmitter: MessageEmitter, - ignoreCancellation: Boolean - ) { - buildChildSteps(context).forEach { - val stepFuture = CompletableFuture() - listOfChildTasks.add(ChildStep(stepFuture, it)) - - ApplicationManager.getApplication().executeOnPooledThread { - try { - it.run(context, messageEmitter, ignoreCancellation) - stepFuture.complete(null) - } catch (e: Throwable) { - stepFuture.completeExceptionally(e) - } - } - } - - try { - CompletableFuture.allOf(*listOfChildTasks.map { it.future }.toTypedArray()).join() - } catch (e: CompletionException) { - throw e.cause ?: e - } catch (e: Throwable) { - throw e - } - } -} - -abstract class CliBasedStep : Step() { - private fun getCli(context: Context): GeneralCommandLine = context.getRequiredAttribute(CloudDebugCliValidate.EXECUTABLE_ATTRIBUTE).getCommandLine() - - protected abstract fun constructCommandLine(context: Context, commandLine: GeneralCommandLine) - protected abstract fun recordTelemetry(context: Context, startTime: Instant, result: Result) - - final override fun execute( - context: Context, - messageEmitter: MessageEmitter, - ignoreCancellation: Boolean - ) { - val startTime = Instant.now() - var result = Result.Succeeded - val commandLine = getCli(context) - - constructCommandLine(context, commandLine) - LOG.debug { "Built command line: ${commandLine.commandLineString}" } - - val processHandler = ProcessHandlerFactory.getInstance().createProcessHandler(commandLine) - val processCapture = CapturingProcessAdapter() - processHandler.addProcessListener(processCapture) - processHandler.addProcessListener(CliOutputEmitter(messageEmitter)) - processHandler.startNotify() - - monitorProcess(processHandler, context, ignoreCancellation) - - try { - if (!ignoreCancellation) { - context.throwIfCancelled() - } - - if (processHandler.exitCode == 0) { - handleSuccessResult(processCapture.output.stdout, messageEmitter, context) - return - } - - try { - handleErrorResult(processCapture.output.stdout, messageEmitter) - } catch (e: Exception) { - result = Result.Failed - throw e - } - } catch (e: ProcessCanceledException) { - LOG.warn(e) { "Step \"$stepName\" cancelled!" } - result = Result.Cancelled - } finally { - recordTelemetry(context, startTime, result) - } - } - - private fun monitorProcess(processHandler: OSProcessHandler, context: Context, ignoreCancellation: Boolean) { - while (!processHandler.waitFor(WAIT_INTERVAL_MILLIS)) { - if (!ignoreCancellation && context.isCancelled()) { - if (!processHandler.isProcessTerminating && !processHandler.isProcessTerminated) { - processHandler.destroyProcess() - } - } - } - } - - protected open fun handleSuccessResult(output: String, messageEmitter: MessageEmitter, context: Context) { - } - - /** - * Processes the command's stdout and throws an exception after the CLI exits with failure. - * @return null if the failure should be ignored. You're probably doing something wrong if you want this. - */ - protected open fun handleErrorResult(output: String, messageEmitter: MessageEmitter): Nothing? { - if (output.isNotEmpty()) { - messageEmitter.emitMessage("Error details:\n", true) - CliOutputParser.parseErrorOutput(output)?.run { - errors.forEach { - messageEmitter.emitMessage("\t- $it\n", true) - } - } ?: messageEmitter.emitMessage(output, true) - } - - throw IllegalStateException(message("cloud_debug.step.general.cli_error")) - } - - private class CliOutputEmitter(private val messageEmitter: MessageEmitter) : ProcessAdapter() { - val previousLevel = AtomicReference(null) - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - if (outputType == ProcessOutputTypes.STDERR) { - val logEvent = event.text.replace("\r", "\n").asLogEvent() - val level = logEvent.level ?: previousLevel.get() - messageEmitter.emitMessage(logEvent.text, level == Level.ERROR) - logEvent.level?.let { previousLevel.set(it) } - } - // output to the log for diagnostic and integrations tests - LOG.debug { event.text.trim() } - } - } - - companion object { - private const val WAIT_INTERVAL_MILLIS = 100L - private val LOG = getLogger() - } -} - -class Context(val project: Project) { - val workflowToken = UUID.randomUUID().toString() - private val attributeMap = AttributeBag() - private val isCancelled: AtomicBoolean = AtomicBoolean(false) - - fun cancel() { - isCancelled.set(true) - } - - fun isCancelled() = isCancelled.get() - - fun throwIfCancelled() { - if (isCancelled()) { - throw ProcessCanceledException() - } - } - - fun getAttribute(key: AttributeBagKey): T? = attributeMap.get(key) - - fun getRequiredAttribute(key: AttributeBagKey): T = attributeMap.getOrThrow(key) - - fun putAttribute(key: AttributeBagKey, data: T) { - attributeMap.putData(key, data) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/AttachDebuggers.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/AttachDebuggers.kt deleted file mode 100644 index 53e682ec03..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/AttachDebuggers.kt +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import com.intellij.execution.OutputListener -import com.intellij.execution.process.ProcessEvent -import com.intellij.execution.process.ProcessHandler -import com.intellij.execution.process.ProcessHandlerFactory -import com.intellij.execution.process.ProcessOutputTypes -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.ui.ConsoleView -import com.intellij.execution.ui.ConsoleViewContentType -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.util.Key -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.MessageEmitter -import software.aws.toolkits.jetbrains.services.clouddebug.execution.ParallelStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Step -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings -import software.aws.toolkits.jetbrains.services.ecs.execution.ImmutableContainerOptions -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.CloudDebugPlatform -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant -import java.util.concurrent.ExecutionException - -/** - * Implements the "Debug $SERVICE" menu item in the ECS tree of AWS Explorer. - */ -class SetUpDebuggers(private val settings: EcsServiceCloudDebuggingRunSettings, private val environment: ExecutionEnvironment) : ParallelStep() { - override val stepName = message("cloud_debug.step.attach_debugger") - override val hidden = false - - override fun buildChildSteps(context: Context): List = settings.containerOptions.map { (containerName, options) -> - AttachDebugger(containerName, options, environment) - } -} - -/** - * Implements the "Debug $SERVICE" menu item in the ECS tree of AWS Explorer. - */ -class AttachDebugger( - private val containerName: String, - private val containerOptions: ImmutableContainerOptions, - private val environment: ExecutionEnvironment -) : Step() { - override val stepName = message("cloud_debug.step.attach_debugger.resource", containerName) - - override fun execute( - context: Context, - messageEmitter: MessageEmitter, - ignoreCancellation: Boolean - ) { - val startTime = Instant.now() - var result = Result.Succeeded - try { - val debuggerAttacher = DebuggerSupport.debuggers()[containerOptions.platform] - ?: throw IllegalStateException( - message( - "cloud_debug.step.attach_debugger.unknown_platform", containerOptions.platform - ) - ) - - val debugPorts = SetUpPortForwarding.getDebugPortsForContainer(context, containerName) - - val attachDebuggerFuture = debuggerAttacher.attachDebugger(context, containerName, containerOptions, environment, debugPorts, stepName) - val descriptor = attachDebuggerFuture.get() - var stdoutLogsHandler: ProcessHandler? = null - var stderrLogsHandler: ProcessHandler? = null - - runInEdt { - descriptor?.runnerLayoutUi?.let { - val console = debuggerAttacher.getConsoleView(environment, it) - stdoutLogsHandler = tailContainerLogs(context, console, false) - stderrLogsHandler = tailContainerLogs(context, console, true) - } - } - - // This should block so that the run config does not complete until debuggers die, or we can kill all from the stop command - descriptor?.processHandler?.let { - waitForDebuggerToDisconnect(it, context) - // debugger has disconnected so we should stop tailing logs, safe access is needed due to initiliazation requirements - ApplicationManager.getApplication().executeOnPooledThread { - Thread.sleep(5000) - stdoutLogsHandler?.destroyProcess() - stderrLogsHandler?.destroyProcess() - } - } ?: throw IllegalStateException(message("cloud_debug.step.attach_debugger.failed")) - } catch (e: ExecutionException) { - result = Result.Failed - throw e.cause ?: e - } finally { - ClouddebugTelemetry.attachDebugger( - project = context.project, - result = result, - workflowtoken = context.workflowToken, - clouddebugplatform = CloudDebugPlatform.from(containerOptions.platform.name), - value = Duration.between(startTime, Instant.now()).toMillis().toDouble(), - createTime = startTime - ) - } - } - - private fun tailContainerLogs(context: Context, console: ConsoleView, stderr: Boolean): ProcessHandler { - val commandLine = context.getRequiredAttribute(CloudDebugCliValidate.EXECUTABLE_ATTRIBUTE).getCommandLine() - .withParameters("--target") - .withParameters(ResourceInstrumenter.getTargetForContainer(context, containerName)) - /* TODO remove this when the cli conforms to the contract */ - .withParameters("--selector") - .withParameters(containerName) - .withParameters("logs") - .withParameters("--follow") - - if (stderr) { - commandLine.withParameters("--filter", "stderr") - } else { - commandLine.withParameters("--filter", "stdout") - } - - val handler = ProcessHandlerFactory.getInstance().createProcessHandler(commandLine) - - val viewType = if (stderr) { - ConsoleViewContentType.ERROR_OUTPUT - } else { - ConsoleViewContentType.NORMAL_OUTPUT - } - - handler.addProcessListener(object : OutputListener() { - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - // Skip system messages - if (outputType == ProcessOutputTypes.SYSTEM) { - return - } - console.print(event.text, viewType) - } - }) - - handler.startNotify() - - return handler - } - - private fun waitForDebuggerToDisconnect(processHandler: ProcessHandler, context: Context) { - while (!processHandler.waitFor(WAIT_INTERVAL_MILLIS)) { - if (context.isCancelled()) { - if (!processHandler.isProcessTerminating && !processHandler.isProcessTerminated) { - processHandler.destroyProcess() - } - } - } - } - - private companion object { - private const val WAIT_INTERVAL_MILLIS = 100L - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/CloudDebugCliValidate.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/CloudDebugCliValidate.kt deleted file mode 100644 index 890289727d..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/CloudDebugCliValidate.kt +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import software.aws.toolkits.core.utils.AttributeBagKey -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.executables.CloudDebugExecutable -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugResolver -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.MessageEmitter -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Step -import software.aws.toolkits.resources.message - -class CloudDebugCliValidate : Step() { - override val stepName = "Checking for cloud-debug validity and updates" - - override fun execute(context: Context, messageEmitter: MessageEmitter, ignoreCancellation: Boolean) { - CloudDebugResolver.validateOrUpdateCloudDebug(context.project, messageEmitter, context) - } - - companion object { - /* - * Load and validate the cloud-debug executable. If it is not found or fails to validate, it throws a RuntimeException. - */ - fun validateAndLoadCloudDebugExecutable(): ExecutableInstance.Executable = - ExecutableManager.getInstance().getExecutable().thenApply { - when (it) { - is ExecutableInstance.Executable -> it - is ExecutableInstance.UnresolvedExecutable -> throw RuntimeException(message("cloud_debug.step.clouddebug.resolution.fail")) - is ExecutableInstance.InvalidExecutable -> throw RuntimeException(it.validationError) - } - }.toCompletableFuture().join() - - val EXECUTABLE_ATTRIBUTE = AttributeBagKey.create("clouddebug.executable") - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/CopyArtifacts.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/CopyArtifacts.kt deleted file mode 100644 index ccd1871eda..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/CopyArtifacts.kt +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import com.intellij.execution.configurations.GeneralCommandLine -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.jetbrains.services.clouddebug.execution.CliBasedStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.ParallelStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Step -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant - -class CopyArtifactsStep(private val settings: EcsServiceCloudDebuggingRunSettings) : ParallelStep() { - override val stepName = message("cloud_debug.step.sync") - override val hidden = false - - override fun buildChildSteps(context: Context): List { - val containerMap = mutableMapOf() - - val stepList = settings.containerOptions.map { - it.value.artifactMappings.map { artifactMapping -> - val localPath = artifactMapping.localPath - val remotePath = artifactMapping.remotePath - - ResourceTransferStep(localPath, remotePath, it.key) - }.also { _ -> - containerMap[it.value.platform] = it.key - } - }.flatten() - - // since we copy to the shared volume, it does not matter that much which container we copy to, but - // we only want to copy once, so build those steps out of the computed set - return stepList.plus( - containerMap.mapNotNull { - addDebuggerIfNeeded(it.value, it.key, context) - } - ) - } - - private fun addDebuggerIfNeeded(containerName: String, platform: CloudDebuggingPlatform, context: Context): Step? { - val debuggerSupport = - DebuggerSupport.debuggers()[platform] ?: throw IllegalStateException(message("cloud_debug.run_configuration.bad_runtime", platform)) - return debuggerSupport.createDebuggerUploadStep(context, containerName) - } -} - -class ResourceTransferStep(private val localPath: String, private val remotePath: String, private val containerName: String) : CliBasedStep() { - override val stepName = message("cloud_debug.step.copy_folder", localPath) - - override fun constructCommandLine(context: Context, commandLine: GeneralCommandLine) { - // TODO: Update with token based CLI - commandLine - .withParameters("--verbose") - .withParameters("--json") - .withParameters("copy") - .withParameters("--src") - .withParameters(localPath) - .withParameters("--dest") - .withParameters("remote://${ResourceInstrumenter.getTargetForContainer(context, containerName)}://$containerName://$remotePath") - } - - override fun recordTelemetry(context: Context, startTime: Instant, result: Result) { - ClouddebugTelemetry.copy( - context.project, - result = result, - workflowtoken = context.workflowToken, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble() - ) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/PortForwarding.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/PortForwarding.kt deleted file mode 100644 index 1dafa14e15..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/PortForwarding.kt +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.util.net.NetUtils -import software.aws.toolkits.core.utils.AttributeBagKey -import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.jetbrains.services.clouddebug.execution.CliBasedStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.ParallelStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Step -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant - -class SetUpPortForwarding(private val settings: EcsServiceCloudDebuggingRunSettings, private val enable: Boolean = true) : ParallelStep() { - override val stepName = if (enable) { - message("cloud_debug.step.port_forward") - } else { - message("cloud_debug.step.port_forward_cleanup") - } - override val hidden = false - - override fun buildChildSteps(context: Context): List { - val portForwards = mutableListOf() - - settings.containerOptions.forEach { entry -> - val container = entry.key - val remoteDebugPorts = entry.value.remoteDebugPorts - val containerLocalPorts = selectDebugPorts().getOrElse(container) { - throw IllegalStateException("Local port list is missing for $container") - } - - if (enable) { - context.putAttribute(createLocalDebugPortKey(container), containerLocalPorts) - } - - containerLocalPorts.zip(remoteDebugPorts).forEach { (local, remote) -> - portForwards.add(PortForwarder(settings, container, local, remote, enable)) - } - - val ports = entry.value.portMappings - ports.forEach { (localPort, remotePort) -> - portForwards.add(PortForwarder(settings, container, localPort, remotePort, enable)) - } - } - - return portForwards - } - - private fun selectDebugPorts(): Map> { - val numberOfPortsRequired = settings.containerOptions - .values - .sumBy { DebuggerSupport.debugger(it.platform).numberOfDebugPorts } - - val ports = NetUtils.findAvailableSocketPorts(numberOfPortsRequired).toList() - - var index = 0 - return settings.containerOptions - .map { - val numberOfPorts = DebuggerSupport.debugger(it.value.platform).numberOfDebugPorts - val containerPorts = ports.subList(index, index + numberOfPorts) - - index += numberOfPorts - - it.key to containerPorts - } - .toMap() - } - - companion object { - private fun createLocalDebugPortKey(containerName: String): AttributeBagKey> = AttributeBagKey.create("localDebugPort-$containerName") - - fun getDebugPortsForContainer(context: Context, containerName: String): List = context.getRequiredAttribute(createLocalDebugPortKey(containerName)) - } -} - -class PortForwarder( - private val settings: EcsServiceCloudDebuggingRunSettings, - private val containerName: String, - private val localPort: Int, - private val remotePort: Int, - private val enable: Boolean -) : CliBasedStep() { - // Convert ports to strings first, else it formats 8080 as 8,080 - override val stepName = message( - "cloud_debug.step.port_forward.resource", - if (enable) "Forward" else "Disable forwarding", - containerName, - remotePort.toString(), - localPort.toString() - ) - - override fun constructCommandLine(context: Context, commandLine: GeneralCommandLine) { - commandLine - .withParameters("--verbose") - .withParameters("--json") - .withParameters("forward") - .withParameters("--port") - .withParameters("$localPort:$remotePort") - .withParameters("--target") - .withParameters(ResourceInstrumenter.getTargetForContainer(context, containerName)) - /* TODO remove this when the cli conforms to the ocntract */ - .withParameters("--selector") - .withParameters(containerName) - .withParameters("--operation") - .withParameters(if (enable) "enable" else "disable") - .withEnvironment(settings.region.toEnvironmentVariables()) - .withEnvironment(settings.credentialProvider.resolveCredentials().toEnvironmentVariables()) - } - - override fun recordTelemetry(context: Context, startTime: Instant, result: Result) { - ClouddebugTelemetry.portForward( - context.project, - result, - workflowtoken = context.workflowToken, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble(), - createTime = startTime - ) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/PreStartSteps.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/PreStartSteps.kt deleted file mode 100644 index 142cac0c3b..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/PreStartSteps.kt +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.ParallelStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Step -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings - -class PreStartSteps(private val settings: EcsServiceCloudDebuggingRunSettings) : ParallelStep() { - override val stepName = "Pre-Start" - - override fun buildChildSteps(context: Context): List = listOf( - CopyArtifactsStep(settings), - SetUpPortForwarding(settings), - StopApplications(settings, false) - ) -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/ResourceInstrumenter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/ResourceInstrumenter.kt deleted file mode 100644 index ad6e7593d8..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/ResourceInstrumenter.kt +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import com.intellij.execution.configurations.GeneralCommandLine -import software.aws.toolkits.core.utils.AttributeBagKey -import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables -import software.aws.toolkits.jetbrains.services.clouddebug.CliOutputParser -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants -import software.aws.toolkits.jetbrains.services.clouddebug.execution.CliBasedStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.MessageEmitter -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant - -class ResourceInstrumenter(private val settings: EcsServiceCloudDebuggingRunSettings) : CliBasedStep() { - override val stepName = message("cloud_debug.step.instrument", EcsUtils.serviceArnToName(settings.serviceArn)) - - override fun constructCommandLine(context: Context, commandLine: GeneralCommandLine) { - val iamRole = context.getRequiredAttribute(CloudDebugConstants.INSTRUMENT_IAM_ROLE_KEY) - val serviceName = EcsUtils.originalServiceName(settings.serviceArn) - - commandLine - .withParameters("--verbose") - .withParameters("--json") - .withParameters("instrument") - .withParameters("ecs") - .withParameters("service") - .withParameters("--cluster") - .withParameters(EcsUtils.clusterArnToName(settings.clusterArn)) - .withParameters("--service") - .withParameters(serviceName) - .withParameters("--iam-role") - .withParameters(iamRole) - .withEnvironment(settings.region.toEnvironmentVariables()) - .withEnvironment(settings.credentialProvider.resolveCredentials().toEnvironmentVariables()) - } - - override fun recordTelemetry(context: Context, startTime: Instant, result: Result) { - ClouddebugTelemetry.instrument( - context.project, - result = result, - workflowtoken = context.workflowToken, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble() - ) - } - - override fun handleSuccessResult(output: String, messageEmitter: MessageEmitter, context: Context) { - val targets = CliOutputParser.parseInstrumentResponse(output) ?: throw RuntimeException("Could not get targets from response") - /* TODO uncomment this when the cli conforms to the contract - val mappedTargets = targets.targets.map { it.name to it.target }.toMap() - - context.putAttribute(TARGET_MAPPING, mappedTargets)*/ - context.putAttribute(TARGET_MAPPING, targets.target) - } - - companion object { - /* TODO uncomment this when the cli conforms to the contract - private val TARGET_MAPPING = AttributeBagKey.create>("targetMapping") - */ - private val TARGET_MAPPING = AttributeBagKey.create("targetMapping") - - @Suppress("UNUSED_PARAMETER") - fun getTargetForContainer(context: Context, containerName: String): String = context.getRequiredAttribute(TARGET_MAPPING) - /* TODO uncomment this when the cli conforms to the contract - fun getTargetForContainer(context: Context, containerName: String): String = context.getRequiredAttribute(TARGET_MAPPING) - .getOrElse(containerName) { throw IllegalStateException("No target mapping for $containerName") } - */ - } -} 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 deleted file mode 100644 index ddd5687091..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/RetrieveRole.kt +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants.INSTRUMENTED_STATUS -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants.INSTRUMENT_IAM_ROLE_KEY -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.MessageEmitter -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Step -import software.aws.toolkits.jetbrains.services.clouddebug.resources.CloudDebuggingResources -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant - -// TODO when doing the correct service call remove project since we don't need it anymore -class RetrieveRole(private val settings: EcsServiceCloudDebuggingRunSettings) : Step() { - override val stepName = message("cloud_debug.step.retrieve_execution_role") - - override fun execute( - context: Context, - messageEmitter: MessageEmitter, - ignoreCancellation: Boolean - ) { - val startTime = Instant.now() - var result = Result.Succeeded - try { - 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?") - } - - context.putAttribute(INSTRUMENT_IAM_ROLE_KEY, description.taskRole) - } catch (e: Exception) { - result = Result.Failed - throw RuntimeException(message("cloud_debug.step.retrieve_execution_role.failed"), e) - } finally { - ClouddebugTelemetry.retrieveRole( - project = context.project, - workflowtoken = context.workflowToken, - result = result, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble(), - createTime = startTime - ) - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/RootStep.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/RootStep.kt deleted file mode 100644 index 181fe3e2e6..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/RootStep.kt +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import com.intellij.execution.runners.ExecutionEnvironment -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.MessageEmitter -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Step -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings - -class RootStep(settings: EcsServiceCloudDebuggingRunSettings, environment: ExecutionEnvironment) : Step() { - override val stepName: String = "RootStep" - override val hidden = true - - private val topLevelSteps: List = listOf( - CloudDebugCliValidate(), - RetrieveRole(settings), - ResourceInstrumenter(settings), - PreStartSteps(settings), - SetUpStartApplications(settings), - SetUpDebuggers(settings, environment) - ) - - override fun execute( - context: Context, - messageEmitter: MessageEmitter, - ignoreCancellation: Boolean - ) { - topLevelSteps.forEach { - it.run(context, messageEmitter) - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/StartApplications.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/StartApplications.kt deleted file mode 100644 index 5fbf35fbdf..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/StartApplications.kt +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.util.execution.ParametersListUtil -import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables -import software.aws.toolkits.jetbrains.services.clouddebug.execution.CliBasedStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.ParallelStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Step -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant - -class SetUpStartApplications(private val settings: EcsServiceCloudDebuggingRunSettings) : ParallelStep() { - override val stepName = message("cloud_debug.step.start_application") - override val hidden = false - - override fun buildChildSteps(context: Context): List = settings.containerOptions.map { (containerName, options) -> - StartApplication(settings, containerName, options.startCommand) - } -} - -class StartApplication( - private val settings: EcsServiceCloudDebuggingRunSettings, - private val containerName: String, - private val startCommand: String -) : CliBasedStep() { - override val stepName = message("cloud_debug.step.start_application.resource", containerName) - - override fun constructCommandLine(context: Context, commandLine: GeneralCommandLine) { - commandLine - .withParameters("--verbose") - .withParameters("--json") - .withParameters("start") - .withParameters("--target") - .withParameters(ResourceInstrumenter.getTargetForContainer(context, containerName)) - /* TODO remove this when the cli conforms to the contract */ - .withParameters("--selector") - .withParameters(containerName) - .withParameters(ParametersListUtil.parse(startCommand, false, false)) - .withEnvironment(settings.region.toEnvironmentVariables()) - .withEnvironment(settings.credentialProvider.resolveCredentials().toEnvironmentVariables()) - } - - override fun recordTelemetry(context: Context, startTime: Instant, result: Result) { - ClouddebugTelemetry.startApplication( - context.project, - result = result, - workflowtoken = context.workflowToken, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble() - ) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/StopApplications.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/StopApplications.kt deleted file mode 100644 index 21086bbea0..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/execution/steps/StopApplications.kt +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.execution.steps - -import com.intellij.execution.configurations.GeneralCommandLine -import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables -import software.aws.toolkits.jetbrains.services.clouddebug.execution.CliBasedStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.clouddebug.execution.MessageEmitter -import software.aws.toolkits.jetbrains.services.clouddebug.execution.ParallelStep -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Step -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsServiceCloudDebuggingRunSettings -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.ClouddebugTelemetry -import software.aws.toolkits.telemetry.Result -import java.time.Duration -import java.time.Instant - -class StopApplications( - private val settings: EcsServiceCloudDebuggingRunSettings, - private val isCleanup: Boolean -) : ParallelStep() { - override val stepName = if (isCleanup) { - message("cloud_debug.step.stop_application.cleanup") - } else { - message("cloud_debug.step.stop_application.pre_start") - } - override val hidden = false - - override fun buildChildSteps(context: Context): List = settings.containerOptions.map { (containerName, _) -> - StopApplication(settings, containerName, isCleanup) - } -} - -class StopApplication( - private val settings: EcsServiceCloudDebuggingRunSettings, - private val containerName: String, - private val isCleanup: Boolean -) : CliBasedStep() { - override val stepName = if (isCleanup) { - message("cloud_debug.step.stop_application.cleanup.resource", containerName) - } else { - message("cloud_debug.step.stop_application.pre_start.resource", containerName) - } - - override fun constructCommandLine(context: Context, commandLine: GeneralCommandLine) { - commandLine - .withParameters("--verbose") - .withParameters("--json") - .withParameters("stop") - .withParameters("--target") - .withParameters(ResourceInstrumenter.getTargetForContainer(context, containerName)) - /* TODO remove this when the cli conforms to the contract */ - .withParameters("--selector") - .withParameters(containerName) - .withEnvironment(settings.region.toEnvironmentVariables()) - .withEnvironment(settings.credentialProvider.resolveCredentials().toEnvironmentVariables()) - } - - override fun recordTelemetry(context: Context, startTime: Instant, result: Result) { - ClouddebugTelemetry.stopApplication( - context.project, - result = result, - workflowtoken = context.workflowToken, - value = Duration.between(startTime, Instant.now()).toMillis().toDouble() - ) - } - - override fun handleErrorResult(output: String, messageEmitter: MessageEmitter) = - if (isCleanup) { - super.handleErrorResult(output, messageEmitter) - } else { - messageEmitter.emitMessage(output, true) - // suppress the error if stop fails - null - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/java/JvmDebuggerSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/java/JvmDebuggerSupport.kt deleted file mode 100644 index 7ac65f4bdb..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/java/JvmDebuggerSupport.kt +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.java - -import com.intellij.execution.RunManager -import com.intellij.execution.configurations.RuntimeConfigurationError -import com.intellij.execution.remote.RemoteConfiguration -import com.intellij.execution.remote.RemoteConfigurationType -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.ui.RunContentDescriptor -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.ecs.execution.ImmutableContainerOptions -import software.aws.toolkits.resources.message -import java.util.concurrent.CompletableFuture - -class JvmDebuggerSupport : DebuggerSupport() { - override val platform = CloudDebuggingPlatform.JVM - override val debuggerPath: DebuggerPath? = null - - override fun automaticallyAugmentable(input: List): Boolean { - if (input.first().trim('"', '\'').substringAfterLast('/') != JAVA_EXECUTABLE) { - return false - } - // Finally, check that we are not using java debugging arguments - input.forEach { - if (it.contains(JAVA_DEBUG)) { - throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.augment.debug_information_already_inside", - JAVA_DEBUG - ) - ) - } - if (it.contains(JAVA_5_DEBUG)) { - throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.augment.debug_information_already_inside", - JAVA_5_DEBUG - ) - ) - } - } - return true - } - - override fun attachDebuggingArguments(input: List, ports: List, debuggerPath: String): String = - "${input.firstOrNull()} -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=${ports.first()} ${input.drop(1).joinToString(" ")}" - - override fun attachDebugger( - context: Context, - containerName: String, - containerOptions: ImmutableContainerOptions, - environment: ExecutionEnvironment, - ports: List, - displayName: String - ): CompletableFuture { - val runSettings = RunManager.getInstance(environment.project).createConfiguration(displayName, RemoteConfigurationType::class.java) - runSettings.isActivateToolWindowBeforeRun = false - // hack in case the user modified their Java Remote configuration template - runSettings.configuration.beforeRunTasks = emptyList() - - (runSettings.configuration as RemoteConfiguration).apply { - HOST = LOCALHOST_NAME - PORT = ports.first().toString() - USE_SOCKET_TRANSPORT = true - SERVER_MODE = false - } - - return executeConfiguration(environment, runSettings) - } - - private companion object { - // java start command - private const val JAVA_EXECUTABLE = "java" - /* - * This is the debugging syntax pre java 5. it is still valid but requires 2 flags (with -Xdebug as well), so - * we go with the new syntax. - */ - private const val JAVA_DEBUG = "-Xrunjdwp:transport=" - /* - * This is valid for Java 5 and higher. - */ - private const val JAVA_5_DEBUG = "-agentlib:jdwp=transport" - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/python/PythonDebuggerSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/python/PythonDebuggerSupport.kt deleted file mode 100644 index 97c249b41b..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/python/PythonDebuggerSupport.kt +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.python - -import com.intellij.execution.filters.TextConsoleBuilderFactory -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.ui.RunContentDescriptor -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.project.Project -import com.intellij.xdebugger.XDebugProcess -import com.intellij.xdebugger.XDebugProcessStarter -import com.intellij.xdebugger.XDebugSession -import com.intellij.xdebugger.XDebuggerManager -import com.jetbrains.python.PythonHelper -import com.jetbrains.python.debugger.PyDebugProcess -import software.aws.toolkits.jetbrains.services.PathMapper -import software.aws.toolkits.jetbrains.services.PathMapping -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.jetbrains.services.clouddebug.execution.Context -import software.aws.toolkits.jetbrains.services.ecs.execution.ImmutableContainerOptions -import java.util.concurrent.CompletableFuture - -class PythonDebuggerSupport : DebuggerSupport() { - override val platform = CloudDebuggingPlatform.PYTHON - - // python start command. This is a regex because we can have valid start commands like python3.7 or just python - // The space after "python" is intentional: it makes statements like "python.sh" not match - private val pythonStartRegex = """^python((\d+\.\d+)|(\d+))?$""".toRegex() - - override val debuggerPath = object : DebuggerPath { - override fun getDebuggerPath(): String = PythonHelper.DEBUGGER.pythonPathEntry - - override fun getDebuggerEntryPoint(): String = "${getRemoteDebuggerPath()}/pydev/pydevd.py" - - // TODO fix when cloud-debug fixes rsync to run mkdir -p - override fun getRemoteDebuggerPath(): String = - // "/aws/cloud-debug/debugger/$platform" - "/aws/$platform" - } - - override fun attachDebugger( - context: Context, - containerName: String, - containerOptions: ImmutableContainerOptions, - environment: ExecutionEnvironment, - ports: List, - displayName: String - ): CompletableFuture { - val manager = XDebuggerManager.getInstance(environment.project) - val future = CompletableFuture() - - runInEdt { - try { - val descriptor = manager.startSessionAndShowTab( - displayName, null, - startDebugProcess(containerOptions, environment.project, ports.first()) - ).runContentDescriptor - future.complete(descriptor) - } catch (e: Exception) { - future.completeExceptionally(e) - } - } - - return future - } - - override fun automaticallyAugmentable(input: List): Boolean = input.first().trim('"', '\'').substringAfterLast('/').contains(pythonStartRegex) - - override fun attachDebuggingArguments(input: List, ports: List, debuggerPath: String): String = - "${input.first()} -u $debuggerPath --multiprocess --port ${ports.first()} --file ${input.drop(1).joinToString(" ")}" - - private fun startDebugProcess(containerOptions: ImmutableContainerOptions, project: Project, port: Int) = object : XDebugProcessStarter() { - override fun start(session: XDebugSession): XDebugProcess { - val console = TextConsoleBuilderFactory.getInstance().createBuilder(project).console - - return PyDebugProcess( - session, - console, - null, - LOCALHOST_NAME, - port - ).also { - it.positionConverter = PathMapper.PositionConverter( - PathMapper( - convertArtifactMappingsToPathMappings( - containerOptions.artifactMappings, debuggerPath - ).map { pair -> - PathMapping(pair.first, pair.second) - } - ) - ) - } - } - } -} 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 deleted file mode 100644 index 3b5f710c7d..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/clouddebug/resources/CloudDebuggingResources.kt +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.clouddebug.resources - -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.DeserializationFeature -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.CapturingProcessHandler -import com.intellij.execution.process.CapturingProcessRunner -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.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.toEnvironmentVariables -import software.aws.toolkits.jetbrains.core.executables.CloudDebugExecutable -import software.aws.toolkits.jetbrains.services.clouddebug.execution.MessageEmitter -import software.aws.toolkits.jetbrains.services.clouddebug.execution.steps.CloudDebugCliValidate -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import java.util.concurrent.TimeUnit - -object CloudDebuggingResources { - private val OBJECT_MAPPER = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - val LIST_INSTRUMENTED_RESOURCES: Resource> = - ExecutableBackedCacheResource(CloudDebugExecutable::class, "cdb.list_resources") { - val results = mutableSetOf() - - this.withParameters("list") - - var nextToken: String? = null - do { - nextToken = callListInstrumentedResources(this, results, nextToken) - } while (!nextToken.isNullOrEmpty()) - - results - } - - /* - * Describes instrumented resources using cluster name/arn and service name/arn. Input can be original or instrumented service - */ - fun describeInstrumentedResource( - credentialsProvider: ToolkitCredentialsProvider, - region: AwsRegion, - clusterName: String, - serviceName: String - ): DescribeResult? { - val execTask = try { - CloudDebugCliValidate.validateAndLoadCloudDebugExecutable() - } catch (e: Exception) { - LOG.warn(e) { "Failed to validate cloud debug executable while attempting to do a describe call" } - return null - } - - val credentialsEnvVars = credentialsProvider.resolveCredentials().toEnvironmentVariables() - val regionEnvVars = region.toEnvironmentVariables() - - val generalCommandLine = execTask.getCommandLine() - .withParameters("describe") - .withParameters("--cluster") - .withParameters(EcsUtils.serviceArnToName(clusterName)) - .withParameters("--service") - .withParameters(EcsUtils.originalServiceName(serviceName)) - .withEnvironment(credentialsEnvVars) - .withEnvironment(regionEnvVars) - - return try { - val processOutput = CapturingProcessRunner( - ProcessHandlerFactory.getInstance().createProcessHandler(generalCommandLine) - ).runProcess(TimeUnit.SECONDS.toMillis(5).toInt()) // TODO: Is this a good timeout? It will make AWS calls... - - check(!processOutput.isTimeout) { "Timed out" } - check(processOutput.exitCode == 0) { "Did not exit successfully" } - - OBJECT_MAPPER.readValue(processOutput.stdout) - } catch (e: Exception) { - LOG.warn(e) { "Unable to describe the instrumentation status of the resource cluster:$clusterName service:$serviceName!" } - null - } - } - - // Do a best effort shutdown of cloud debug dispatcher - fun shutdownCloudDebugDispatcher(messageEmitter: MessageEmitter? = null) { - val shutdownTask = try { - CloudDebugCliValidate.validateAndLoadCloudDebugExecutable() - } catch (e: Exception) { - LOG.warn(e) { "Failed to validate cloud debug executable while attempting to do a describe call" } - return - } - val generalCommandLine = shutdownTask.getCommandLine().withParameters("shutdown") - try { - val handler = CapturingProcessHandler(generalCommandLine) - handler.addProcessListener(object : ProcessAdapter() { - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - messageEmitter?.emitMessage(event.text, outputType == ProcessOutputTypes.STDERR) - } - }) - handler.runProcess(TimeUnit.SECONDS.toMillis(5).toInt()) - } catch (e: Exception) { - LOG.warn(e) { "Unable to shutdown the local dispatcher!" } - messageEmitter?.emitMessage("Unable to shutdown the local dispatcher $e", true) - } - } - - private fun callListInstrumentedResources(generalCommandLine: GeneralCommandLine, results: MutableSet, nextToken: String?): String? { - nextToken?.let { - generalCommandLine.parametersList.replaceOrAppend("--next-token", nextToken) - } - - val processOutput = CapturingProcessRunner( - ProcessHandlerFactory.getInstance().createProcessHandler(generalCommandLine) - ).runProcess(TimeUnit.SECONDS.toMillis(30).toInt()) // TODO: Is this a good timeout? It will make AWS calls... - - check(!processOutput.isTimeout) { "Timed out" } - check(processOutput.exitCode == 0) { "Did not exit successfully" } - - val cliResult = OBJECT_MAPPER.readValue(processOutput.stdout) - results.addAll(cliResult.resources) - - return cliResult.nextToken - } - - data class DescribeResult( - val taskRole: String, - val status: String, - val debugServiceName: String - ) - - data class ListResultEntry( - @JsonProperty("type") val serviceType: String, - val clusterName: String, - val serviceName: String - ) - - private data class ListInstrumentedResources( - val resources: List, - @JsonProperty("next-token") val nextToken: String - ) - - private val LOG = getLogger() -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormation.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormation.kt index 858aff1df8..0548ad1a6f 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormation.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormation.kt @@ -42,6 +42,20 @@ fun CloudFormationClient.describeStack(stackName: String, callback: (Stack?) -> } } +fun CloudFormationClient.describeStackForSync(stackName: String, enableParamsAndTags: (Boolean) -> Unit, callback: (Stack?) -> Unit) { + ApplicationManager.getApplication().executeOnPooledThread { + try { + enableParamsAndTags(false) + val stack = this.describeStacks { it.stackName(stackName) }.stacks().firstOrNull() + callback(stack) + } catch (e: Exception) { + /* no-op */ + } finally { + enableParamsAndTags(true) + } + } +} + private val CFN_CREATE_FAILURE_TERMINAL_STATES = setOf( StackStatus.CREATE_FAILED, StackStatus.DELETE_COMPLETE, @@ -130,7 +144,9 @@ fun CloudFormationClient.waitForStackDeletionComplete( fail = { stack -> if (stack.stackStatus() in CFN_DELETE_FAILURE_TERMINAL_STATES) { message("cloudformation.delete_stack.failed", stack.stackName(), stack.stackStatus()) - } else null + } else { + null + } }, successByException = { e -> e is CloudFormationException && e.awsErrorDetails().errorCode() == "ValidationError" }, timeoutErrorMessage = message("cloudformation.delete_stack.timeout", stackName, maxAttempts * delay.seconds), diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationExplorerNodes.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationExplorerNodes.kt index a6ad85ab1c..5500eb01a2 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationExplorerNodes.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationExplorerNodes.kt @@ -15,12 +15,14 @@ import software.aws.toolkits.jetbrains.core.explorer.nodes.CacheBackedAwsExplore import software.aws.toolkits.jetbrains.services.cloudformation.resources.CloudFormationResources import software.aws.toolkits.jetbrains.services.cloudformation.stack.StackWindowManager import software.aws.toolkits.jetbrains.utils.toHumanReadable +import software.aws.toolkits.resources.message class CloudFormationServiceNode(project: Project, service: AwsExplorerServiceNode) : CacheBackedAwsExplorerServiceRootNode( project, service, CloudFormationResources.ACTIVE_STACKS ) { + override fun displayName(): String = message("explorer.node.cloudformation") override fun toNode(child: StackSummary): AwsExplorerNode<*> = CloudFormationStackNode(nodeProject, child.stackName(), child.stackStatus(), child.stackId()) } @@ -30,10 +32,10 @@ class CloudFormationStackNode( private val stackStatus: StackStatus, val stackId: String ) : AwsExplorerResourceNode( - project, - CloudFormationClient.SERVICE_NAME, - stackName, - AwsIcons.Resources.CLOUDFORMATION_STACK + project, + CloudFormationClient.SERVICE_NAME, + stackName, + AwsIcons.Resources.CLOUDFORMATION_STACK ) { override fun resourceType() = "stack" @@ -41,7 +43,7 @@ class CloudFormationStackNode( override fun displayName() = stackName - override fun statusText(): String? = stackStatus.toString().toHumanReadable() + override fun statusText(): String = stackStatus.toString().toHumanReadable() override fun onDoubleClick() { StackWindowManager.getInstance(nodeProject).openStack(stackName, stackId) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationTemplate.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationTemplate.kt index db7f660e08..3a14983236 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationTemplate.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationTemplate.kt @@ -9,10 +9,8 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiElement import org.jetbrains.yaml.YAMLFileType import org.jetbrains.yaml.YAMLLanguage -import software.amazon.awssdk.services.lambda.model.Runtime +import org.jetbrains.yaml.psi.YAMLSequence import software.aws.toolkits.jetbrains.services.cloudformation.yaml.YamlCloudFormationTemplate -import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup -import software.aws.toolkits.jetbrains.services.lambda.validOrNull import software.aws.toolkits.resources.message import java.io.File @@ -54,12 +52,16 @@ interface NamedMap { fun getScalarProperty(key: String): String fun getOptionalScalarProperty(key: String): String? fun setScalarProperty(key: String, value: String) + fun getSequenceProperty(key: String): YAMLSequence + fun getOptionalSequenceProperty(key: String): YAMLSequence? } interface Resource : NamedMap { val cloudFormationTemplate: CloudFormationTemplate fun isType(requestedType: String): Boolean fun type(): String? + fun getScalarMetadata(key: String): String + fun getOptionalScalarMetadata(key: String): String? } interface Parameter : NamedMap { @@ -97,6 +99,14 @@ class MutableParameter(private val copyFrom: Parameter) : Parameter { throw NotImplementedError() } + override fun getSequenceProperty(key: String): YAMLSequence { + throw NotImplementedError() + } + + override fun getOptionalSequenceProperty(key: String): YAMLSequence? { + throw NotImplementedError() + } + override fun defaultValue(): String? = defaultValue override fun description(): String? = description @@ -135,26 +145,3 @@ fun Project.validateSamTemplateHasResources(virtualFile: VirtualFile): String? { .ifEmpty { return message("serverless.application.deploy.error.no_resources", path) } return null } - -/** - * Validate whether the Lambda function runtimes in the specified template are supported to build before deployment to AWS. - * - * @param virtualFile SAM template file - * @return null if they are supported, or an error message otherwise. - */ -fun Project.validateSamTemplateLambdaRuntimes(virtualFile: VirtualFile): String? { - val path = virtualFile.path - - CloudFormationTemplateIndex - .listFunctions(this, virtualFile) - .forEach { indexedFunction -> - val rawRuntime = indexedFunction.runtime() ?: return message("serverless.application.deploy.error.empty_runtime", path) - val runtime = Runtime.fromValue(rawRuntime).validOrNull ?: return message("serverless.application.deploy.error.invalid_runtime", rawRuntime, path) - val runtimeGroup = runtime.runtimeGroup ?: return message("serverless.application.deploy.error.invalid_runtime_group", runtime.toString(), path) - - if (!runtimeGroup.supportsSamBuild()) { - return message("serverless.application.deploy.error.unsupported_runtime_group", runtime.toString(), path) - } - } - return null -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationTemplateIndex.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationTemplateIndex.kt index 6da996aae6..327132eff0 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationTemplateIndex.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/CloudFormationTemplateIndex.kt @@ -54,13 +54,12 @@ class CloudFormationTemplateIndex : FileBasedIndexExtension, FileContent> = DataIndexer { fileContent -> val indexedResources = mutableMapOf>() - fileContent.psiFile.acceptNode(object : PsiElementVisitor() { - override fun visitElement(element: PsiElement) { - super.visitElement(element) - // element is nullable in versions prior to 2020.1 FIX_WHEN_MIN_IS_201 - element?.run { + fileContent.psiFile.acceptNode( + object : PsiElementVisitor() { + override fun visitElement(element: PsiElement) { + super.visitElement(element) val parent = element.parent as? YAMLKeyValue ?: return - if (parent.value != this) return + if (parent.value != element) return val resource = YamlCloudFormationTemplate.convertPsiToResource(parent) ?: return val resourceType = resource.type() ?: return @@ -69,14 +68,14 @@ class CloudFormationTemplateIndex : FileBasedIndexExtension = EnumeratorStringDescriptor.INSTANCE - override fun getVersion(): Int = 2 + override fun getVersion(): Int = 3 override fun getInputFilter(): FileBasedIndex.InputFilter = fileFilter diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/IndexedResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/IndexedResources.kt index 9a5b9433e0..775ad742cd 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/IndexedResources.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/IndexedResources.kt @@ -12,28 +12,24 @@ import java.io.DataOutput * Immutable data class for indexing [Resource]. Use [from] to create an instance so that it always * returns a concrete [IndexedResource] such as [IndexedFunction] if applicable. */ -open class IndexedResource protected constructor(val type: String, val indexedProperties: Map) { - - protected constructor(resource: Resource, indexProperties: List) : - this( - resource.type() ?: throw RuntimeException(message("cloudformation.template_index.missing_type")), - indexProperties - .asSequence() - .map { - it to try { - resource.getScalarProperty(it) - } catch (e: Exception) { - null - } - } - .mapNotNull { (key, value) -> value?.let { key to it } } - .toMap() - ) +open class IndexedResource protected constructor(val type: String, val indexedProperties: Map, val indexedMetadata: Map) { + + protected constructor(resource: Resource, indexProperties: List, indexMetadata: List) : + this( + resource.type() ?: throw RuntimeException(message("cloudformation.template_index.missing_type")), + indexProperties.mapScalars(resource, Resource::getScalarProperty), + indexMetadata.mapScalars(resource, Resource::getScalarMetadata) + ) fun save(dataOutput: DataOutput) { dataOutput.writeUTF(type) dataOutput.writeInt(indexedProperties.size) - indexedProperties.forEach { key, value -> + indexedProperties.forEach { (key, value) -> + dataOutput.writeUTF(key) + dataOutput.writeUTF(value) + } + dataOutput.writeInt(indexedMetadata.size) + indexedMetadata.forEach { (key, value) -> dataOutput.writeUTF(key) dataOutput.writeUTF(value) } @@ -44,8 +40,22 @@ open class IndexedResource protected constructor(val type: String, val indexedPr override fun hashCode(): Int = indexedProperties.hashCode() companion object { + private fun List.mapScalars(resource: Resource, fn: Resource.(property: String) -> String): Map = + this + .asSequence() + .map { + it to try { + resource.fn(it) + } catch (e: Exception) { + null + } + } + .mapNotNull { (key, value) -> value?.let { key to it } } + .toMap() + fun read(dataInput: DataInput): IndexedResource { val propertyList: MutableMap = mutableMapOf() + val metadataList: MutableMap = mutableMapOf() val type = dataInput.readUTF() val propertySize = dataInput.readInt() @@ -54,32 +64,50 @@ open class IndexedResource protected constructor(val type: String, val indexedPr val value = dataInput.readUTF() propertyList[key] = value } - return from(type, propertyList) + val metadataSize = dataInput.readInt() + repeat(metadataSize) { + val key = dataInput.readUTF() + val value = dataInput.readUTF() + metadataList[key] = value + } + return from(type, propertyList, metadataList) } - fun from(type: String, indexedProperties: Map) = - INDEXED_RESOURCE_MAPPINGS[type]?.first?.invoke(type, indexedProperties) ?: IndexedResource(type, indexedProperties) + fun from(type: String, indexedProperties: Map, indexedMetadata: Map) = + INDEXED_RESOURCE_MAPPINGS[type]?.first?.invoke(type, indexedProperties, indexedMetadata) + ?: IndexedResource(type, indexedProperties, indexedMetadata) fun from(resource: Resource): IndexedResource? = resource.type()?.let { - INDEXED_RESOURCE_MAPPINGS[it]?.second?.invoke(resource) ?: IndexedResource(resource, listOf()) + INDEXED_RESOURCE_MAPPINGS[it]?.second?.invoke(resource) ?: IndexedResource(resource, listOf(), listOf()) } } } class IndexedFunction : IndexedResource { - internal constructor(type: String, indexedProperties: Map) : super(type, indexedProperties) + internal constructor(type: String, indexedProperties: Map, indexedMetadata: Map) : + super(type, indexedProperties, indexedMetadata) - internal constructor(resource: Resource) : super(resource, listOf("Runtime", "Handler")) + internal constructor(resource: Resource) : super(resource, listOf("Runtime", "Handler", "PackageType"), listOf("BuildMethod")) fun runtime(): String? = indexedProperties["Runtime"] fun handler(): String? = indexedProperties["Handler"] - override fun toString(): String = indexedProperties.toString() + fun packageType(): String? = indexedProperties["PackageType"] + + fun buildMethod(): String? = indexedMetadata["BuildMethod"] + + override fun toString(): String = "IndexedFunction(indexedProperties=$indexedProperties,indexedMetadata=$indexedMetadata)" } -internal val INDEXED_RESOURCE_MAPPINGS = mapOf) -> IndexedResource, (Resource) -> IndexedResource>>( - LAMBDA_FUNCTION_TYPE to Pair(::IndexedFunction, ::IndexedFunction), - SERVERLESS_FUNCTION_TYPE to Pair(::IndexedFunction, ::IndexedFunction) +internal val INDEXED_RESOURCE_MAPPINGS = mapOf< + String, + Pair< + (String, Map, Map) -> IndexedResource, + (Resource) -> IndexedResource + > + >( + LAMBDA_FUNCTION_TYPE to Pair(::IndexedFunction, ::IndexedFunction), + SERVERLESS_FUNCTION_TYPE to Pair(::IndexedFunction, ::IndexedFunction) ) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/Resources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/Resources.kt index 7e97f811f0..1e0b9c41ad 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/Resources.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/Resources.kt @@ -3,18 +3,28 @@ package software.aws.toolkits.jetbrains.services.cloudformation +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.jetbrains.services.cloudformation.yaml.YamlCloudFormationTemplate.Companion.getTextValues import software.aws.toolkits.resources.message interface Function : Resource { fun codeLocation(): String fun setCodeLocation(location: String) + fun packageType(): PackageType { + val key = "PackageType" + val type = getOptionalScalarProperty(key) ?: return PackageType.ZIP + return PackageType.values().firstOrNull { it.toString() == type } ?: throw IllegalStateException(message("cloudformation.invalid_property", key, type)) + } + fun runtime(): String = getScalarProperty("Runtime") fun handler(): String = getScalarProperty("Handler") + fun architectures(): List? = getOptionalSequenceProperty("Architectures")?.getTextValues() fun timeout(): Int? = getOptionalScalarProperty("Timeout")?.toInt() fun memorySize(): Int? = getOptionalScalarProperty("MemorySize")?.toInt() } const val LAMBDA_FUNCTION_TYPE = "AWS::Lambda::Function" + class LambdaFunction(private val delegate: Resource) : Resource by delegate, Function { override fun setCodeLocation(location: String) { setScalarProperty("Code", location) @@ -26,6 +36,7 @@ class LambdaFunction(private val delegate: Resource) : Resource by delegate, Fun } const val SERVERLESS_FUNCTION_TYPE = "AWS::Serverless::Function" + class SamFunction(private val delegate: Resource) : Resource by delegate, Function { private val globals = cloudFormationTemplate.globals() @@ -39,7 +50,13 @@ class SamFunction(private val delegate: Resource) : Resource by delegate, Functi setScalarProperty("CodeUri", location) } - override fun codeLocation(): String = getScalarProperty("CodeUri") + override fun codeLocation(): String = when (packageType()) { + PackageType.ZIP -> getScalarProperty("CodeUri") + PackageType.IMAGE -> getScalarMetadata("DockerContext") + else -> throw IllegalStateException("Bad packageType somehow returned to code location: ${packageType()}") + } + + fun dockerFile(): String? = getOptionalScalarMetadata("Dockerfile") override fun toString(): String = logicalName } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/Settings.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/Settings.form new file mode 100644 index 0000000000..515764025d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/Settings.form @@ -0,0 +1,55 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/actions/DeleteStackAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/actions/DeleteStackAction.kt index 19ccd714c6..3af0641310 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/actions/DeleteStackAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/actions/DeleteStackAction.kt @@ -5,27 +5,23 @@ package software.aws.toolkits.jetbrains.services.cloudformation.actions import com.intellij.openapi.application.runInEdt import software.amazon.awssdk.services.cloudformation.CloudFormationClient -import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.awsClient import software.aws.toolkits.jetbrains.core.explorer.actions.DeleteResourceAction import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationStackNode import software.aws.toolkits.jetbrains.services.cloudformation.resources.CloudFormationResources import software.aws.toolkits.jetbrains.services.cloudformation.stack.StackWindowManager import software.aws.toolkits.jetbrains.services.cloudformation.waitForStackDeletionComplete -import software.aws.toolkits.jetbrains.utils.TaggingResourceType import software.aws.toolkits.resources.message -class DeleteStackAction : DeleteResourceAction( - message("cloudformation.stack.delete.action"), - TaggingResourceType.CLOUDFORMATION_STACK -) { +class DeleteStackAction : DeleteResourceAction(message("cloudformation.stack.delete.action")) { override fun performDelete(selected: CloudFormationStackNode) { - val client: CloudFormationClient = AwsClientManager.getInstance(selected.nodeProject).getClient() + val client: CloudFormationClient = selected.nodeProject.awsClient() client.deleteStack { it.stackName(selected.stackName) } runInEdt { StackWindowManager.getInstance(selected.nodeProject).openStack(selected.stackName, selected.stackId) } client.waitForStackDeletionComplete(selected.stackName) - selected.nodeProject.refreshAwsTree(CloudFormationResources.LIST_STACKS) + selected.nodeProject.refreshAwsTree(CloudFormationResources.ACTIVE_STACKS) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/resources/CloudFormationResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/resources/CloudFormationResources.kt index 7a3994ceb1..d7bc796eaa 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/resources/CloudFormationResources.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/resources/CloudFormationResources.kt @@ -8,14 +8,16 @@ import software.amazon.awssdk.services.cloudformation.model.StackStatus import software.amazon.awssdk.services.cloudformation.model.StackSummary import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource import software.aws.toolkits.jetbrains.core.Resource -import software.aws.toolkits.jetbrains.core.filter object CloudFormationResources { - val LIST_STACKS: Resource.Cached> = - ClientBackedCachedResource(CloudFormationClient::class, "cloudformation.list_stacks") { - listStacksPaginator().stackSummaries().toList() - } - @JvmField - val ACTIVE_STACKS = LIST_STACKS.filter { it.stackStatus() != StackStatus.DELETE_COMPLETE }.filter { it.stackName() != null } + // "Active" stacks means everything that is not deleted + val ACTIVE_STACKS: Resource.Cached> = ClientBackedCachedResource(CloudFormationClient::class, "cloudformation.list_active_stacks") { + listStacksPaginator { + it.stackStatusFilters( + // We want all values except for DELETE_COMPLETE + StackStatus.knownValues().toList().filter { status -> status != StackStatus.DELETE_COMPLETE } + ) + }.stackSummaries().toList().filter { it.stackName() != null } + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/DynamicTableView.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/DynamicTableView.kt index d0f1415b02..d3d335c983 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/DynamicTableView.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/DynamicTableView.kt @@ -4,10 +4,11 @@ package software.aws.toolkits.jetbrains.services.cloudformation.stack import com.intellij.ui.components.JBScrollPane import com.intellij.ui.table.JBTable +import java.awt.event.MouseListener import javax.swing.JComponent import javax.swing.SwingUtilities -import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableModel +import javax.swing.table.TableCellRenderer class DynamicTableView(private vararg val fields: Field) : View { private val model = object : DefaultTableModel(fields.map(Field::readableName).toTypedArray(), 0) { @@ -17,11 +18,12 @@ class DynamicTableView(private vararg val fields: Field) : View { private val table = JBTable(model).apply { autoCreateRowSorter = true autoscrolls = true + cellSelectionEnabled = true setShowColumns(true) setPaintBusy(true) - fields.forEach { - when (val renderer = it.renderer) { - is DefaultTableCellRenderer -> getColumn(it.readableName).cellRenderer = renderer + fields.forEach { field -> + field.renderer?.let { + getColumn(field.readableName).cellRenderer = it } } } @@ -43,9 +45,19 @@ class DynamicTableView(private vararg val fields: Field) : View { table.setPaintBusy(busy) } + fun addMouseListener(listener: MouseListener) = table.addMouseListener(listener) + + fun selectedRow(): Map, Any?>? { + val row = table.selectedRows?.takeIf { it.size == 1 }?.firstOrNull() ?: return null + return (0 until model.columnCount).map { col -> + val field = fields.find { field -> field.readableName == model.getColumnName(col) } ?: return null + field to model.getValueAt(row, col) + }.toMap() + } + data class Field( val readableName: String, - val renderer: DefaultTableCellRenderer? = null, + val renderer: TableCellRenderer? = null, val getData: (T) -> Any? ) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/EventsFetcher.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/EventsFetcher.kt index 7a9eca0c84..1cad8597ac 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/EventsFetcher.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/EventsFetcher.kt @@ -8,7 +8,7 @@ import software.amazon.awssdk.services.cloudformation.model.StackEvent import javax.swing.SwingUtilities /** - * AWS returns events in Events in reverse chronological order. This class remembers last event id and returns only new one. + * AWS returns events in reverse chronological order. This class remembers last event id and returns only new one. * [lastEventIdOfCurrentPage] String? id of the last event used to prevent duplicates. * [id] String field used as event id * [previousPages] stack of tokens for all pages except first. First page does not have token @@ -27,8 +27,7 @@ class EventsFetcher(private val stackName: String) { * @return new events from last call or all events if [pageToSwitchTo] is set (because all events * are new to another page) and set of available [Page]s */ - fun fetchEvents(client: CloudFormationClient, pageToSwitchTo: Page?): - Pair, Set> { + fun fetchEvents(client: CloudFormationClient, pageToSwitchTo: Page?): Pair, Set> { assert(!SwingUtilities.isEventDispatchThread()) val pageToFetch: String? = when (pageToSwitchTo) { @@ -43,6 +42,7 @@ class EventsFetcher(private val stackName: String) { when (pageToSwitchTo) { Page.NEXT -> currentPage?.let { previousPages.add(it) } // Store current as prev Page.PREVIOUS -> if (previousPages.isNotEmpty()) previousPages.removeAt(previousPages.size - 1) + else -> {} } nextPage = response.nextToken() currentPage = pageToFetch 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 d9ccbd4d5d..7e0316c3ea 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 @@ -31,7 +31,7 @@ interface EventsTable : View { fun showBusyIcon() } -private class StatusCellRenderer : DefaultTableCellRenderer() { +class StatusCellRenderer : DefaultTableCellRenderer() { override fun getTableCellRendererComponent(table: JTable, value: Any, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column).also { it.foreground = StatusType.fromStatusValue(value as String).color @@ -40,15 +40,18 @@ private class StatusCellRenderer : DefaultTableCellRenderer() { internal class EventsTableImpl : EventsTable, Disposable { - private val table = DynamicTableView( + private val logicalId = DynamicTableView.Field(message("cloudformation.stack.logical_id")) { e -> e.logicalResourceId() } + private val physicalId = DynamicTableView.Field(message("cloudformation.stack.physical_id")) { e -> e.physicalResourceId() } + + private val table = DynamicTableView( DynamicTableView.Field(message("general.time")) { e -> e.timestamp() }, // CFN Resource Status does not match what we expect (StackStatus enum) 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() }, + logicalId, + physicalId, DynamicTableView.Field( message("cloudformation.stack.reason"), - WrappingCellRenderer(wrapOnSelection = true, toggleableWrap = false) + WrappingCellRenderer(wrapOnSelection = true, wrapOnToggle = false) ) { e -> e.resourceStatusReason() ?: "" } ).apply { component.border = IdeBorderFactory.createBorder(SideBorder.BOTTOM) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/ResourceTableView.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/ResourceTableView.kt new file mode 100644 index 0000000000..d207dac973 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/ResourceTableView.kt @@ -0,0 +1,37 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +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.StackResource +import software.aws.toolkits.jetbrains.utils.ui.WrappingCellRenderer +import software.aws.toolkits.resources.message +import javax.swing.JComponent + +class ResourceTableView : View, ResourceListener, Disposable { + private val logicalId = DynamicTableView.Field(message("cloudformation.stack.logical_id")) { it.logicalResourceId() } + private val physicalId = DynamicTableView.Field(message("cloudformation.stack.physical_id")) { it.physicalResourceId() } + + private val table = DynamicTableView( + logicalId, + physicalId, + DynamicTableView.Field(message("cloudformation.stack.type")) { it.resourceType() }, + DynamicTableView.Field(message("cloudformation.stack.status"), renderer = StatusCellRenderer()) { it.resourceStatusAsString() }, + DynamicTableView.Field( + message("cloudformation.stack.reason"), + renderer = WrappingCellRenderer(wrapOnSelection = true, wrapOnToggle = false) + ) { it.resourceStatusReason() } + ).apply { component.border = JBUI.Borders.empty() } + + override val component: JComponent = table.component + + override fun updatedResources(resources: List) = table.updateItems(resources, clearExisting = true) + + override fun dispose() {} +} + +interface ResourceListener { + fun updatedResources(resources: List) +} 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 1a018fd8e5..7c247cf714 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 @@ -2,51 +2,61 @@ // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.jetbrains.services.cloudformation.stack +import com.intellij.icons.AllIcons import com.intellij.notification.NotificationGroup import com.intellij.notification.NotificationType +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.LangDataKeys -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.SimpleToolWindowPanel import com.intellij.openapi.util.Disposer import com.intellij.ui.OnePixelSplitter import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.content.Content 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 +import software.aws.toolkits.jetbrains.core.awsClient import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction import software.aws.toolkits.jetbrains.core.toolwindow.ToolkitToolWindow -import software.aws.toolkits.jetbrains.core.toolwindow.ToolkitToolWindowManager -import software.aws.toolkits.jetbrains.core.toolwindow.ToolkitToolWindowTab -import software.aws.toolkits.jetbrains.core.toolwindow.ToolkitToolWindowType import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationStackNode +import software.aws.toolkits.jetbrains.services.cloudformation.toolwindow.CloudFormationToolWindow import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.CloudformationTelemetry +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean import javax.swing.BoxLayout +import javax.swing.JComponent import javax.swing.JPanel import javax.swing.SwingUtilities -private const val UPDATE_STACK_STATUS_INTERVAL = 5000 +private val UPDATE_STACK_STATUS_INTERVAL = Duration.ofSeconds(5) +private val UPDATE_STACK_STATUS_INTERVAL_ON_FINAL_STATE = Duration.ofSeconds(60) private const val REDRAW_ANIMATED_ICON_INTERVAL = 70 private const val TREE_TABLE_INITIAL_PROPORTION = 0.25f -internal val STACK_TOOL_WINDOW = - ToolkitToolWindowType("AWS.CloudFormation", message("cloudformation.toolwindow.label"), icon = AwsIcons.Logos.CLOUD_FORMATION_TOOL) class StackWindowManager(private val project: Project) { - private val toolWindow = ToolkitToolWindowManager.getInstance(project, STACK_TOOL_WINDOW) + private val toolWindow = CloudFormationToolWindow.getInstance(project) fun openStack(stackName: String, stackId: String) { assert(SwingUtilities.isEventDispatchThread()) - toolWindow.find(stackId)?.run { show() } ?: StackUI(project, stackName, stackId, toolWindow).start() - CloudformationTelemetry.open(project) + if (!toolWindow.showExistingContent(stackId)) { + StackUI(project, stackName, stackId, toolWindow).start() + } + CloudformationTelemetry.open(project, success = true) } companion object { - fun getInstance(project: Project): StackWindowManager = ServiceManager.getService(project, StackWindowManager::class.java) + fun getInstance(project: Project): StackWindowManager = project.service() } } @@ -56,9 +66,14 @@ class OpenStackUiAction : SingleResourceNodeAction(mess } } -private class StackUI(private val project: Project, private val stackName: String, stackId: String, toolWindow: ToolkitToolWindow) : UpdateListener { +private class StackUI( + private val project: Project, + private val stackName: String, + stackId: String, + private val toolWindow: ToolkitToolWindow +) : UpdateListener, Disposable { - internal val toolWindowTab: ToolkitToolWindowTab + val toolWindowTab: Content private val animator: IconAnimator private val updater: Updater private val notificationGroup: NotificationGroup @@ -66,6 +81,7 @@ private class StackUI(private val project: Project, private val stackName: Strin private val eventsTable: EventsTableImpl private val outputsTable = OutputsTableView() + private val resourcesTable = ResourceTableView() init { val tree = TreeViewImpl(project, stackName) @@ -73,73 +89,95 @@ private class StackUI(private val project: Project, private val stackName: Strin eventsTable = EventsTableImpl() pageButtons = PageButtons(this::onPageButtonClick) - notificationGroup = NotificationGroup.findRegisteredGroup(STACK_TOOL_WINDOW.id) - ?: NotificationGroup.toolWindowGroup(STACK_TOOL_WINDOW.id, STACK_TOOL_WINDOW.id) + val notificationId = CloudFormationToolWindow.getInstance(project).toolWindowId + notificationGroup = NotificationGroup.findRegisteredGroup(notificationId) + ?: NotificationGroup.toolWindowGroup(notificationId, notificationId) + val window = SimpleToolWindowPanel(false, true) val mainPanel = OnePixelSplitter(false, TREE_TABLE_INITIAL_PROPORTION).apply { firstComponent = tree.component secondComponent = JBTabbedPane().apply { - 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 + 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 + 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 { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(outputsTable.component) - }) + tabComponentInsets = JBUI.emptyInsets() + border = JBUI.Borders.empty() + } + ) + + this.add( + message("cloudformation.stack.tab_labels.resources"), + JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(resourcesTable.component) + } + ) + + this.add( + message("cloudformation.stack.tab_labels.outputs"), + JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(add(outputsTable.component)) + } + ) } } - updater = Updater( tree, eventsTable = eventsTable, outputsTable = outputsTable, - stackName = stackName, - updateEveryMs = UPDATE_STACK_STATUS_INTERVAL, + resourceListener = resourcesTable, + updateInterval = UPDATE_STACK_STATUS_INTERVAL, + updateIntervalOnFinalState = UPDATE_STACK_STATUS_INTERVAL_ON_FINAL_STATE, listener = this, - client = AwsClientManager.getInstance(project).getClient(), - setPagesAvailable = pageButtons::setPagesAvailable + client = project.awsClient(), + setPagesAvailable = pageButtons::setPagesAvailable, + stackId = stackId ) - toolWindowTab = toolWindow.addTab(stackName, mainPanel, id = stackId) - listOf(tree, updater, animator, eventsTable, outputsTable, pageButtons).forEach { Disposer.register(toolWindowTab, it) } + window.setContent(mainPanel) + window.toolbar = createToolbar() + + toolWindowTab = toolWindow.addTab(stackName, window, id = stackId) + // dispose self when toolwindowtab closes + Disposer.register(toolWindowTab, this) + listOf(tree, updater, animator, eventsTable, outputsTable, resourcesTable, pageButtons).forEach { Disposer.register(this, it) } } fun start() { - toolWindowTab.show() + toolWindow.show(toolWindowTab) animator.start() updater.start() } @@ -162,10 +200,46 @@ private class StackUI(private val project: Project, private val stackName: Strin notificationGroup.createNotification("$stackName: $message", notificationType).notify(project) } + private fun createToolbar(): JComponent { + val actionGroup = DefaultActionGroup() + actionGroup.addAction(object : DumbAwareAction(message("general.refresh"), null, AllIcons.Actions.Refresh) { + override fun actionPerformed(e: AnActionEvent) { + updater.start() + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = !updater.running + } + }) + + actionGroup.addAction(createFilterAction()) + + return ActionManager.getInstance().createActionToolbar("", actionGroup, false).component + } + + private fun createFilterAction() = + object : ToggleAction(message("cloudformation.stack.filter.show_completed"), null, AllIcons.RunConfigurations.ShowPassed), DumbAware { + private val state = AtomicBoolean(true) + override fun isSelected(e: AnActionEvent): Boolean = state.get() + + override fun setSelected(e: AnActionEvent, newState: Boolean) { + if (state.getAndSet(newState) != newState) { + ApplicationManager.getApplication().executeOnPooledThread { + updater.applyFilter { + newState || it.resourceStatus().type != StatusType.COMPLETED + } + } + } + } + } + fun onPageButtonClick(page: Page) { eventsTable.showBusyIcon() // To prevent double click, we disable buttons. They will be enabled by Updater when data fetched pageButtons.setPagesAvailable(emptySet()) updater.switchPage(page) } + + override fun dispose() { + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/TreeView.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/TreeView.kt index 23b47ec0ab..45156be80f 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/TreeView.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/TreeView.kt @@ -51,7 +51,6 @@ internal class TreeViewImpl(private val project: Project, stackName: String) : T * Remove outdated nodes, add new nodes, update status */ private fun updateResourceList(resources: Collection) { - val resourcesByName = resources.map { it.logicalResourceId() to it }.toMap() val existingResources = mutableSetOf() val nodesToDelete = mutableListOf() @@ -80,7 +79,7 @@ internal class TreeViewImpl(private val project: Project, stackName: String) : T val resource = nameAndResource.value val status = resource.resourceStatus() - val newDescriptor = StackNodeDescriptor(project, name, status.type, status.name, rootDescriptor) + val newDescriptor = StackNodeDescriptor(project, name, status.type, status.name, rootDescriptor, physicalId = resource.physicalResourceId()) rootNode.add(DefaultMutableTreeNode(newDescriptor, false)) } } @@ -89,7 +88,7 @@ internal class TreeViewImpl(private val project: Project, stackName: String) : T val descriptor = StackNodeDescriptor(project, stackName, StatusType.UNKNOWN, message("loading_resource.loading")) val rootNode = DefaultMutableTreeNode(descriptor, true) model = DefaultTreeModel(rootNode) - tree = Tree(model) + tree = Tree(model).also { it.name = "$stackName.tree" } tree.setPaintBusy(true) component = JBScrollPane(tree) } @@ -119,10 +118,11 @@ internal class TreeViewImpl(private val project: Project, stackName: String) : T private class StackNodeDescriptor( project: Project, - name: String, + val name: String, private var statusType: StatusType, private var status: String, - parent: StackNodeDescriptor? = null + parent: StackNodeDescriptor? = null, + var physicalId: String? = null ) : NodeDescriptor(project, parent) { init { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/Updater.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/Updater.kt index 0d99625aa2..7188f5a821 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/Updater.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/stack/Updater.kt @@ -14,6 +14,8 @@ import software.amazon.awssdk.services.cloudformation.model.Output import software.amazon.awssdk.services.cloudformation.model.StackEvent import software.amazon.awssdk.services.cloudformation.model.StackResource import software.amazon.awssdk.services.cloudformation.model.StackStatus +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean import javax.swing.SwingUtilities /** @@ -31,16 +33,23 @@ class Updater( private val treeView: TreeView, private val eventsTable: EventsTable, private val outputsTable: OutputsListener, - private val stackName: String, - private val updateEveryMs: Int, + private val resourceListener: ResourceListener, + private val updateInterval: Duration, + private val updateIntervalOnFinalState: Duration, private val listener: UpdateListener, private val client: CloudFormationClient, - private val setPagesAvailable: (Set) -> Unit + private val setPagesAvailable: (Set) -> Unit, + private val stackId: String ) : Disposable { @Volatile - private var previousStackStatusType: StatusType = StatusType.UNKNOWN - private val eventsFetcher = EventsFetcher(stackName) + private var stackStatus: StatusType? = null + + @Volatile + private var predicate: (StackResource) -> Boolean = { true } + private val updating = AtomicBoolean(false) + val running get() = updating.get() + private val eventsFetcher = EventsFetcher(stackId) private val app: Application get() = ApplicationManager.getApplication() @@ -52,6 +61,9 @@ class Updater( private val alarm: Alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this) fun start() { + updating.set(true) + // cancel pending requests after refreshing + alarm.cancelAllRequests() alarm.addRequest({ fetchDataSafely() }, 0) } @@ -62,6 +74,14 @@ class Updater( alarm.addRequest({ fetchDataSafely(page) }, 0) } + /** + * Apply a filter to the resources returned by the updater + */ + fun applyFilter(predicate: (StackResource) -> Boolean) { + this.predicate = predicate + fetchDataSafely() + } + private fun fetchDataSafely(pageToSwitchTo: Page? = null) { try { fetchData(pageToSwitchTo) @@ -79,13 +99,16 @@ class Updater( val newStackStatusType = newStackStatus.type val newStackStatusNotInProgress = newStackStatusType !in setOf(StatusType.UNKNOWN, StatusType.PROGRESS) - // Stack changed to some "final" status just now, notify user - val stackSwitchedToFinalStatus = previousStackStatusType != newStackStatusType && newStackStatusNotInProgress + // Stack changed to some "final" status just now, notify the user. Don't show a notification if the state + // happened at creation time, which is distinguished by stackStatus being null + val stackSwitchedToFinalStatus = stackStatus != null && + stackStatus != newStackStatusType && + newStackStatusNotInProgress // Stack status is final and has not been changed - val stackStatusFinalNotChanged = newStackStatusNotInProgress && newStackStatusType == previousStackStatusType + val stackStatusFinalNotChanged = newStackStatusNotInProgress && newStackStatusType == stackStatus - previousStackStatusType = newStackStatusType + stackStatus = newStackStatusType // Only fetch events if stack is not in final state or page switched val eventsAndButtonStates = if (stackStatusFinalNotChanged && pageToSwitchTo == null) { @@ -96,10 +119,11 @@ class Updater( app.invokeLater { outputsTable.updatedOutputs(stackDetails.outputs) + resourceListener.updatedResources(stackDetails.resources) showData( stackStatus = newStackStatus, - resources = stackDetails.resources, + resources = stackDetails.resources.filter(predicate), newEvents = eventsAndButtonStates?.first ?: emptyList(), pageChanged = pageToSwitchTo != null ) @@ -110,9 +134,16 @@ class Updater( listener.onStackStatusChanged(newStackStatus) } + updating.set(!newStackStatusNotInProgress) // Reschedule next run - if (!alarm.isDisposed) { - alarm.addRequest({ fetchDataSafely() }, updateEveryMs) + if (stackStatus == StatusType.DELETED) { + alarm.cancelAllRequests() + } else if (!alarm.isDisposed && alarm.isEmpty) { + if (updating.get()) { + alarm.addRequest({ fetchDataSafely() }, updateInterval.toMillis()) + } else { + alarm.addRequest({ fetchDataSafely() }, updateIntervalOnFinalState.toMillis()) + } } } } @@ -131,9 +162,9 @@ class Updater( private fun fetchStackDetails(): Stack { assert(!SwingUtilities.isEventDispatchThread()) - val resourcesRequest = DescribeStackResourcesRequest.builder().stackName(stackName).build() + val resourcesRequest = DescribeStackResourcesRequest.builder().stackName(stackId).build() val resources = client.describeStackResources(resourcesRequest).stackResources() - val stack = client.describeStacks { it.stackName(stackName) }.stacks().firstOrNull() + val stack = client.describeStacks { it.stackName(stackId) }.stacks().firstOrNull() val stackStatus = stack?.stackStatus() ?: StackStatus.UNKNOWN_TO_SDK_VERSION val outputs = stack?.outputs() ?: emptyList() return Stack(stackStatus, resources, outputs) @@ -141,5 +172,6 @@ class Updater( private data class Stack(val status: StackStatus, val resources: List, val outputs: List) - override fun dispose() {} + override fun dispose() { + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/toolwindow/CloudFormationToolWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/toolwindow/CloudFormationToolWindow.kt new file mode 100644 index 0000000000..4c3db79289 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/toolwindow/CloudFormationToolWindow.kt @@ -0,0 +1,16 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudformation.toolwindow + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.toolwindow.ToolkitToolWindow + +class CloudFormationToolWindow(override val project: Project) : ToolkitToolWindow { + override val toolWindowId = "aws.cloudformation" + + companion object { + fun getInstance(project: Project) = project.service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/toolwindow/CloudFormationToolWindowFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/toolwindow/CloudFormationToolWindowFactory.kt new file mode 100644 index 0000000000..746cc730ae --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/toolwindow/CloudFormationToolWindowFactory.kt @@ -0,0 +1,25 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudformation.toolwindow + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import software.aws.toolkits.resources.message + +class CloudFormationToolWindowFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + runInEdt { + toolWindow.installWatcher(toolWindow.contentManager) + } + } + + override fun init(toolWindow: ToolWindow) { + toolWindow.stripeTitle = message("cloudformation.toolwindow.label") + } + + override fun shouldBeAvailable(project: Project): Boolean = false +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/yaml/YamlCloudFormationTemplate.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/yaml/YamlCloudFormationTemplate.kt index c3c3990bbb..de338d584e 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/yaml/YamlCloudFormationTemplate.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudformation/yaml/YamlCloudFormationTemplate.kt @@ -15,6 +15,8 @@ import org.jetbrains.yaml.YAMLLanguage import org.jetbrains.yaml.psi.YAMLFile import org.jetbrains.yaml.psi.YAMLKeyValue import org.jetbrains.yaml.psi.YAMLMapping +import org.jetbrains.yaml.psi.YAMLScalar +import org.jetbrains.yaml.psi.YAMLSequence import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationParameter @@ -77,6 +79,12 @@ class YamlCloudFormationTemplate(template: YAMLFile) : CloudFormationTemplate { override fun setScalarProperty(key: String, value: String) { throw NotImplementedError() } + + override fun getSequenceProperty(key: String): YAMLSequence = getOptionalSequenceProperty(key) + ?: throw IllegalStateException(message("cloudformation.missing_property", key, logicalName)) + + override fun getOptionalSequenceProperty(key: String): YAMLSequence? = delegate.getKeyValueByKey(key) + ?.children?.filterIsInstance()?.firstOrNull() } private class YamlResource( @@ -102,8 +110,22 @@ class YamlCloudFormationTemplate(template: YAMLFile) : CloudFormationTemplate { properties().putKeyValue(newKeyValue) } + override fun getSequenceProperty(key: String): YAMLSequence = getOptionalSequenceProperty(key) + ?: throw IllegalStateException(message("cloudformation.missing_property", key, logicalName)) + + override fun getOptionalSequenceProperty(key: String): YAMLSequence? = + properties().getKeyValueByKey(key)?.children?.filterIsInstance()?.firstOrNull() + + override fun getScalarMetadata(key: String): String = getOptionalScalarMetadata(key) + ?: throw IllegalStateException(message("cloudformation.missing_property", key, logicalName)) + + override fun getOptionalScalarMetadata(key: String): String? = metadata().getKeyValueByKey(key)?.valueText + private fun properties(): YAMLMapping = delegate.getKeyValueByKey("Properties")?.value as? YAMLMapping ?: throw RuntimeException(message("cloudformation.key_not_found", "Properties", logicalName)) + + private fun metadata(): YAMLMapping = delegate.getKeyValueByKey("Metadata")?.value as? YAMLMapping + ?: throw RuntimeException(message("cloudformation.key_not_found", "Metadata", logicalName)) } private class YamlCloudFormationParameter(override val logicalName: String, private val delegate: YAMLMapping) : @@ -116,6 +138,11 @@ class YamlCloudFormationTemplate(template: YAMLFile) : CloudFormationTemplate { override fun setScalarProperty(key: String, value: String) { throw NotImplementedError() } + + override fun getSequenceProperty(key: String): YAMLSequence = getOptionalSequenceProperty(key) + ?: throw IllegalStateException(message("cloudformation.missing_property", key, logicalName)) + override fun getOptionalSequenceProperty(key: String): YAMLSequence? = delegate.getKeyValueByKey(key) + ?.children?.filterIsInstance()?.firstOrNull() } companion object { @@ -136,6 +163,8 @@ class YamlCloudFormationTemplate(template: YAMLFile) : CloudFormationTemplate { return yamlKeyValue.asResource(YamlCloudFormationTemplate(yamlKeyValue.containingFile as YAMLFile)) } + fun YAMLSequence.getTextValues(): List = this.items.map { it.value }.filterIsInstance().map { it.textValue } + private fun YAMLKeyValue.asResource(cloudFormationTemplate: CloudFormationTemplate): Resource? { if (PsiTreeUtil.getParentOfType(this, YAMLKeyValue::class.java)?.keyText == "Resources") { val yamlValue = this.value as? YAMLMapping ?: return null diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchActor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchActor.kt new file mode 100644 index 0000000000..0847232d00 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchActor.kt @@ -0,0 +1,565 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudwatch.logs + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.ui.TableUtil +import com.intellij.ui.table.TableView +import com.intellij.util.ExceptionUtil +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient +import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogStreamsRequest +import software.amazon.awssdk.services.cloudwatchlogs.model.FilterLogEventsRequest +import software.amazon.awssdk.services.cloudwatchlogs.model.GetLogEventsRequest +import software.amazon.awssdk.services.cloudwatchlogs.model.GetQueryResultsResponse +import software.amazon.awssdk.services.cloudwatchlogs.model.LogStream +import software.amazon.awssdk.services.cloudwatchlogs.model.OrderBy +import software.amazon.awssdk.services.cloudwatchlogs.model.QueryStatus +import software.amazon.awssdk.services.cloudwatchlogs.model.ResourceNotFoundException +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights.LogResult +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights.identifier +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights.toLogResult +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import java.time.Duration + +sealed class CloudWatchActor( + protected val project: Project, + protected val client: CloudWatchLogsClient, + protected val table: TableView +) : Disposable { + @Suppress("LeakingThis") + protected val coroutineScope = disposableCoroutineScope(this) + val channel = Channel() + + protected abstract val emptyText: String + protected abstract val tableErrorMessage: String + protected abstract val notFoundText: String + + protected val edtContext = getCoroutineUiContext() + private val exceptionHandler = CoroutineExceptionHandler { _, e -> + LOG.error(e) { "Exception thrown in the LogStreamActor not handled:" } + notifyError(title = message("general.unknown_error"), project = project) + table.setPaintBusy(false) + channel.close() + } + + init { + coroutineScope.launch(exceptionHandler) { + handleMessages() + } + } + + abstract suspend fun handleMessages() + + protected suspend fun loadAndPopulate(loadBlock: suspend () -> List) { + try { + tableLoading() + val items = loadBlock() + table.listTableModel.items = items + table.emptyText.text = emptyText + } catch (e: ResourceNotFoundException) { + withContext(edtContext) { + table.emptyText.text = notFoundText + } + } catch (e: Exception) { + LOG.error(e) { tableErrorMessage } + withContext(edtContext) { + table.emptyText.text = tableErrorMessage + notifyError(title = tableErrorMessage, project = project) + } + } finally { + tableDoneLoading() + } + } + + protected suspend fun tableLoading() = withContext(edtContext) { + table.setPaintBusy(true) + table.emptyText.text = message("loading_resource.loading") + } + + protected suspend fun tableDoneLoading() = withContext(edtContext) { + table.tableViewModel.fireTableDataChanged() + table.setPaintBusy(false) + } + + override fun dispose() { + channel.close() + } + + companion object { + private val LOG = getLogger>() + } +} + +abstract class CloudWatchLogsActor( + project: Project, + client: CloudWatchLogsClient, + table: TableView +) : CloudWatchActor(project, client, table) { + sealed class Message { + object LoadInitial : Message() + class LoadInitialRange(val previousEvent: LogStreamEntry, val duration: Duration) : Message() + class LoadInitialFilter(val queryString: String) : Message() + object LoadForward : Message() + object LoadBackward : Message() + } + protected var nextBackwardToken: String? = null + protected var nextForwardToken: String? = null + + protected open suspend fun loadInitial() { + throw IllegalStateException("Table does not support loadInitial") + } + + protected open suspend fun loadInitialRange(startTime: Long, duration: Duration) { + throw IllegalStateException("Table does not support loadInitialRange") + } + + protected open suspend fun loadInitialFilter(queryString: String) { + throw IllegalStateException("Table does not support loadInitialFilter") + } + + protected abstract suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean = false, saveBackwardToken: Boolean = false): List + + override suspend fun handleMessages() { + for (message in channel) { + when (message) { + is Message.LoadForward -> if (!nextForwardToken.isNullOrEmpty()) { + withContext(edtContext) { table.setPaintBusy(true) } + val items = try { + loadMore(nextForwardToken, saveForwardToken = true) + } catch (e: Exception) { + LOG.warn(e) { "Exception thrown while trying to load forwards" } + notifyError( + project = project, + title = message("cloudwatch.logs.exception"), + content = message("cloudwatch.logs.failed_to_load_more") + ) + listOf() + } + withContext(edtContext) { + if (items.isNotEmpty()) { + table.listTableModel.addRows(items) + } + table.setPaintBusy(false) + } + } + is Message.LoadBackward -> if (!nextBackwardToken.isNullOrEmpty()) { + withContext(edtContext) { table.setPaintBusy(true) } + val items = try { + loadMore(nextBackwardToken, saveBackwardToken = true) + } catch (e: Exception) { + LOG.warn(e) { "Exception thrown while trying to load backwards" } + notifyError( + project = project, + title = message("cloudwatch.logs.exception"), + content = message("cloudwatch.logs.failed_to_load_more") + ) + listOf() + } + if (items.isNotEmpty()) { + // Selected rows can be non contiguous so we have to save every row selected + val newSelection = table.selectedRows.map { it + items.size } + val viewRect = table.visibleRect + table.listTableModel.items = items + table.listTableModel.items + withContext(edtContext) { + table.tableViewModel.fireTableDataChanged() + val offset = table.getCellRect(items.size, 0, true) + // Move the view box down y - cell height amount to stay in the same place + viewRect.y = (viewRect.y + offset.y - offset.height) + table.scrollRectToVisible(viewRect) + // Re-add the selection + newSelection.forEach { + table.addRowSelectionInterval(it, it) + } + } + } + withContext(edtContext) { table.setPaintBusy(false) } + } + is Message.LoadInitial -> { + loadInitial() + // make sure the scroll pane is at the top after loading. Needed for Refresh! + val rect = table.getCellRect(0, 0, true) + withContext(edtContext) { + table.scrollRectToVisible(rect) + } + } + is Message.LoadInitialRange -> { + loadInitialRange(message.previousEvent.timestamp, message.duration) + val item = table.listTableModel.items.firstOrNull { it == message.previousEvent } + val index = table.listTableModel.indexOf(item).takeIf { it > 0 } ?: return + withContext(edtContext) { + table.setRowSelectionInterval(index, index) + TableUtil.scrollSelectionToVisible(table) + } + } + is Message.LoadInitialFilter -> { + loadInitialFilter(message.queryString) + } + } + } + } + + companion object { + private val LOG = getLogger>() + } +} + +class LogStreamFilterActor( + project: Project, + client: CloudWatchLogsClient, + table: TableView, + private val logGroup: String, + private val logStream: String +) : CloudWatchLogsActor(project, client, table) { + override val emptyText = message("cloudwatch.logs.no_events_query", logStream) + override val tableErrorMessage = message("cloudwatch.logs.failed_to_load_stream", logStream) + override val notFoundText = message("cloudwatch.logs.log_stream_does_not_exist", logStream) + + override suspend fun loadInitialFilter(queryString: String) { + val request = FilterLogEventsRequest + .builder() + .logGroupName(logGroup) + .logStreamNames(logStream) + .filterPattern(queryString) + .build() + loadAndPopulate { getSearchLogEvents(request) } + } + + override suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean, saveBackwardToken: Boolean): List { + // loading backwards doesn't make sense in this context, so just skip the event + if (saveBackwardToken) { + return listOf() + } + + val request = FilterLogEventsRequest + .builder() + .logGroupName(logGroup) + .logStreamNames(logStream) + .nextToken(nextToken) + .build() + + return getSearchLogEvents(request) + } + + private fun getSearchLogEvents(request: FilterLogEventsRequest): List = try { + val response = client.filterLogEvents(request) + val events = response.events().filterNotNull().map { it.toLogStreamEntry() } + nextForwardToken = response.nextToken() + + events + } catch (e: Exception) { + runInEdt { + Messages.showErrorDialog(project, e.localizedMessage, message("cloudwatch.logs.failed_to_load_stream", logStream)) + } + listOf() + } +} + +class LogStreamListActor( + project: Project, + client: CloudWatchLogsClient, + table: TableView, + private val logGroup: String, + private val logStream: String +) : + CloudWatchLogsActor(project, client, table) { + override val emptyText = message("cloudwatch.logs.no_events") + override val tableErrorMessage = message("cloudwatch.logs.failed_to_load_stream", logStream) + override val notFoundText = message("cloudwatch.logs.log_stream_does_not_exist", logStream) + + override suspend fun loadInitial() { + val request = GetLogEventsRequest + .builder() + .logGroupName(logGroup) + .logStreamName(logStream) + .startFromHead(true) + .build() + loadAndPopulate { getLogEvents(request, saveForwardToken = true, saveBackwardToken = true) } + } + + override suspend fun loadInitialRange(startTime: Long, duration: Duration) = loadAndPopulate { + val events = mutableListOf() + client.getLogEventsPaginator { + it.logGroupName(logGroup) + .logStreamName(logStream) + .startFromHead(true) + .startTime(startTime - duration.toMillis()) + .endTime(startTime + duration.toMillis()) + }.stream().forEach { response -> + if (nextBackwardToken == null) { + nextBackwardToken = response.nextBackwardToken() + } + nextForwardToken = response.nextForwardToken() + events.addAll(response.events().mapNotNull { it.toLogStreamEntry() }) + } + return@loadAndPopulate events + } + + override suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean, saveBackwardToken: Boolean): List { + val request = GetLogEventsRequest + .builder() + .logGroupName(logGroup) + .logStreamName(logStream) + .startFromHead(true) + .nextToken(nextToken) + .build() + + return getLogEvents(request, saveForwardToken = saveForwardToken, saveBackwardToken = saveBackwardToken) + } + + private fun getLogEvents( + request: GetLogEventsRequest, + saveForwardToken: Boolean = false, + saveBackwardToken: Boolean = false + ): List { + val response = client.getLogEvents(request) + val events = response.events().filterNotNull().map { it.toLogStreamEntry() } + if (saveForwardToken) { + nextForwardToken = response.nextForwardToken() + } + if (saveBackwardToken) { + nextBackwardToken = response.nextBackwardToken() + } + + return events + } +} + +class LogGroupActor( + project: Project, + client: CloudWatchLogsClient, + table: TableView, + private val logGroup: String +) : CloudWatchLogsActor(project, client, table) { + override val emptyText = message("cloudwatch.logs.no_log_streams") + override val tableErrorMessage = message("cloudwatch.logs.failed_to_load_streams", logGroup) + override val notFoundText = message("cloudwatch.logs.log_group_does_not_exist", logGroup) + + override suspend fun loadInitial() { + // With this order by we can't filter (API limitation), but it wouldn't make sense to order by name + val request = DescribeLogStreamsRequest.builder() + .logGroupName(logGroup) + .descending(true) + .orderBy(OrderBy.LAST_EVENT_TIME) + .build() + loadAndPopulate { getLogStreams(request) } + } + + override suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean, saveBackwardToken: Boolean): List { + // loading backwards doesn't make sense in this context, so just skip the event + if (saveBackwardToken) { + return listOf() + } + + val request = DescribeLogStreamsRequest.builder() + .logGroupName(logGroup) + .nextToken(nextToken) + .descending(true) + .orderBy(OrderBy.LAST_EVENT_TIME) + .build() + + return getLogStreams(request) + } + + private fun getLogStreams(request: DescribeLogStreamsRequest): List { + val response = client.describeLogStreams(request) + val events = response.logStreams().filterNotNull() + nextForwardToken = response.nextToken() + + return events + } +} + +class LogGroupSearchActor( + project: Project, + client: CloudWatchLogsClient, + table: TableView, + private val logGroup: String +) : CloudWatchLogsActor(project, client, table) { + override val emptyText = message("cloudwatch.logs.no_log_streams") + override val tableErrorMessage = message("cloudwatch.logs.failed_to_load_streams", logGroup) + override val notFoundText = message("cloudwatch.logs.log_group_does_not_exist", logGroup) + + override suspend fun loadInitialFilter(queryString: String) { + val request = DescribeLogStreamsRequest.builder() + .logGroupName(logGroup) + .descending(true) + .orderBy(OrderBy.LOG_STREAM_NAME) + .logStreamNamePrefix(queryString) + .build() + loadAndPopulate { getLogStreams(request) } + } + + override suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean, saveBackwardToken: Boolean): List { + // loading backwards doesn't make sense in this context, so just skip the event + if (saveBackwardToken) { + return listOf() + } + + val request = DescribeLogStreamsRequest.builder() + .logGroupName(logGroup) + .nextToken(nextToken) + .descending(true) + .orderBy(OrderBy.LOG_STREAM_NAME) + .build() + + return getLogStreams(request) + } + + private fun getLogStreams(request: DescribeLogStreamsRequest): List { + val response = client.describeLogStreams(request) + val events = response.logStreams().filterNotNull() + nextForwardToken = response.nextToken() + + return events + } +} + +class InsightsQueryResultsActor( + project: Project, + client: CloudWatchLogsClient, + table: TableView, + private val queryId: String +) : CloudWatchActor(project, client, table) { + sealed class Message { + object StartLoadingAll : Message() + object StopLoading : Message() + } + + override val emptyText = message("cloudwatch.logs.no_results_found") + override val tableErrorMessage = message("cloudwatch.logs.query_results_table_error") + override val notFoundText = message("cloudwatch.logs.no_results_found") + + private var loadJob: Job? = null + + override suspend fun handleMessages() { + for (message in channel) { + when (message) { + is Message.StartLoadingAll -> { + try { + startLoading() + } catch (e: Exception) { + notifyError( + project = project, + title = message("cloudwatch.logs.exception"), + content = ExceptionUtil.getThrowableText(e) + ) + } + } + is Message.StopLoading -> { + try { + stopLoading() + } catch (e: Exception) { + notifyError( + project = project, + title = message("cloudwatch.logs.exception"), + content = ExceptionUtil.getThrowableText(e) + ) + } + } + } + } + } + + // run on a separate context so we don't lock up the message listener + private fun startLoading() = coroutineScope.launch(getCoroutineBgContext()) { + tableLoading() + val loadedQueryResults = mutableSetOf() + var response: GetQueryResultsResponse + + do { + try { + response = client.getQueryResults { + it.queryId(queryId) + } + } catch (e: Exception) { + notifyError( + project = project, + title = message("cloudwatch.logs.exception"), + content = ExceptionUtil.getThrowableText(e) + ) + table.emptyText.text = tableErrorMessage + + channel.close() + return@launch + } + + // consider a buffered flow instead, but can't use until min-coroutine version in the IDE is 1.3.6 + // chunk the response because a) transforming the response into the table model may be expensive + // and b) don't want to force the table model to redraw after every single row + val dedupedResults = sequence { + response.results().forEach { result -> + val logResult = result.toLogResult() + val ptr = logResult.identifier() + if (ptr !in loadedQueryResults) { + loadedQueryResults.add(ptr) + yield(logResult) + } + } + }.chunked(1000) + + dedupedResults.forEach { chunk -> + LOG.info { "loading block of ${chunk.size}" } + table.listTableModel.addRows(chunk) + } + } while (isQueryRunning(response.status())) + + LOG.info { "done, ${loadedQueryResults.size} entries in set" } + tableDoneLoading() + table.emptyText.text = emptyText + LOG.info { "total items in table: ${table.listTableModel.items.size}" } + channel.close() + }.also { + loadJob = it + } + + private fun isQueryRunning(status: QueryStatus) = status !in terminalQueryStates + + private fun stopLoading() { + channel.close() + + loadJob?.let { job -> + if (job.isActive) { + job.cancel() + } + } + + coroutineScope.launch { + // network call; run off EDT + try { + client.stopQuery { + it.queryId(queryId) + } + } catch (e: Exception) { + // best effort; this will fail if the query raced to completion or user does not have permission + LOG.warn(e) { "Failed to stop query" } + } + } + } + + override fun dispose() { + stopLoading() + super.dispose() + } + + companion object { + private val LOG = getLogger() + private val terminalQueryStates = setOf(QueryStatus.COMPLETE, QueryStatus.CANCELLED, QueryStatus.FAILED) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchLogWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchLogWindow.kt index 18ad439a3a..695148fe50 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchLogWindow.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchLogWindow.kt @@ -3,51 +3,45 @@ package software.aws.toolkits.jetbrains.services.cloudwatch.logs -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.util.text.DateFormatUtil -import icons.AwsIcons -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.jetbrains.core.toolwindow.ToolkitToolWindowManager -import software.aws.toolkits.jetbrains.core.toolwindow.ToolkitToolWindowType +import software.aws.toolkits.jetbrains.core.toolwindow.ToolkitToolWindow import software.aws.toolkits.jetbrains.services.cloudwatch.logs.editor.CloudWatchLogGroup import software.aws.toolkits.jetbrains.services.cloudwatch.logs.editor.CloudWatchLogStream -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope -import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights.DetailedLogRecord +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights.QueryDetails +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights.QueryResultPanel +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.toolwindow.CloudWatchLogsToolWindowFactory import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudWatchResourceType import software.aws.toolkits.telemetry.CloudwatchlogsTelemetry import software.aws.toolkits.telemetry.Result import java.time.Duration -class CloudWatchLogWindow(private val project: Project) : CoroutineScope by ApplicationThreadPoolScope("openLogGroup") { - private val toolWindow = ToolkitToolWindowManager.getInstance(project, CW_LOGS_TOOL_WINDOW) - private val edtContext = getCoroutineUiContext() +class CloudWatchLogWindow(override val project: Project) : ToolkitToolWindow { + override val toolWindowId = CloudWatchLogsToolWindowFactory.TOOLWINDOW_ID - fun showLogGroup(logGroup: String) = launch { + fun showLogGroup(logGroup: String) { var result = Result.Succeeded - try { - val existingWindow = toolWindow.find(logGroup) - if (existingWindow != null) { - withContext(edtContext) { - existingWindow.show() + runInEdt { + try { + if (!showExistingContent(logGroup)) { + val group = CloudWatchLogGroup(project, logGroup) + val title = message("cloudwatch.logs.log_group_title", logGroup.split("/").last()) + addTab(title, group.content, activate = true, id = logGroup, additionalDisposable = group) } - return@launch - } - val group = CloudWatchLogGroup(project, logGroup) - val title = message("cloudwatch.logs.log_group_title", logGroup.split("/").last()) - withContext(edtContext) { - toolWindow.addTab(title, group.content, activate = true, id = logGroup, disposable = group) + } catch (e: Exception) { + LOG.error(e) { "Exception thrown while trying to show log group '$logGroup'" } + result = Result.Failed + throw e + } finally { + CloudwatchlogsTelemetry.open(project, result, CloudWatchResourceType.LogGroup, source = "logGroup") } - } catch (e: Exception) { - LOG.error(e) { "Exception thrown while trying to show log group '$logGroup'" } - result = Result.Failed - throw e - } finally { - CloudwatchlogsTelemetry.openGroup(project, result) } } @@ -55,56 +49,69 @@ class CloudWatchLogWindow(private val project: Project) : CoroutineScope by Appl logGroup: String, logStream: String, previousEvent: LogStreamEntry? = null, - duration: Duration? = null - ) = launch { + duration: Duration? = null, + streamLogs: Boolean = false + ) { var result = Result.Succeeded try { val id = "$logGroup/$logStream/${previousEvent?.timestamp}/${previousEvent?.message}/$duration" - val existingWindow = toolWindow.find(id) - if (existingWindow != null) { - withContext(edtContext) { - existingWindow.show() - } - return@launch + if (showExistingContent(id)) { + return } val title = if (previousEvent != null && duration != null) { message( - "cloudwatch.logs.filtered_log_stream_title", logStream, + "cloudwatch.logs.filtered_log_stream_title", + logStream, DateFormatUtil.getDateTimeFormat().format(previousEvent.timestamp - duration.toMillis()), DateFormatUtil.getDateTimeFormat().format(previousEvent.timestamp + duration.toMillis()) ) } else { message("cloudwatch.logs.log_stream_title", logStream) } - val stream = CloudWatchLogStream(project, logGroup, logStream, previousEvent, duration) - withContext(edtContext) { - toolWindow.addTab(title, stream.content, activate = true, id = id, disposable = stream) + val stream = CloudWatchLogStream(project, logGroup, logStream, previousEvent, duration, streamLogs) + runInEdt { + addTab(title, stream.content, activate = true, id = id, additionalDisposable = stream) } } catch (e: Exception) { LOG.error(e) { "Exception thrown while trying to show log group '$logGroup' stream '$logStream'" } result = Result.Failed throw e } finally { - CloudwatchlogsTelemetry.openStream(project, result) + CloudwatchlogsTelemetry.open(project, result, CloudWatchResourceType.LogStream, source = "logStream") } } - fun closeLogGroup(logGroup: String) = launch { - toolWindow.findPrefix(logGroup).forEach { - withContext(edtContext) { - it.dispose() - } + fun showQueryResults(queryDetails: QueryDetails, queryId: String, fields: List) { + if (showExistingContent(queryId)) { + return + } + + val panel = QueryResultPanel(project, fields, queryId, queryDetails) + val title = message("cloudwatch.logs.query_tab_title", queryId) + runInEdt { + addTab(title, panel, activate = true, id = queryId) } } - companion object { - internal val CW_LOGS_TOOL_WINDOW = ToolkitToolWindowType( - "AWS.CloudWatchLog", - message("cloudwatch.logs.toolwindow"), - AwsIcons.Resources.CloudWatch.LOGS_TOOL_WINDOW - ) + fun showDetailedEvent(client: CloudWatchLogsClient, identifier: String) { + if (showExistingContent(identifier)) { + return + } + + val detailedLogEvent = DetailedLogRecord(project, client, identifier) + runInEdt { + addTab(detailedLogEvent.title, detailedLogEvent.getComponent(), activate = true, id = identifier, additionalDisposable = detailedLogEvent) + } + } - fun getInstance(project: Project) = ServiceManager.getService(project, CloudWatchLogWindow::class.java) + fun closeLogGroup(logGroup: String) { + findPrefix(logGroup).forEach { + removeContent(it) + } + } + + companion object { private val LOG = getLogger() + fun getInstance(project: Project) = project.service() } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchLogsServiceNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchLogsServiceNode.kt index 5725ad3dad..511a92a210 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchLogsServiceNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/CloudWatchLogsServiceNode.kt @@ -12,12 +12,14 @@ import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerResourceNo import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceNode import software.aws.toolkits.jetbrains.core.explorer.nodes.CacheBackedAwsExplorerServiceRootNode import software.aws.toolkits.jetbrains.services.cloudwatch.logs.resources.CloudWatchResources +import software.aws.toolkits.resources.message class CloudWatchLogsServiceNode(project: Project, service: AwsExplorerServiceNode) : CacheBackedAwsExplorerServiceRootNode( project, service, CloudWatchResources.LIST_LOG_GROUPS ) { + override fun displayName(): String = message("explorer.node.cloudwatch") override fun toNode(child: LogGroup): AwsExplorerNode<*> = CloudWatchLogsNode(nodeProject, child.arn(), child.logGroupName()) } @@ -38,6 +40,6 @@ class CloudWatchLogsNode( override fun displayName() = logGroupName override fun onDoubleClick() { - CloudWatchLogWindow.getInstance(nodeProject)?.showLogGroup(logGroupName) + CloudWatchLogWindow.getInstance(nodeProject).showLogGroup(logGroupName) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/DownloadLogStream.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/DownloadLogStream.kt index be9e8809cf..f0597b20ec 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/DownloadLogStream.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/DownloadLogStream.kt @@ -16,32 +16,36 @@ import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages import com.intellij.openapi.util.io.FileUtilRt +import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileWrapper -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient import software.amazon.awssdk.services.cloudwatchlogs.model.GetLogEventsRequest import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext import software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.OpenStreamInEditor import software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.buildStringFromLogsOutput -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope -import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.jetbrains.utils.notifyInfo import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudWatchResourceType import software.aws.toolkits.telemetry.CloudwatchlogsTelemetry import software.aws.toolkits.telemetry.Result import java.io.File import java.nio.file.Files import java.time.Instant +import kotlin.coroutines.CoroutineContext import kotlin.streams.asSequence -class LogStreamDownloadTask(project: Project, val client: CloudWatchLogsClient, val logGroup: String, val logStream: String) : - Task.Backgroundable(project, message("cloudwatch.logs.opening_in_editor", logStream), true), - CoroutineScope by ApplicationThreadPoolScope("OpenLogStreamInEditor") { - private val edt = getCoroutineUiContext() +class LogStreamDownloadTask( + project: Project, + private val edt: CoroutineContext, + val client: CloudWatchLogsClient, + val logGroup: String, + val logStream: String +) : Task.Backgroundable(project, message("cloudwatch.logs.opening_in_editor", logStream), true) { override fun run(indicator: ProgressIndicator) = runBlocking { // Default content load limit is 20MB, default per page is 1MB/10000 log entries. so we load MaxLength/1MB @@ -56,15 +60,15 @@ class LogStreamDownloadTask(project: Project, val client: CloudWatchLogsClient, .logStreamName(logStream) .endTime(startTime.toEpochMilli()) val getRequest = client.getLogEventsPaginator(request.build()) - getRequest.stream().asSequence().forEachIndexed { index, it -> + getRequest.stream().asSequence().forEachIndexed { index, value -> indicator.checkCanceled() - buffer.append(it.events().buildStringFromLogsOutput()) + buffer.append(value.events().buildStringFromLogsOutput()) // This might look off by 1 because for example if we are at index 20, it's the // 21st iteration, but at this point we won't try to open in a file so we bail from // streaming at the correct time if (index >= maxPages) { runBlocking { - request.nextToken(it.nextForwardToken()) + request.nextToken(value.nextForwardToken()) if (promptWriteToFile() == Messages.OK) { ProgressManager.getInstance().run( LogStreamDownloadToFileTask( @@ -84,7 +88,9 @@ class LogStreamDownloadTask(project: Project, val client: CloudWatchLogsClient, } } - val success = OpenStreamInEditor.open(project, edt, logStream, buffer.toString()) + val success = withContext(edt) { + OpenStreamInEditor.open(project, logStream, buffer.toString()) + } CloudwatchlogsTelemetry.openStreamInEditor(project, success) } @@ -104,7 +110,7 @@ class LogStreamDownloadTask(project: Project, val client: CloudWatchLogsClient, message("cloudwatch.logs.stream_too_big_message", logStream), message("cloudwatch.logs.stream_too_big"), message("cloudwatch.logs.stream_save_to_file", logStream), - Messages.CANCEL_BUTTON, + Messages.getCancelButton(), AllIcons.General.QuestionDialog ) } @@ -140,7 +146,7 @@ class LogStreamDownloadToFileTask( val descriptor = FileSaverDescriptor(message("cloudwatch.logs.download"), message("cloudwatch.logs.download.description")) val saveLocation = withContext(edt) { val destination = FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project) - destination.save(null, logStream) + destination.save(null as VirtualFile?, logStream) } if (saveLocation != null) { streamLogStreamToFile(indicator, request, saveLocation.file, buffer) @@ -163,7 +169,7 @@ class LogStreamDownloadToFileTask( title = message("aws.notification.title"), content = message("cloudwatch.logs.saving_to_disk_succeeded", logStream, file.path), notificationActions = listOf( - object : AnAction(message("cloudwatch.logs.open_in_editor"), null, AllIcons.Actions.Menu_open) { + object : AnAction(message("cloudwatch.logs.open_in_editor"), null, AllIcons.Actions.MenuOpen) { override fun actionPerformed(e: AnActionEvent) { val virtualFile = VirtualFileWrapper(file).virtualFile ?: throw IllegalStateException("Log Stream was downloaded but does not exist on disk!") @@ -172,22 +178,22 @@ class LogStreamDownloadToFileTask( } ) ) - CloudwatchlogsTelemetry.downloadStreamToFile(project, success = true) + CloudwatchlogsTelemetry.download(project, success = true, CloudWatchResourceType.LogStream) } catch (e: Exception) { LOG.error(e) { "Exception thrown while downloading large log stream" } e.notifyError(project = project, title = message("cloudwatch.logs.saving_to_disk_failed", logStream)) - CloudwatchlogsTelemetry.downloadStreamToFile(project, success = false) + CloudwatchlogsTelemetry.download(project, success = false, CloudWatchResourceType.LogStream) } } override fun onThrowable(e: Throwable) { - LogStreamDownloadTask.LOG.error(e) { "LogStreamDownloadToFileTask exception thrown" } + LOG.error(e) { "LogStreamDownloadToFileTask exception thrown" } val result = if (e is ProcessCanceledException) { Result.Cancelled } else { Result.Failed } - CloudwatchlogsTelemetry.downloadStreamToFile(project, result) + CloudwatchlogsTelemetry.download(project, result, CloudWatchResourceType.LogStream) } companion object { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/LogActor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/LogActor.kt deleted file mode 100644 index f57b0bb043..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/LogActor.kt +++ /dev/null @@ -1,381 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.cloudwatch.logs - -import com.intellij.openapi.Disposable -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer -import com.intellij.ui.TableUtil -import com.intellij.ui.table.TableView -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient -import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogStreamsRequest -import software.amazon.awssdk.services.cloudwatchlogs.model.FilterLogEventsRequest -import software.amazon.awssdk.services.cloudwatchlogs.model.GetLogEventsRequest -import software.amazon.awssdk.services.cloudwatchlogs.model.LogStream -import software.amazon.awssdk.services.cloudwatchlogs.model.OrderBy -import software.amazon.awssdk.services.cloudwatchlogs.model.ResourceNotFoundException -import software.aws.toolkits.core.utils.error -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope -import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext -import software.aws.toolkits.jetbrains.utils.notifyError -import software.aws.toolkits.resources.message -import java.time.Duration - -sealed class LogActor( - private val project: Project, - protected val client: CloudWatchLogsClient, - private val table: TableView -) : CoroutineScope by ApplicationThreadPoolScope("CloudWatchLogsStream"), Disposable { - val channel = Channel() - - protected var nextBackwardToken: String? = null - protected var nextForwardToken: String? = null - protected abstract val emptyText: String - protected abstract val tableErrorMessage: String - protected abstract val notFoundText: String - - private val edtContext = getCoroutineUiContext(disposable = this@LogActor) - private val exceptionHandler = CoroutineExceptionHandler { _, e -> - LOG.error(e) { "Exception thrown in the LogStreamActor not handled:" } - notifyError(title = message("general.unknown_error"), project = project) - table.setPaintBusy(false) - Disposer.dispose(this) - } - - sealed class Message { - object LoadInitial : Message() - class LoadInitialRange(val previousEvent: LogStreamEntry, val duration: Duration) : Message() - class LoadInitialFilter(val queryString: String) : Message() - object LoadForward : Message() - object LoadBackward : Message() - } - - init { - launch(exceptionHandler) { - handleMessages() - } - } - - private suspend fun handleMessages() { - for (message in channel) { - when (message) { - is Message.LoadForward -> if (!nextForwardToken.isNullOrEmpty()) { - withContext(edtContext) { table.setPaintBusy(true) } - val items = loadMore(nextForwardToken, saveForwardToken = true) - withContext(edtContext) { - table.listTableModel.addRows(items) - table.setPaintBusy(false) - } - } - is Message.LoadBackward -> if (!nextBackwardToken.isNullOrEmpty()) { - withContext(edtContext) { table.setPaintBusy(true) } - val items = loadMore(nextBackwardToken, saveBackwardToken = true) - if (items.isNotEmpty()) { - // Selected rows can be non contiguous so we have to save every row selected - val newSelection = table.selectedRows.map { it + items.size } - val viewRect = table.visibleRect - table.listTableModel.items = items + table.listTableModel.items - withContext(edtContext) { - table.tableViewModel.fireTableDataChanged() - val offset = table.getCellRect(items.size, 0, true) - // Move the view box down y - cell height amount to stay in the same place - viewRect.y = (viewRect.y + offset.y - offset.height) - table.scrollRectToVisible(viewRect) - // Re-add the selection - newSelection.forEach { - table.addRowSelectionInterval(it, it) - } - } - } - withContext(edtContext) { table.setPaintBusy(false) } - } - is Message.LoadInitial -> { - loadInitial() - // make sure the scroll pane is at the top after loading. Needed for Refresh! - val rect = table.getCellRect(0, 0, true) - withContext(edtContext) { - table.scrollRectToVisible(rect) - } - } - is Message.LoadInitialRange -> { - loadInitialRange(message.previousEvent.timestamp, message.duration) - val item = table.listTableModel.items.firstOrNull { it == message.previousEvent } - val index = table.listTableModel.indexOf(item).takeIf { it > 0 } ?: return - withContext(edtContext) { - table.setRowSelectionInterval(index, index) - TableUtil.scrollSelectionToVisible(table) - } - } - is Message.LoadInitialFilter -> { - loadInitialFilter(message.queryString) - } - } - } - } - - protected suspend fun loadAndPopulate(loadBlock: suspend () -> List) { - try { - tableLoading() - val items = loadBlock() - table.listTableModel.items = items - table.emptyText.text = emptyText - } catch (e: ResourceNotFoundException) { - withContext(edtContext) { - table.emptyText.text = notFoundText - } - } catch (e: Exception) { - LOG.error(e) { tableErrorMessage } - withContext(edtContext) { - table.emptyText.text = tableErrorMessage - notifyError(title = tableErrorMessage, project = project) - } - } finally { - tableDoneLoading() - } - } - - protected open suspend fun loadInitial() { - throw IllegalStateException("Table does not support loadInitial") - } - - protected open suspend fun loadInitialRange(startTime: Long, duration: Duration) { - throw IllegalStateException("Table does not support loadInitialRange") - } - - protected open suspend fun loadInitialFilter(queryString: String) { - throw IllegalStateException("Table does not support loadInitialFilter") - } - - protected abstract suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean = false, saveBackwardToken: Boolean = false): List - - private suspend fun tableLoading() = withContext(edtContext) { - table.setPaintBusy(true) - table.emptyText.text = message("loading_resource.loading") - } - - private suspend fun tableDoneLoading() = withContext(edtContext) { - table.tableViewModel.fireTableDataChanged() - table.setPaintBusy(false) - } - - override fun dispose() { - channel.close() - } - - companion object { - private val LOG = getLogger>() - } -} - -class LogStreamFilterActor( - project: Project, - client: CloudWatchLogsClient, - table: TableView, - private val logGroup: String, - private val logStream: String -) : LogActor(project, client, table) { - override val emptyText = message("cloudwatch.logs.no_events_query", logStream) - override val tableErrorMessage = message("cloudwatch.logs.failed_to_load_stream", logStream) - override val notFoundText = message("cloudwatch.logs.log_stream_does_not_exist", logStream) - - override suspend fun loadInitialFilter(queryString: String) { - val request = FilterLogEventsRequest - .builder() - .logGroupName(logGroup) - .logStreamNames(logStream) - .filterPattern(queryString) - .build() - loadAndPopulate { getSearchLogEvents(request) } - } - - override suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean, saveBackwardToken: Boolean): List { - // loading backwards doesn't make sense in this context, so just skip the event - if (saveBackwardToken) { - return listOf() - } - - val request = FilterLogEventsRequest - .builder() - .logGroupName(logGroup) - .logStreamNames(logStream) - .nextToken(nextToken) - .build() - - return getSearchLogEvents(request) - } - - private fun getSearchLogEvents(request: FilterLogEventsRequest): List { - val response = client.filterLogEvents(request) - val events = response.events().filterNotNull().map { it.toLogStreamEntry() } - nextForwardToken = response.nextToken() - - return events - } -} - -class LogStreamListActor( - project: Project, - client: CloudWatchLogsClient, - table: TableView, - private val logGroup: String, - private val logStream: String -) : - LogActor(project, client, table) { - override val emptyText = message("cloudwatch.logs.no_events") - override val tableErrorMessage = message("cloudwatch.logs.failed_to_load_stream", logStream) - override val notFoundText = message("cloudwatch.logs.log_stream_does_not_exist", logStream) - - override suspend fun loadInitial() { - val request = GetLogEventsRequest - .builder() - .logGroupName(logGroup) - .logStreamName(logStream) - .startFromHead(true) - .build() - loadAndPopulate { getLogEvents(request, saveForwardToken = true, saveBackwardToken = true) } - } - - override suspend fun loadInitialRange(startTime: Long, duration: Duration) = loadAndPopulate { - val events = mutableListOf() - client.getLogEventsPaginator { - it.logGroupName(logGroup) - .logStreamName(logStream) - .startFromHead(true) - .startTime(startTime - duration.toMillis()) - .endTime(startTime + duration.toMillis()) - }.stream().forEach { response -> - if (nextBackwardToken == null) { - nextBackwardToken = response.nextBackwardToken() - } - nextForwardToken = response.nextForwardToken() - events.addAll(response.events().mapNotNull { it.toLogStreamEntry() }) - } - return@loadAndPopulate events - } - - override suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean, saveBackwardToken: Boolean): List { - val request = GetLogEventsRequest - .builder() - .logGroupName(logGroup) - .logStreamName(logStream) - .startFromHead(true) - .nextToken(nextToken) - .build() - - return getLogEvents(request, saveForwardToken = saveForwardToken, saveBackwardToken = saveBackwardToken) - } - - private fun getLogEvents( - request: GetLogEventsRequest, - saveForwardToken: Boolean = false, - saveBackwardToken: Boolean = false - ): List { - val response = client.getLogEvents(request) - val events = response.events().filterNotNull().map { it.toLogStreamEntry() } - if (saveForwardToken) { - nextForwardToken = response.nextForwardToken() - } - if (saveBackwardToken) { - nextBackwardToken = response.nextBackwardToken() - } - - return events - } -} - -class LogGroupActor( - project: Project, - client: CloudWatchLogsClient, - table: TableView, - private val logGroup: String -) : LogActor(project, client, table) { - override val emptyText = message("cloudwatch.logs.no_log_streams") - override val tableErrorMessage = message("cloudwatch.logs.failed_to_load_streams", logGroup) - override val notFoundText = message("cloudwatch.logs.log_group_does_not_exist", logGroup) - - override suspend fun loadInitial() { - // With this order by we can't filter (API limitation), but it wouldn't make sense to order by name - val request = DescribeLogStreamsRequest.builder() - .logGroupName(logGroup) - .descending(true) - .orderBy(OrderBy.LAST_EVENT_TIME) - .build() - loadAndPopulate { getLogStreams(request) } - } - - override suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean, saveBackwardToken: Boolean): List { - // loading backwards doesn't make sense in this context, so just skip the event - if (saveBackwardToken) { - return listOf() - } - - val request = DescribeLogStreamsRequest.builder() - .logGroupName(logGroup) - .nextToken(nextToken) - .descending(true) - .orderBy(OrderBy.LAST_EVENT_TIME) - .build() - - return getLogStreams(request) - } - - private fun getLogStreams(request: DescribeLogStreamsRequest): List { - val response = client.describeLogStreams(request) - val events = response.logStreams().filterNotNull() - nextForwardToken = response.nextToken() - - return events - } -} - -class LogGroupSearchActor( - project: Project, - client: CloudWatchLogsClient, - table: TableView, - private val logGroup: String -) : LogActor(project, client, table) { - override val emptyText = message("cloudwatch.logs.no_log_streams") - override val tableErrorMessage = message("cloudwatch.logs.failed_to_load_streams", logGroup) - override val notFoundText = message("cloudwatch.logs.log_group_does_not_exist", logGroup) - - override suspend fun loadInitialFilter(queryString: String) { - val request = DescribeLogStreamsRequest.builder() - .logGroupName(logGroup) - .descending(true) - .orderBy(OrderBy.LOG_STREAM_NAME) - .logStreamNamePrefix(queryString) - .build() - loadAndPopulate { getLogStreams(request) } - } - - override suspend fun loadMore(nextToken: String?, saveForwardToken: Boolean, saveBackwardToken: Boolean): List { - // loading backwards doesn't make sense in this context, so just skip the event - if (saveBackwardToken) { - return listOf() - } - - val request = DescribeLogStreamsRequest.builder() - .logGroupName(logGroup) - .nextToken(nextToken) - .descending(true) - .orderBy(OrderBy.LOG_STREAM_NAME) - .build() - - return getLogStreams(request) - } - - private fun getLogStreams(request: DescribeLogStreamsRequest): List { - val response = client.describeLogStreams(request) - val events = response.logStreams().filterNotNull() - nextForwardToken = response.nextToken() - - return events - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/LogStreamEntry.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/LogStreamEntry.kt index c6729192b8..f156b10428 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/LogStreamEntry.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/LogStreamEntry.kt @@ -8,5 +8,5 @@ import software.amazon.awssdk.services.cloudwatchlogs.model.OutputLogEvent data class LogStreamEntry(val message: String, val timestamp: Long) -fun OutputLogEvent.toLogStreamEntry() = LogStreamEntry(message() ?: "", timestamp() ?: 0) -fun FilteredLogEvent.toLogStreamEntry() = LogStreamEntry(message() ?: "", timestamp() ?: 0) +fun OutputLogEvent.toLogStreamEntry() = LogStreamEntry(message()?.trim() ?: "", timestamp() ?: 0) +fun FilteredLogEvent.toLogStreamEntry() = LogStreamEntry(message()?.trim() ?: "", timestamp() ?: 0) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/CopyFromTableAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/CopyFromTableAction.kt deleted file mode 100644 index 827d1f3f7f..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/CopyFromTableAction.kt +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions - -import com.intellij.icons.AllIcons -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.ide.CopyPasteManager -import com.intellij.openapi.project.DumbAware -import com.intellij.ui.table.TableView -import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogStreamEntry -import software.aws.toolkits.resources.message -import java.awt.datatransfer.StringSelection - -// FIX_WHEN_MIN_IS_201 make anaction text dynamic based on table size -class CopyFromTableAction(private val table: TableView) : - AnAction(message("cloudwatch.logs.copy_action", 1), null, AllIcons.Actions.Copy), - DumbAware { - override fun update(e: AnActionEvent) { - e.presentation.text = message("cloudwatch.logs.copy_action", table.selectedRows.size) - } - - override fun actionPerformed(e: AnActionEvent) { - val copyPasteManager = CopyPasteManager.getInstance() - // This emulates the copy that that comes from the jtable which is TSV. We could - // pull the action out of the table but it would be more weird - val selection = table.selectedRows.joinToString("\n") { row -> - table.selectedColumns.joinToString("\t") { column -> - table.getValueAt(row, column).toString().trim() - } - } - copyPasteManager.setContents(StringSelection(selection)) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/DeleteGroupAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/DeleteGroupAction.kt index 724a3cd9dc..b87adddc53 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/DeleteGroupAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/DeleteGroupAction.kt @@ -4,20 +4,19 @@ package software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient -import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.awsClient import software.aws.toolkits.jetbrains.core.explorer.actions.DeleteResourceAction import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogsNode import software.aws.toolkits.jetbrains.services.cloudwatch.logs.resources.CloudWatchResources -import software.aws.toolkits.jetbrains.utils.TaggingResourceType import software.aws.toolkits.resources.message -class DeleteGroupAction : DeleteResourceAction(message("cloudwatch.logs.delete_log_group"), TaggingResourceType.CLOUDWATCHLOGS_GROUP) { +class DeleteGroupAction : DeleteResourceAction(message("cloudwatch.logs.delete_log_group")) { override fun performDelete(selected: CloudWatchLogsNode) { - val client: CloudWatchLogsClient = AwsClientManager.getInstance(selected.nodeProject).getClient() + val client: CloudWatchLogsClient = selected.nodeProject.awsClient() - CloudWatchLogWindow.getInstance(selected.nodeProject)?.closeLogGroup(selected.logGroupName) + CloudWatchLogWindow.getInstance(selected.nodeProject).closeLogGroup(selected.logGroupName) client.deleteLogGroup { it.logGroupName(selected.logGroupName) } selected.nodeProject.refreshAwsTree(CloudWatchResources.LIST_LOG_GROUPS) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/DownloadLogStreamToFileAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/DownloadLogStreamToFileAction.kt index ae2313303b..de14858591 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/DownloadLogStreamToFileAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/DownloadLogStreamToFileAction.kt @@ -18,7 +18,7 @@ class DownloadLogStreamToFileAction( private val client: CloudWatchLogsClient, private val logGroup: String, private val logStream: String? -) : AnAction(message("cloudwatch.logs.save_action"), null, AllIcons.Actions.Menu_saveall), DumbAware { +) : AnAction(message("cloudwatch.logs.save_action"), null, AllIcons.Actions.MenuSaveall), DumbAware { override fun actionPerformed(e: AnActionEvent) { logStream ?: return ProgressManager.getInstance().run(LogStreamDownloadToFileTask(project, client, logGroup, logStream, "")) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenCurrentInEditorAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenCurrentInEditorAction.kt index 0b0edaf6e8..8d6bb2222d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenCurrentInEditorAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenCurrentInEditorAction.kt @@ -6,13 +6,14 @@ package software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogStreamEntry -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope -import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.CloudwatchlogsTelemetry @@ -20,15 +21,16 @@ class OpenCurrentInEditorAction( private val project: Project, private val logStream: String, private val tableEntries: () -> List -) : - AnAction(message("cloudwatch.logs.open_in_editor"), null, AllIcons.Actions.Menu_open), - CoroutineScope by ApplicationThreadPoolScope("OpenCurrentInEditorAction"), +) : AnAction(message("cloudwatch.logs.open_in_editor"), null, AllIcons.Actions.MenuOpen), DumbAware { - private val edt = getCoroutineUiContext() + private val coroutineScope = projectCoroutineScope(project) override fun actionPerformed(e: AnActionEvent) { - launch { - val success = OpenStreamInEditor.open(project, edt, logStream, tableEntries().buildStringFromLogs()) + val modality = e.dataContext.getData(PlatformDataKeys.CONTEXT_COMPONENT)?.let { ModalityState.stateForComponent(it) } + ?: ModalityState.defaultModalityState() + + coroutineScope.launch(modality.asContextElement()) { + val success = OpenStreamInEditor.open(project, logStream, tableEntries().buildStringFromLogs()) CloudwatchlogsTelemetry.viewCurrentMessagesInEditor(project, success = success, value = tableEntries().size.toDouble()) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenLogGroupAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenLogGroupAction.kt index a5eda7418a..f9aa1431c4 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenLogGroupAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenLogGroupAction.kt @@ -12,6 +12,6 @@ import software.aws.toolkits.resources.message class OpenLogGroupAction : SingleResourceNodeAction(message("cloudwatch.logs.open")), DumbAware { override fun actionPerformed(selected: CloudWatchLogsNode, e: AnActionEvent) { - CloudWatchLogWindow.getInstance(selected.nodeProject)?.showLogGroup(selected.logGroupName) + CloudWatchLogWindow.getInstance(selected.nodeProject).showLogGroup(selected.logGroupName) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenLogStreamInEditorAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenLogStreamInEditorAction.kt index 2fd2e80e04..c71cf3ddc1 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenLogStreamInEditorAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenLogStreamInEditorAction.kt @@ -6,6 +6,9 @@ package software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project @@ -18,9 +21,12 @@ class OpenLogStreamInEditorAction( private val client: CloudWatchLogsClient, private val logGroup: String, private val logStream: String? -) : AnAction(message("cloudwatch.logs.open_in_editor"), null, AllIcons.Actions.Menu_open), DumbAware { +) : AnAction(message("cloudwatch.logs.open_in_editor"), null, AllIcons.Actions.MenuOpen), DumbAware { override fun actionPerformed(e: AnActionEvent) { logStream ?: return - ProgressManager.getInstance().run(LogStreamDownloadTask(project, client, logGroup, logStream)) + val modality = e.dataContext.getData(PlatformDataKeys.CONTEXT_COMPONENT)?.let { ModalityState.stateForComponent(it) } + ?: ModalityState.defaultModalityState() + + ProgressManager.getInstance().run(LogStreamDownloadTask(project, modality.asContextElement(), client, logGroup, logStream)) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenStreamInEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenStreamInEditor.kt index f603d30ef5..2b7db3f274 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenStreamInEditor.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/OpenStreamInEditor.kt @@ -9,16 +9,16 @@ import com.intellij.openapi.project.Project import com.intellij.testFramework.ReadOnlyLightVirtualFile import kotlinx.coroutines.withContext import software.amazon.awssdk.services.cloudwatchlogs.model.OutputLogEvent +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogStreamEntry import software.aws.toolkits.jetbrains.services.cloudwatch.logs.toLogStreamEntry import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.resources.message -import kotlin.coroutines.CoroutineContext object OpenStreamInEditor { - suspend fun open(project: Project, edt: CoroutineContext, logStream: String, fileContent: String): Boolean { + suspend fun open(project: Project, logStream: String, fileContent: String): Boolean { val file = ReadOnlyLightVirtualFile(logStream, PlainTextLanguage.INSTANCE, fileContent) - return withContext(edt) { + return withContext(getCoroutineUiContext()) { // set virtual file to read only FileEditorManager.getInstance(project).openFile(file, true, true).ifEmpty { if (!fileContent.isBlank()) { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/ShowLogsAroundAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/ShowLogsAroundAction.kt index 9461e01f4d..81330300f7 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/ShowLogsAroundAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/ShowLogsAroundAction.kt @@ -10,6 +10,7 @@ import com.intellij.openapi.actionSystem.PlatformDataKeys import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.ui.table.TableView +import kotlinx.coroutines.runBlocking import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogStreamEntry import software.aws.toolkits.resources.message @@ -41,15 +42,15 @@ private class ShowLogsAround( timeMessage: String, private val duration: Duration ) : AnAction(timeMessage, null, null), DumbAware { - override fun actionPerformed(e: AnActionEvent) { + override fun actionPerformed(e: AnActionEvent) = runBlocking { CloudwatchlogsTelemetry.showEventsAround(project, success = true, value = duration.toMillis().toDouble()) val project = e.getRequiredData(PlatformDataKeys.PROJECT) val window = CloudWatchLogWindow.getInstance(project) val selectedRow = treeTable.selectedRow if (selectedRow >= treeTable.listTableModel.rowCount || selectedRow < 0) { - return + return@runBlocking } - val selectedObject = treeTable.listTableModel.getItem(selectedRow) ?: return + val selectedObject = treeTable.listTableModel.getItem(selectedRow) ?: return@runBlocking window.showLogStream(logGroup, logStream, selectedObject, duration) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/TailLogsAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/TailLogsAction.kt index 872103219d..a1480bee3f 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/TailLogsAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/actions/TailLogsAction.kt @@ -8,30 +8,34 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.ToggleAction import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.annotations.TestOnly -import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogActor -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogsActor import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.CloudwatchlogsTelemetry -class TailLogsAction(private val project: Project, private val channel: () -> Channel) : +class TailLogsAction(private val project: Project, private val channel: () -> Channel) : ToggleAction(message("cloudwatch.logs.tail"), null, AllIcons.RunConfigurations.Scroll_down), - CoroutineScope by ApplicationThreadPoolScope("TailCloudWatchLogs"), DumbAware { + private val coroutineScope = projectCoroutineScope(project) private var isSelected = false var logStreamingJob: Job? = null private set + @TestOnly get override fun isSelected(e: AnActionEvent): Boolean = isSelected override fun setSelected(e: AnActionEvent, state: Boolean) { + setSelected(state) + } + + fun setSelected(state: Boolean) { CloudwatchlogsTelemetry.tailStream(project, enabled = state) isSelected = state if (state) { @@ -42,10 +46,10 @@ class TailLogsAction(private val project: Project, private val channel: () -> Ch } private fun startTailing() { - logStreamingJob = launch { + logStreamingJob = coroutineScope.launch { while (true) { try { - channel().send(LogActor.Message.LoadForward) + channel().send(CloudWatchLogsActor.Message.LoadForward) delay(1000) } catch (e: ClosedSendChannelException) { // Channel is closed, so break out of the while loop and kill the coroutine diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogGroup.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogGroup.form index 802887771a..93904a5d57 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogGroup.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogGroup.form @@ -33,8 +33,8 @@ - - + + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogGroup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogGroup.kt index 7f7f01ef2c..39f951f99d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogGroup.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogGroup.kt @@ -6,42 +6,50 @@ package software.aws.toolkits.jetbrains.services.cloudwatch.logs.editor import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import com.intellij.openapi.ui.SimpleToolWindowPanel import com.intellij.openapi.util.Disposer import com.intellij.ui.SearchTextField -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient -import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogActor -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope -import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ConnectionState +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogsActor +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights.QueryEditorDialog import software.aws.toolkits.jetbrains.utils.ui.onEmpty import software.aws.toolkits.jetbrains.utils.ui.onEnter import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudWatchResourceType +import software.aws.toolkits.telemetry.CloudwatchinsightsTelemetry import software.aws.toolkits.telemetry.CloudwatchlogsTelemetry +import software.aws.toolkits.telemetry.InsightsDialogOpenSource import javax.swing.JPanel class CloudWatchLogGroup( private val project: Project, private val logGroup: String -) : CoroutineScope by ApplicationThreadPoolScope("CloudWatchLogsGroup"), Disposable { +) : Disposable { + private val coroutineScope = disposableCoroutineScope(this) lateinit var content: JPanel + private set - private val edtContext = getCoroutineUiContext(disposable = this) + private val edtContext = getCoroutineUiContext() private lateinit var tablePanel: SimpleToolWindowPanel private lateinit var locationInformation: LocationBreadcrumbs private lateinit var searchField: SearchTextField private lateinit var breadcrumbHolder: JPanel - val client: CloudWatchLogsClient = project.awsClient() - private val groupTable: LogGroupTable = LogGroupTable(project, client, logGroup, LogGroupTable.TableType.LIST) + private val client: CloudWatchLogsClient + private val connection: ConnectionSettings + private val groupTable: LogGroupTable private var searchGroupTable: LogGroupTable? = null private fun createUIComponents() { @@ -52,10 +60,17 @@ class CloudWatchLogGroup( } init { + + connection = when (val state = AwsConnectionManager.getInstance(project).connectionState) { + is ConnectionState.ValidConnection -> ConnectionSettings(state.credentials, state.region) + else -> throw IllegalStateException(state.shortMessage) + } + client = AwsClientManager.getInstance().getClient(connection.credentials, connection.region) + groupTable = LogGroupTable(project, client, logGroup, LogGroupTable.TableType.LIST) + val locationCrumbs = LocationCrumbs(project, logGroup) locationInformation.crumbs = locationCrumbs.crumbs breadcrumbHolder.border = locationCrumbs.border - locationInformation.installClickListener() Disposer.register(this, groupTable) tablePanel.setContent(groupTable.component) @@ -69,7 +84,7 @@ class CloudWatchLogGroup( searchField.onEmpty { val oldTable = searchGroupTable searchGroupTable = null - launch(edtContext) { + coroutineScope.launch(edtContext) { tablePanel.setContent(groupTable.component) // Dispose the old one if it was not null oldTable?.let { launch { Disposer.dispose(it) } } @@ -84,12 +99,12 @@ class CloudWatchLogGroup( val table = LogGroupTable(project, client, logGroup, LogGroupTable.TableType.FILTER) Disposer.register(this@CloudWatchLogGroup, table) searchGroupTable = table - launch(edtContext) { + coroutineScope.launch(edtContext) { tablePanel.setContent(table.component) oldTable?.let { launch { Disposer.dispose(it) } } } - launch { - table.channel.send(LogActor.Message.LoadInitialFilter(searchField.text)) + coroutineScope.launch { + table.channel.send(CloudWatchLogsActor.Message.LoadInitialFilter(searchField.text)) } } } @@ -97,17 +112,29 @@ class CloudWatchLogGroup( private fun addToolbar() { val actionGroup = DefaultActionGroup() - actionGroup.addAction(object : AnAction(message("general.refresh"), null, AllIcons.Actions.Refresh), DumbAware { - override fun actionPerformed(e: AnActionEvent) { - CloudwatchlogsTelemetry.refreshGroup(project) - refreshTable() + actionGroup.addAction( + object : DumbAwareAction(message("general.refresh"), null, AllIcons.Actions.Refresh) { + override fun actionPerformed(e: AnActionEvent) { + CloudwatchlogsTelemetry.refresh(project, CloudWatchResourceType.LogGroup) + refreshTable() + } + } + ) + actionGroup.addAction( + object : DumbAwareAction(message("cloudwatch.logs.query"), null, AllIcons.Actions.Find) { + override fun actionPerformed(e: AnActionEvent) { + QueryEditorDialog(project, connection, logGroup).show() + CloudwatchinsightsTelemetry.openEditor(project, InsightsDialogOpenSource.LogGroup) + } } - }) - tablePanel.toolbar = ActionManager.getInstance().createActionToolbar("CloudWatchLogStream", actionGroup, false).component + ) + val toolbar = ActionManager.getInstance().createActionToolbar("CloudWatchLogStream", actionGroup, false) + toolbar.setTargetComponent(tablePanel.component) + tablePanel.toolbar = toolbar.component } - private fun refreshTable() { - launch { groupTable.channel.send(LogActor.Message.LoadInitial) } + internal fun refreshTable() { + coroutineScope.launch { groupTable.channel.send(CloudWatchLogsActor.Message.LoadInitial) } } override fun dispose() {} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogStream.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogStream.form index 09d1dde71d..84811d01df 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogStream.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogStream.form @@ -22,8 +22,8 @@ - - + + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogStream.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogStream.kt index 4457b7e368..b0008e8690 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogStream.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/CloudWatchLogStream.kt @@ -16,20 +16,20 @@ import com.intellij.openapi.ui.SimpleToolWindowPanel import com.intellij.openapi.util.Disposer import com.intellij.ui.SearchTextField import com.intellij.ui.components.breadcrumbs.Breadcrumbs -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogActor +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogsActor import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogStreamEntry import software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.OpenCurrentInEditorAction import software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.TailLogsAction import software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.WrapLogsAction -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope -import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext import software.aws.toolkits.jetbrains.utils.ui.onEmpty import software.aws.toolkits.jetbrains.utils.ui.onEnter import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudWatchResourceType import software.aws.toolkits.telemetry.CloudwatchlogsTelemetry import java.time.Duration import javax.swing.JPanel @@ -39,19 +39,25 @@ class CloudWatchLogStream( private val logGroup: String, private val logStream: String, private val previousEvent: LogStreamEntry? = null, - private val duration: Duration? = null -) : CoroutineScope by ApplicationThreadPoolScope("CloudWatchLogStream"), Disposable { + private val duration: Duration? = null, + streamLogs: Boolean = false +) : Disposable { + private val coroutineScope = disposableCoroutineScope(this) lateinit var content: JPanel + private set private lateinit var breadcrumbHolder: JPanel private lateinit var locationInformation: Breadcrumbs private lateinit var tablePanel: SimpleToolWindowPanel private lateinit var searchField: SearchTextField - private val edtContext = getCoroutineUiContext(disposable = this) + private val edtContext = getCoroutineUiContext() private val client: CloudWatchLogsClient = project.awsClient() - private val logStreamTable: LogStreamTable = LogStreamTable(project, client, logGroup, logStream, LogStreamTable.TableType.LIST) + private val logStreamTable: LogStreamTable = LogStreamTable(project, client, logGroup, logStream, LogStreamTable.TableType.LIST).also { + Disposer.register(this@CloudWatchLogStream, it) + } private var searchStreamTable: LogStreamTable? = null + private val tailLogsAction = TailLogsAction(project) { searchStreamTable?.channel ?: logStreamTable.channel } private fun createUIComponents() { tablePanel = SimpleToolWindowPanel(false, true) @@ -66,14 +72,15 @@ class CloudWatchLogStream( val locationCrumbs = LocationCrumbs(project, logGroup, logStream) locationInformation.crumbs = locationCrumbs.crumbs breadcrumbHolder.border = locationCrumbs.border - locationInformation.installClickListener() - - Disposer.register(this, logStreamTable) addActionToolbar() addSearchListener() refreshTable() + + if (streamLogs) { + tailLogsAction.setSelected(true) + } } private fun addSearchListener() { @@ -84,7 +91,7 @@ class CloudWatchLogStream( searchField.onEmpty { val oldTable = searchStreamTable searchStreamTable = null - launch(edtContext) { + coroutineScope.launch(edtContext) { tablePanel.setContent(logStreamTable.component) // Dispose the old one if it was not null oldTable?.let { launch { Disposer.dispose(it) } } @@ -101,12 +108,12 @@ class CloudWatchLogStream( val table = LogStreamTable(project, client, logGroup, logStream, LogStreamTable.TableType.FILTER) Disposer.register(this@CloudWatchLogStream, table) searchStreamTable = table - launch(edtContext) { + coroutineScope.launch(edtContext) { tablePanel.setContent(table.component) oldTable?.let { launch { Disposer.dispose(it) } } } - launch { - table.channel.send(LogActor.Message.LoadInitialFilter(searchField.text)) + coroutineScope.launch { + table.channel.send(CloudWatchLogsActor.Message.LoadInitialFilter(searchField.text)) } } } @@ -114,28 +121,34 @@ class CloudWatchLogStream( private fun addActionToolbar() { val actionGroup = DefaultActionGroup() - actionGroup.addAction(object : AnAction(message("general.refresh"), null, AllIcons.Actions.Refresh), DumbAware { - override fun actionPerformed(e: AnActionEvent) { - refreshTable() - CloudwatchlogsTelemetry.refreshStream(project) + actionGroup.addAction( + object : AnAction(message("general.refresh"), null, AllIcons.Actions.Refresh), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + refreshTable() + CloudwatchlogsTelemetry.refresh(project, CloudWatchResourceType.LogStream) + } + }, + Constraints.FIRST + ) + actionGroup.add( + OpenCurrentInEditorAction(project, logStream) { + searchStreamTable?.logsTable?.listTableModel?.items ?: logStreamTable.logsTable.listTableModel.items } - }, Constraints.FIRST) - actionGroup.add(OpenCurrentInEditorAction(project, logStream) { - searchStreamTable?.logsTable?.listTableModel?.items ?: logStreamTable.logsTable.listTableModel.items - }) - actionGroup.add(TailLogsAction(project) { searchStreamTable?.channel ?: logStreamTable.channel }) + ) + actionGroup.add(tailLogsAction) actionGroup.add(WrapLogsAction(project) { searchStreamTable?.logsTable ?: logStreamTable.logsTable }) val toolbar = ActionManager.getInstance().createActionToolbar("CloudWatchLogStream", actionGroup, false) + toolbar.setTargetComponent(tablePanel.component) tablePanel.toolbar = toolbar.component } - private fun refreshTable() = launch { + internal fun refreshTable() = coroutineScope.launch { if (searchField.text.isNotEmpty() && searchStreamTable != null) { - searchStreamTable?.channel?.send(LogActor.Message.LoadInitialFilter(searchField.text.trim())) + searchStreamTable?.channel?.send(CloudWatchLogsActor.Message.LoadInitialFilter(searchField.text.trim())) } else if (previousEvent != null && duration != null) { - logStreamTable.channel.send(LogActor.Message.LoadInitialRange(previousEvent, duration)) + logStreamTable.channel.send(CloudWatchLogsActor.Message.LoadInitialRange(previousEvent, duration)) } else { - logStreamTable.channel.send(LogActor.Message.LoadInitial) + logStreamTable.channel.send(CloudWatchLogsActor.Message.LoadInitial) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LocationCrumbs.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LocationCrumbs.kt index 801678c4c0..2fde45f563 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LocationCrumbs.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/editor/LocationCrumbs.kt @@ -9,40 +9,36 @@ import com.intellij.openapi.editor.colors.TextAttributesKey import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project -import com.intellij.ui.ClickListener import com.intellij.ui.IdeBorderFactory import com.intellij.ui.SideBorder import com.intellij.ui.components.breadcrumbs.Breadcrumbs import com.intellij.ui.components.breadcrumbs.Crumb +import kotlinx.coroutines.runBlocking import software.aws.toolkits.jetbrains.core.credentials.activeCredentialProvider import software.aws.toolkits.jetbrains.core.credentials.activeRegion import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow import software.aws.toolkits.resources.message import java.awt.event.ActionEvent -import java.awt.event.MouseEvent import javax.swing.AbstractAction import javax.swing.border.Border class LocationBreadcrumbs : Breadcrumbs() { - // getAttributes is similar to PsiBreadcrumbs.java with a modification to not style if there are no actions + init { + onSelect { crumb, event -> + crumb.contextActions.firstOrNull()?.actionPerformed(ActionEvent(event, ActionEvent.ACTION_PERFORMED, "")) + } + } + override fun getAttributes(crumb: Crumb): TextAttributes? = getKey(crumb)?.let { EditorColorsManager.getInstance().globalScheme.getAttributes(it) } - private fun getKey(crumb: Crumb): TextAttributesKey? = if (crumb.contextActions.isEmpty()) { - EditorColors.BREADCRUMBS_DEFAULT - } else if (isHovered(crumb)) { + private fun getKey(crumb: Crumb): TextAttributesKey? = if (isHovered(crumb) && crumb.contextActions.isNotEmpty()) { EditorColors.BREADCRUMBS_HOVERED - } else if (isSelected(crumb) && crumb.contextActions.isNotEmpty()) { - EditorColors.BREADCRUMBS_CURRENT } else { - if (isAfterSelected(crumb)) { - EditorColors.BREADCRUMBS_INACTIVE - } else { - EditorColors.BREADCRUMBS_DEFAULT - } + EditorColors.BREADCRUMBS_DEFAULT } } -// This is different from LocationBreadcrumbs becuase createUiComponents in Kotlin does not have access to the constructor arguments +// This is different from LocationBreadcrumbs because createUiComponents in Kotlin does not have access to the constructor arguments class LocationCrumbs(project: Project, logGroup: String, logStream: String? = null) { // This is made available instead of set because it needs to be on different components depending on the window val border: Border = IdeBorderFactory.createBorder(SideBorder.BOTTOM) @@ -50,29 +46,27 @@ class LocationCrumbs(project: Project, logGroup: String, logStream: String? = nu val crumbs = listOfNotNull( Crumb.Impl(null, project.activeCredentialProvider().displayName, null, listOf()), Crumb.Impl(null, project.activeRegion().displayName, null, listOf()), - Crumb.Impl(null, logGroup, null, object : AbstractAction(message("cloudwatch.logs.view_log_streams")), DumbAware { - override fun actionPerformed(e: ActionEvent?) { - CloudWatchLogWindow.getInstance(project)?.showLogGroup(logGroup) + Crumb.Impl( + null, + logGroup, + null, + object : AbstractAction(message("cloudwatch.logs.view_log_streams")), DumbAware { + override fun actionPerformed(e: ActionEvent?): Unit = runBlocking { + CloudWatchLogWindow.getInstance(project).showLogGroup(logGroup) + } } - }), + ), logStream?.let { - Crumb.Impl(null, it, null, object : AbstractAction(message("cloudwatch.logs.view_log_stream")), DumbAware { - override fun actionPerformed(e: ActionEvent?) { - CloudWatchLogWindow.getInstance(project)?.showLogStream(logGroup, it) + Crumb.Impl( + null, + it, + null, + object : AbstractAction(message("cloudwatch.logs.view_log_stream")), DumbAware { + override fun actionPerformed(e: ActionEvent?) { + CloudWatchLogWindow.getInstance(project).showLogStream(logGroup, it) + } } - }) + ) } ) } - -// A click listener that fires the first registered action when it is clicked on -fun Breadcrumbs.installClickListener() { - object : ClickListener() { - override fun onClick(event: MouseEvent, clickCount: Int): Boolean { - val crumb = getCrumbAt(event.x, event.y) ?: return false - val action = crumb.contextActions.firstOrNull() ?: return false - action.actionPerformed(ActionEvent(event, ActionEvent.ACTION_PERFORMED, "")) - return true - } - }.installOn(this) -} 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 1cca277c9c..e02233cae5 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 @@ -9,6 +9,7 @@ import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.Constraints import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer import com.intellij.ui.DoubleClickListener import com.intellij.ui.PopupHandler import com.intellij.ui.ScrollPaneFactory @@ -16,34 +17,35 @@ import com.intellij.ui.TableSpeedSearch import com.intellij.ui.table.JBTable import com.intellij.ui.table.TableView import com.intellij.util.ui.ListTableModel -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient import software.amazon.awssdk.services.cloudwatchlogs.model.LogStream +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow -import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogActor +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogsActor import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogGroupActor import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogGroupSearchActor import software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.ExportActionGroup -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope import software.aws.toolkits.jetbrains.utils.ui.bottomReached import software.aws.toolkits.resources.message import java.awt.event.KeyAdapter import java.awt.event.KeyEvent import java.awt.event.MouseEvent import javax.swing.JComponent +import javax.swing.SortOrder class LogGroupTable( private val project: Project, private val client: CloudWatchLogsClient, private val logGroup: String, type: TableType -) : CoroutineScope by ApplicationThreadPoolScope("LogGroupTable"), Disposable { +) : Disposable { + private val coroutineScope = disposableCoroutineScope(this) val component: JComponent - val channel: Channel + val channel: Channel private val groupTable: TableView - private val logGroupActor: LogActor + private val logGroupActor: CloudWatchLogsActor enum class TableType { LIST, @@ -51,21 +53,24 @@ class LogGroupTable( } init { + val (sortColumn, sortOrder) = when (type) { + TableType.LIST -> 1 to SortOrder.DESCENDING // Sort by event time, most recent first + TableType.FILTER -> 0 to SortOrder.ASCENDING // Sort by name alphabetically + } val tableModel = ListTableModel( - arrayOf(LogStreamsStreamColumn(), LogStreamsDateColumn()), - mutableListOf() + arrayOf(LogStreamsStreamColumn(sortable = type == TableType.FILTER), LogStreamsDateColumn(sortable = type == TableType.LIST)), + mutableListOf(), + sortColumn, + sortOrder ) groupTable = TableView(tableModel).apply { setPaintBusy(true) autoscrolls = true + cellSelectionEnabled = true emptyText.text = message("loading_resource.loading") tableHeader.reorderingAllowed = false tableHeader.resizingAllowed = false } - groupTable.rowSorter = when (type) { - TableType.LIST -> LogGroupTableSorter(tableModel) - TableType.FILTER -> LogGroupFilterTableSorter(tableModel) - } TableSpeedSearch(groupTable) addTableMouseListener(groupTable) addKeyListener(groupTable) @@ -75,29 +80,32 @@ class LogGroupTable( TableType.LIST -> LogGroupActor(project, client, groupTable, logGroup) TableType.FILTER -> LogGroupSearchActor(project, client, groupTable, logGroup) } + Disposer.register(this@LogGroupTable, logGroupActor) channel = logGroupActor.channel component = ScrollPaneFactory.createScrollPane(groupTable).also { it.bottomReached { if (groupTable.rowCount != 0) { - launch { logGroupActor.channel.send(LogActor.Message.LoadForward) } + coroutineScope.launch { logGroupActor.channel.send(CloudWatchLogsActor.Message.LoadForward) } } } } } private fun addKeyListener(table: JBTable) { - table.addKeyListener(object : KeyAdapter() { - override fun keyTyped(e: KeyEvent) { - val logStream = table.getSelectedRowLogStream() ?: return - if (!e.isConsumed && e.keyCode == KeyEvent.VK_ENTER) { - e.consume() - val window = CloudWatchLogWindow.getInstance(project) - window.showLogStream(logGroup, logStream) + table.addKeyListener( + object : KeyAdapter() { + override fun keyTyped(e: KeyEvent) { + val logStream = table.getSelectedRowLogStream() ?: return + if (!e.isConsumed && e.keyCode == KeyEvent.VK_ENTER) { + e.consume() + val window = CloudWatchLogWindow.getInstance(project) + window.showLogStream(logGroup, logStream) + } } } - }) + ) } private fun addTableMouseListener(table: JBTable) { @@ -113,10 +121,13 @@ class LogGroupTable( private fun addActions(table: JBTable) { val actionGroup = DefaultActionGroup() - actionGroup.addAction(ExportActionGroup(project, client, logGroup) { - val row = groupTable.selectedRow.takeIf { it >= 0 } ?: return@ExportActionGroup null - table.getValueAt(row, 0) as? String - }, Constraints.FIRST) + actionGroup.addAction( + ExportActionGroup(project, client, logGroup) { + val row = groupTable.selectedRow.takeIf { it >= 0 } ?: return@ExportActionGroup null + table.getValueAt(row, 0) as? String + }, + Constraints.FIRST + ) PopupHandler.installPopupHandler( table, actionGroup, 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 bfeb5c85f0..0c2214ae29 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 @@ -7,7 +7,6 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.actionSystem.Separator import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.ui.PopupHandler @@ -15,23 +14,20 @@ import com.intellij.ui.ScrollPaneFactory import com.intellij.ui.TableSpeedSearch import com.intellij.ui.table.TableView import com.intellij.util.ui.ListTableModel -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient -import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogActor +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogsActor import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogStreamEntry import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogStreamFilterActor import software.aws.toolkits.jetbrains.services.cloudwatch.logs.LogStreamListActor -import software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.CopyFromTableAction import software.aws.toolkits.jetbrains.services.cloudwatch.logs.actions.ShowLogsAroundActionGroup -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope import software.aws.toolkits.jetbrains.utils.ui.bottomReached import software.aws.toolkits.jetbrains.utils.ui.topReached import software.aws.toolkits.resources.message import javax.swing.JComponent import javax.swing.JTable -import javax.swing.SortOrder class LogStreamTable( val project: Project, @@ -39,7 +35,8 @@ class LogStreamTable( private val logGroup: String, private val logStream: String, type: TableType -) : CoroutineScope by ApplicationThreadPoolScope("LogStreamTable"), Disposable { +) : Disposable { + private val coroutineScope = disposableCoroutineScope(this) enum class TableType { LIST, @@ -47,23 +44,21 @@ class LogStreamTable( } val component: JComponent - val channel: Channel + val channel: Channel val logsTable: TableView - private val logStreamActor: LogActor + private val logStreamActor: CloudWatchLogsActor init { val model = ListTableModel( arrayOf(LogStreamDateColumn(), LogStreamMessageColumn()), mutableListOf(), - // Don't sort in the model because the requests come sorted - -1, - SortOrder.UNSORTED ) logsTable = TableView(model).apply { autoscrolls = true tableHeader.reorderingAllowed = false tableHeader.resizingAllowed = false autoResizeMode = JTable.AUTO_RESIZE_LAST_COLUMN + cellSelectionEnabled = true 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 @@ -88,12 +83,12 @@ class LogStreamTable( component = ScrollPaneFactory.createScrollPane(logsTable).also { it.topReached { if (logsTable.rowCount != 0) { - launch { logStreamActor.channel.send(LogActor.Message.LoadBackward) } + coroutineScope.launch { logStreamActor.channel.send(CloudWatchLogsActor.Message.LoadBackward) } } } it.bottomReached { if (logsTable.rowCount != 0) { - launch { logStreamActor.channel.send(LogActor.Message.LoadForward) } + coroutineScope.launch { logStreamActor.channel.send(CloudWatchLogsActor.Message.LoadForward) } } } } @@ -103,8 +98,6 @@ class LogStreamTable( private fun addActionsToTable() { val actionGroup = DefaultActionGroup().apply { - add(CopyFromTableAction(logsTable)) - add(Separator()) add(ShowLogsAroundActionGroup(project, logGroup, logStream, logsTable)) } PopupHandler.installPopupHandler( 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 dbce163a09..2641d06c13 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 @@ -8,33 +8,26 @@ import com.intellij.ui.speedSearch.SpeedSearchUtil import com.intellij.util.text.DateFormatUtil import com.intellij.util.text.SyncDateFormat 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.ResizingTextColumnRenderer 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.text.SimpleDateFormat -import javax.swing.JLabel -import javax.swing.JPanel import javax.swing.JTable -import javax.swing.SortOrder -import javax.swing.border.CompoundBorder -import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.TableCellRenderer -import javax.swing.table.TableRowSorter -class LogStreamsStreamColumn : ColumnInfo(message("cloudwatch.logs.log_streams")) { +class LogStreamsStreamColumn(private val sortable: Boolean) : ColumnInfo(message("cloudwatch.logs.log_streams")) { private val renderer = LogStreamsStreamColumnRenderer() override fun valueOf(item: LogStream?): String? = item?.logStreamName() - override fun isCellEditable(item: LogStream?): Boolean = false - override fun getRenderer(item: LogStream?): TableCellRenderer? = renderer + override fun getRenderer(item: LogStream?): TableCellRenderer = renderer + override fun getComparator(): Comparator? = if (sortable) Comparator.comparing { it.logStreamName() } else null } -class LogStreamsStreamColumnRenderer() : TableCellRenderer { +class LogStreamsStreamColumnRenderer : TableCellRenderer { override fun getTableCellRendererComponent(table: JTable?, value: Any?, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { val component = SimpleColoredComponent() component.append((value as? String)?.trim() ?: "") @@ -48,40 +41,36 @@ class LogStreamsStreamColumnRenderer() : TableCellRenderer { } } -class LogStreamsDateColumn : ColumnInfo(message("cloudwatch.logs.last_event_time")) { - private val renderer = ResizingDateColumnRenderer(showSeconds = false) - override fun valueOf(item: LogStream?): String? = item?.lastEventTimestamp()?.toString() +class LogStreamsDateColumn( + private val sortable: Boolean, + val format: SyncDateFormat? = null +) : ColumnInfo(message("cloudwatch.logs.last_event_time")) { + private val renderer = ResizingTextColumnRenderer() + override fun valueOf(item: LogStream?): String? = TimeFormatConversion.convertEpochTimeToStringDateTime( + item?.lastEventTimestamp(), + showSeconds = false, + format = format + ) override fun isCellEditable(item: LogStream?): Boolean = false - override fun getRenderer(item: LogStream?): TableCellRenderer? = renderer -} - -class LogGroupTableSorter(model: ListTableModel) : TableRowSorter>(model) { - init { - sortKeys = listOf(SortKey(1, SortOrder.DESCENDING)) - setSortable(0, false) - setSortable(1, false) - } -} - -class LogGroupFilterTableSorter(model: ListTableModel) : TableRowSorter>(model) { - init { - sortKeys = listOf(SortKey(0, SortOrder.DESCENDING)) - setSortable(0, false) - setSortable(1, false) - } + override fun getRenderer(item: LogStream?): TableCellRenderer = renderer + override fun getComparator(): Comparator? = if (sortable) Comparator.comparing { it.lastEventTimestamp() ?: Long.MIN_VALUE } else null } -class LogStreamDateColumn : ColumnInfo(message("general.time")) { - private val renderer = ResizingDateColumnRenderer(showSeconds = true) - override fun valueOf(item: LogStreamEntry?): String? = item?.timestamp?.toString() +class LogStreamDateColumn(private val format: SyncDateFormat? = null) : ColumnInfo(message("general.time")) { + private val renderer = ResizingTextColumnRenderer() + override fun valueOf(item: LogStreamEntry?): String? = TimeFormatConversion.convertEpochTimeToStringDateTime( + item?.timestamp, + showSeconds = true, + format = format + ) override fun isCellEditable(item: LogStreamEntry?): Boolean = false - override fun getRenderer(item: LogStreamEntry?): TableCellRenderer? = renderer + override fun getRenderer(item: LogStreamEntry?): TableCellRenderer = renderer } class LogStreamMessageColumn : ColumnInfo(message("general.message")) { - private val renderer = WrappingCellRenderer(wrapOnSelection = true, toggleableWrap = true) + private val renderer = WrappingCellRenderer(wrapOnSelection = true, wrapOnToggle = true) fun wrap() { renderer.wrap = true } @@ -92,42 +81,16 @@ class LogStreamMessageColumn : ColumnInfo(message("gener override fun valueOf(item: LogStreamEntry?): String? = item?.message override fun isCellEditable(item: LogStreamEntry?): Boolean = false - override fun getRenderer(item: LogStreamEntry?): TableCellRenderer? = renderer + override fun getRenderer(item: LogStreamEntry?): TableCellRenderer = renderer } -private class ResizingDateColumnRenderer(showSeconds: Boolean) : TableCellRenderer { - private val defaultRenderer = DefaultTableCellRenderer() - private val formatter: SyncDateFormat = if (showSeconds) { - SyncDateFormat(SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")) - } else { - DateFormatUtil.getDateTimeFormat() - } - - override fun getTableCellRendererComponent(table: JTable?, value: Any?, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { - // This wrapper will let us force the component to be at the top instead of in the middle for linewraps - val wrapper = JPanel(BorderLayout()) - val defaultComponent = defaultRenderer.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) - if (table == null) { - return defaultComponent - } - val component = defaultComponent as? JLabel ?: return defaultComponent - component.text = (value as? String)?.toLongOrNull()?.let { - formatter.format(it) - } - if (component.preferredSize.width > table.columnModel.getColumn(column).preferredWidth) { - // add 3 pixels of padding. No padding makes it go into ... mode cutting off the end - table.columnModel.getColumn(column).preferredWidth = component.preferredSize.width + 3 - table.columnModel.getColumn(column).maxWidth = component.preferredSize.width + 3 - } - wrapper.add(component, BorderLayout.NORTH) - // Make sure the background matches for selection - wrapper.background = component.background - // if a component is selected, it puts a border on it, move the border to the wrapper instead - if (isSelected) { - // this border has an outside and inside border, take only the outside border - wrapper.border = (component.border as? CompoundBorder)?.outsideBorder +object TimeFormatConversion { + fun convertEpochTimeToStringDateTime(epochTime: Long?, showSeconds: Boolean, format: SyncDateFormat? = null): String? { + val formatter = format ?: if (showSeconds) { + SyncDateFormat(SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")) + } else { + DateFormatUtil.getDateTimeFormat() } - component.border = null - return wrapper + return epochTime?.let { formatter.format(it) } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/DetailedLogRecord.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/DetailedLogRecord.form new file mode 100644 index 0000000000..4808808790 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/DetailedLogRecord.form @@ -0,0 +1,42 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/DetailedLogRecord.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/DetailedLogRecord.kt new file mode 100644 index 0000000000..84ef9442b6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/DetailedLogRecord.kt @@ -0,0 +1,126 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.ui.TableSpeedSearch +import com.intellij.ui.components.breadcrumbs.Breadcrumbs +import com.intellij.ui.table.TableView +import com.intellij.util.ExceptionUtil +import com.intellij.util.ui.ListTableModel +import com.intellij.util.ui.StatusText +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.editor.LocationCrumbs +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudwatchinsightsTelemetry +import software.aws.toolkits.telemetry.Result +import javax.swing.JPanel + +class DetailedLogRecord( + private val project: Project, + private val client: CloudWatchLogsClient, + private val logRecordPointer: String +) : Disposable { + private val coroutineScope = disposableCoroutineScope(this) + val title = message("cloudwatch.logs.log_record", logRecordPointer) + + private lateinit var breadcrumbHolder: JPanel + private lateinit var locationInformation: Breadcrumbs + lateinit var basePanel: JPanel + private set + lateinit var tableView: TableView + private set + private val recordLoadTask: Deferred + + private fun createUIComponents() { + val model = ListTableModel( + LogRecordFieldColumn(), + LogRecordValueColumn() + ) + tableView = TableView(model).apply { + setPaintBusy(true) + emptyText.text = message("loading_resource.loading") + } + tableView.emptyText.text = StatusText.getDefaultEmptyText() + TableSpeedSearch(tableView) + } + + init { + recordLoadTask = loadLogRecordAsync() + locationInformation.isVisible = false + coroutineScope.launch { + val record = recordLoadTask.await() + val items = record.map { it.key to it.value } + + runInEdt { + tableView.listTableModel.items = items + tableView.setPaintBusy(false) + } + + if (items.isNotEmpty()) { + val logGroup = record["@log"] ?: return@launch + val logStream = record["@logStream"] ?: return@launch + val locationCrumbs = LocationCrumbs(project, extractLogGroup(logGroup), logStream) + locationInformation.crumbs = locationCrumbs.crumbs + breadcrumbHolder.border = locationCrumbs.border + locationInformation.isVisible = true + } + } + } + + private fun loadLogRecordAsync() = coroutineScope.async { + var result = Result.Succeeded + try { + return@async client.getLogRecord { + it.logRecordPointer(logRecordPointer) + }.logRecord() + } catch (e: Exception) { + LOG.warn(e) { "Exception thrown while loading log record $logRecordPointer" } + notifyError( + project = project, + title = message("cloudwatch.logs.exception"), + content = ExceptionUtil.getThrowableText(e) + ) + result = Result.Failed + } finally { + CloudwatchinsightsTelemetry.openDetailedLogRecord(project, result) + } + + return@async mapOf() + } + + override fun dispose() { + } + + fun getComponent() = basePanel + + @TestOnly + internal fun isLoaded() = recordLoadTask.isCompleted + + companion object { + private val LOG = getLogger() + + // Log group names can be between 1 and 512 characters long. + // Log group names consist of the following characters: a-z, A-Z, 0-9, '_' (underscore), '-' (hyphen), '/' (forward slash), '.' (period), and '#' (number sign) + // https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateLogGroup.html + private val logGroupRegex = Regex("\\d{12}:(.{1,512})") + + fun extractLogGroup(log: String) = + // conveniently, the @log field has the account ID prepended, which we don't want + // @log is a log group identifier in the form of account-id:log-group-name. This can be useful in queries of multiple log groups, to identify which log group a particular event belongs to. + // https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_AnalyzeLogData-discoverable-fields.html + logGroupRegex.find(log)?.groups?.get(1)?.value + ?: throw IllegalStateException("$log format does not appear to be in a valid format (:)") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/EnterQueryName.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/EnterQueryName.form new file mode 100644 index 0000000000..f56377036d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/EnterQueryName.form @@ -0,0 +1,29 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/EnterQueryName.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/EnterQueryName.kt new file mode 100644 index 0000000000..0d77489984 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/EnterQueryName.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.cloudwatch.logs.insights + +import javax.swing.JPanel +import javax.swing.JTextField + +class EnterQueryName { + lateinit var queryName: JTextField + private set + lateinit var saveQueryPanel: JPanel + private set +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/InsightsColumnInfo.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/InsightsColumnInfo.kt new file mode 100644 index 0000000000..7fbaa6ef2a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/InsightsColumnInfo.kt @@ -0,0 +1,46 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights + +import com.intellij.ui.SimpleColoredComponent +import com.intellij.util.ui.ColumnInfo +import software.aws.toolkits.jetbrains.utils.ui.ResizingTextColumnRenderer +import software.aws.toolkits.jetbrains.utils.ui.setSelectionHighlighting +import software.aws.toolkits.resources.message +import java.awt.Component +import javax.swing.JTable +import javax.swing.table.TableCellRenderer + +class LogResultColumnInfo(private val fieldName: String) : ColumnInfo(fieldName) { + override fun valueOf(item: Map?): String? { + if (item != null) { + return item[fieldName] + } + return null + } + + override fun isCellEditable(item: Map?): Boolean = false + override fun getRenderer(item: Map?) = LogResultColumnRenderer() +} + +class LogResultColumnRenderer : TableCellRenderer { + override fun getTableCellRendererComponent(table: JTable?, value: Any?, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { + val component = SimpleColoredComponent() + component.append((value as? String)?.trim() ?: "") + if (table == null) { + return component + } + component.setSelectionHighlighting(table, isSelected) + return component + } +} + +class LogRecordFieldColumn : ColumnInfo(message("cloudwatch.logs.log_record_field")) { + override fun getRenderer(item: LogRecordFieldPair?) = ResizingTextColumnRenderer() + override fun valueOf(item: LogRecordFieldPair?) = item?.first +} + +class LogRecordValueColumn : ColumnInfo(message("cloudwatch.logs.log_record_value")) { + override fun valueOf(item: LogRecordFieldPair?) = item?.second +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/InsightsUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/InsightsUtils.kt new file mode 100644 index 0000000000..044003fda3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/InsightsUtils.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.services.cloudwatch.logs.insights + +import software.amazon.awssdk.services.cloudwatchlogs.model.ResultField + +/** + * Mapped from response from the GetQueryResults call + */ +typealias LogResult = Map + +/** + * Returned from the GetLogRecordResponse call + */ +typealias LogRecord = Map +typealias LogRecordFieldPair = Pair + +fun List.toLogResult() = this.map { it.field() to it.value() }.toMap() + +// @ptr is a unique identifier for each resultant log event which is used here to ensure results are not repeatedly displayed +fun LogResult.identifier(): String = this["@ptr"] ?: throw IllegalStateException("CWL GetQueryResults returned record without @ptr field") diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/LogGroupSelectorTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/LogGroupSelectorTable.kt new file mode 100644 index 0000000000..70b6f1427b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/LogGroupSelectorTable.kt @@ -0,0 +1,87 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights + +import com.intellij.ui.TableSpeedSearch +import com.intellij.ui.TableUtil +import com.intellij.ui.table.TableView +import com.intellij.util.ui.ColumnInfo +import com.intellij.util.ui.ListTableModel +import software.aws.toolkits.resources.message + +data class LogGroup( + var selected: Boolean = false, + var name: String +) + +class LogGroupSelectorTable : TableView(model) { + init { + listTableModel.items = emptyList() + TableSpeedSearch(this) + emptyText.text = message("loading_resource.loading") + } + + fun populateLogGroups(selectedLogGroups: Set, availableLogGroups: List) { + val (selected, modelItems) = availableLogGroups.mapIndexed { index, logGroup -> + if (logGroup in selectedLogGroups) { + index to LogGroup(true, logGroup) + } else { + null to LogGroup(false, logGroup) + } + }.unzip() + + listTableModel.items = modelItems + TableUtil.setupCheckboxColumn(this, 0) + TableUtil.updateScroller(this) + TableUtil.selectRows(this, selected.filterNotNull().toIntArray()) + TableUtil.scrollSelectionToVisible(this) + } + + /** + * Assumes that the model has already been populated + */ + internal fun selectLogGroups(selectedLogGroups: Set) { + val selected = listTableModel.items.mapIndexed { index, logGroup -> + logGroup.selected = logGroup.name in selectedLogGroups + + if (logGroup.selected) index else null + } + + TableUtil.selectRows(this, selected.filterNotNull().toIntArray()) + TableUtil.scrollSelectionToVisible(this) + } + + fun getSelectedLogGroups(): List = + listTableModel.items + .filter { it.selected } + .mapNotNull { it.name } + + companion object { + private val model = ListTableModel( + SelectedColumnInfo(), + LogGroupNameColumnInfo() + ) + + private class SelectedColumnInfo : ColumnInfo("Selected") { + override fun getColumnClass() = Boolean::class.java + + override fun isCellEditable(item: LogGroup) = true + + override fun valueOf(item: LogGroup) = item.selected + + override fun setValue(item: LogGroup, value: Boolean?) { + item.selected = (value ?: false) + } + } + + private class LogGroupNameColumnInfo : ColumnInfo("Log Group Name") { + override fun isCellEditable(item: LogGroup) = false + + override fun valueOf(item: LogGroup) = item.name + + override fun setValue(item: LogGroup, value: String?) { + item.name = (value ?: "") + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryDetails.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryDetails.kt new file mode 100644 index 0000000000..0e433605ce --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryDetails.kt @@ -0,0 +1,71 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights + +import software.aws.toolkits.core.ConnectionSettings +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import java.util.Date + +data class QueryDetails( + val connectionSettings: ConnectionSettings, + val logGroups: List, + val timeRange: TimeRange, + val query: QueryString +) { + fun getQueryRange(clock: Clock = Clock.systemUTC()) = + when (timeRange) { + is TimeRange.AbsoluteRange -> { + StartEndInstant(timeRange.startDate.toInstant(), timeRange.endDate.toInstant()) + } + is TimeRange.RelativeRange -> { + val now = Instant.now(clock) + StartEndInstant( + // Instant doesn't support minus(Week), so we need to explicitly use the ISO calendar system + // ZonedDateTime must be based off an temporal instance with a ZoneId + ZonedDateTime.from(now.atZone(ZoneId.systemDefault())).minus(timeRange.relativeTimeAmount, timeRange.relativeTimeUnit).toInstant(), + now + ) + } + } + + fun getQueryString() = + when (query) { + is QueryString.SearchTermQueryString -> { + val regexTerm = query.searchTerm.replace("/", "\\/") + + "fields @timestamp, @message | filter @message like /$regexTerm/" + } + + is QueryString.InsightsQueryString -> query.query + } +} + +sealed class QueryString { + data class SearchTermQueryString( + val searchTerm: String + ) : QueryString() + data class InsightsQueryString( + val query: String + ) : QueryString() +} + +sealed class TimeRange { + data class AbsoluteRange( + val startDate: Date, + val endDate: Date + ) : TimeRange() + data class RelativeRange( + val relativeTimeAmount: Long, + val relativeTimeUnit: ChronoUnit + ) : TimeRange() +} + +data class StartEndInstant( + val start: Instant, + val end: Instant +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditor.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditor.form new file mode 100644 index 0000000000..2e34156b2a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditor.form @@ -0,0 +1,167 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditor.kt new file mode 100644 index 0000000000..e524a901f2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditor.kt @@ -0,0 +1,166 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.ui.EnumComboBoxModel +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.michaelbaranov.microba.calendar.DatePicker +import software.aws.toolkits.jetbrains.utils.ui.find +import software.aws.toolkits.resources.message +import java.text.NumberFormat +import java.time.temporal.ChronoUnit +import javax.swing.JButton +import javax.swing.JComboBox +import javax.swing.JFormattedTextField +import javax.swing.JPanel +import javax.swing.JRadioButton +import javax.swing.JTextArea +import javax.swing.JTextField + +class QueryEditor internal constructor( + private val project: Project, + private val initialQueryDetails: QueryDetails +) { + lateinit var absoluteTimeRadioButton: JRadioButton + private set + lateinit var relativeTimeRadioButton: JRadioButton + private set + lateinit var searchTerm: JRadioButton + private set + lateinit var querySearchTerm: JTextField + private set + lateinit var queryLogGroupsRadioButton: JRadioButton + private set + lateinit var saveQueryButton: JButton + private set + lateinit var retrieveSavedQueriesButton: JButton + private set + lateinit var tablePanel: SimpleToolWindowPanel + private set + lateinit var queryBox: JTextArea + private set + lateinit var endDate: DatePicker + private set + lateinit var queryEditorBasePanel: JPanel + private set + lateinit var relativeTimeUnit: JComboBox + private set + lateinit var relativeTimeNumber: JFormattedTextField + private set + lateinit var startDate: DatePicker + private set + lateinit var queryGroupScrollPane: JBScrollPane + private set + lateinit var logGroupTable: LogGroupSelectorTable + private set + private lateinit var comboBoxModel: EnumComboBoxModel + private lateinit var numberFormat: NumberFormat + private lateinit var timePanel: JPanel + private lateinit var searchPanel: JPanel + + private fun createUIComponents() { + tablePanel = SimpleToolWindowPanel(false, true) + logGroupTable = LogGroupSelectorTable() + tablePanel.setContent(logGroupTable.component) + // lateinit since this method runs before the standard initialization flow + numberFormat = NumberFormat.getIntegerInstance() + relativeTimeNumber = JFormattedTextField(numberFormat) + // arbitrary length + relativeTimeNumber.columns = 5 + comboBoxModel = EnumComboBoxModel(TimeUnit::class.java) + relativeTimeUnit = ComboBox(comboBoxModel) + relativeTimeUnit.renderer = timeUnitComboBoxRenderer + } + + init { + absoluteTimeRadioButton.addActionListener { + setAbsolute() + } + + relativeTimeRadioButton.addActionListener { + setRelative() + } + + queryLogGroupsRadioButton.addActionListener { + setQueryLanguage() + } + + searchTerm.addActionListener { + setSearchTerm() + } + + saveQueryButton.addActionListener { + val query = if (queryBox.text.isNotEmpty()) queryBox.text else DEFAULT_INSIGHTS_QUERY_STRING + SaveQueryDialog(project, initialQueryDetails.connectionSettings, query, logGroupTable.getSelectedLogGroups()).show() + } + + retrieveSavedQueriesButton.addActionListener { + RetrieveSavedQueryDialog(this, project, initialQueryDetails.connectionSettings).show() + } + + startDate.isEnabled = false + endDate.isEnabled = false + relativeTimeNumber.isEnabled = true + relativeTimeUnit.isEnabled = true + querySearchTerm.isEnabled = true + queryBox.isEnabled = false + saveQueryButton.isEnabled = false + queryLogGroupsRadioButton.isSelected = true + queryBox.text = DEFAULT_INSIGHTS_QUERY_STRING + + queryGroupScrollPane.border = IdeBorderFactory.createTitledBorder(message("cloudwatch.logs.log_groups"), false, JBUI.emptyInsets()) + timePanel.border = JBUI.Borders.emptyTop(UIUtil.DEFAULT_VGAP) + searchPanel.border = JBUI.Borders.emptyTop(UIUtil.DEFAULT_VGAP) + } + + fun setAbsolute() { + absoluteTimeRadioButton.isSelected = true + startDate.isEnabled = true + endDate.isEnabled = true + relativeTimeNumber.isEnabled = false + relativeTimeUnit.isEnabled = false + } + + fun setRelative() { + relativeTimeRadioButton.isSelected = true + startDate.isEnabled = false + endDate.isEnabled = false + relativeTimeNumber.isEnabled = true + relativeTimeUnit.isEnabled = true + } + + fun setSearchTerm() { + searchTerm.isSelected = true + queryBox.isEnabled = false + querySearchTerm.isEnabled = true + saveQueryButton.isEnabled = false + } + + fun setQueryLanguage() { + queryLogGroupsRadioButton.isEnabled = true + queryBox.isEnabled = true + querySearchTerm.isEnabled = false + saveQueryButton.isEnabled = true + } + + fun getRelativeTimeAmount() = numberFormat.parse(relativeTimeNumber.text).toLong() + + fun getSelectedTimeUnit(): ChronoUnit = comboBoxModel.selectedItem.unit + + fun setSelectedTimeUnit(unit: ChronoUnit) { + comboBoxModel.setSelectedItem(comboBoxModel.find { it.unit == unit }) + } + + companion object { + private val timeUnitComboBoxRenderer = SimpleListCellRenderer.create("") { + it.text + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditorDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditorDialog.kt new file mode 100644 index 0000000000..e25a0c9efb --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditorDialog.kt @@ -0,0 +1,244 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import kotlinx.coroutines.async +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.resources.CloudWatchResources +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudwatchinsightsTelemetry +import software.aws.toolkits.telemetry.InsightsQueryStringType +import software.aws.toolkits.telemetry.InsightsQueryTimeType +import software.aws.toolkits.telemetry.Result +import java.awt.event.ActionEvent +import java.time.temporal.ChronoUnit +import javax.swing.Action +import javax.swing.JComponent + +class QueryEditorDialog internal constructor( + // TODO: Exposed for testing only, should be refactored to be private + private val project: Project, + private val initialQueryDetails: QueryDetails +) : DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + constructor(project: Project, connectionSettings: ConnectionSettings, logGroupName: String) : + this(project, defaultQuery(connectionSettings, logGroupName)) + + private val view = QueryEditor(project, initialQueryDetails) + private val action: OkAction = QueryLogGroupOkAction() + + init { + super.init() + + title = message("cloudwatch.logs.query_editor_title") + coroutineScope.launch { + setView(initialQueryDetails) + } + } + + override fun createCenterPanel(): JComponent? = view.queryEditorBasePanel + + override fun doValidate(): ValidationInfo? = validateEditorEntries(view) + + override fun getOKAction(): Action = action + + override fun doOKAction() { + // Do nothing, close logic is handled separately + } + + // TODO: Exposed for testing only, should be refactored to be private + internal suspend fun setView(queryDetails: QueryDetails) { + when (val timeRange = queryDetails.timeRange) { + is TimeRange.AbsoluteRange -> { + view.setAbsolute() + view.startDate.date = timeRange.startDate + view.endDate.date = timeRange.endDate + } + + is TimeRange.RelativeRange -> { + view.setRelative() + view.relativeTimeNumber.text = timeRange.relativeTimeAmount.toString() + view.setSelectedTimeUnit(timeRange.relativeTimeUnit) + } + } + + when (val query = queryDetails.query) { + is QueryString.SearchTermQueryString -> { + view.setSearchTerm() + view.querySearchTerm.text = query.searchTerm + } + + is QueryString.InsightsQueryString -> { + view.setQueryLanguage() + view.queryBox.text = query.query + } + } + + val availableLogGroups = AwsResourceCache.getInstance().getResource( + CloudWatchResources.LIST_LOG_GROUPS, + region = initialQueryDetails.connectionSettings.region, + credentialProvider = initialQueryDetails.connectionSettings.credentials + ).await().map { it.logGroupName() } + withContext(getCoroutineUiContext()) { + view.logGroupTable.populateLogGroups(initialQueryDetails.logGroups.toSet(), availableLogGroups) + } + } + + private fun beginQuerying() { + if (!okAction.isEnabled) { + return + } + + close(OK_EXIT_CODE) + val queryDetails = getQueryDetails() + val fieldList = getFields(queryDetails.getQueryString()) + coroutineScope.launch { + val queryId = startQueryAsync(queryDetails).await() + CloudWatchLogWindow.getInstance(project).showQueryResults(queryDetails, queryId, fieldList) + } + } + + // TODO: Exposed for testing only, should be refactored to be private + internal fun getQueryDetails(): QueryDetails { + val timeRange = if (view.absoluteTimeRadioButton.isSelected) { + TimeRange.AbsoluteRange( + startDate = view.startDate.date, + endDate = view.endDate.date + ) + } else { + TimeRange.RelativeRange( + relativeTimeAmount = view.getRelativeTimeAmount(), + relativeTimeUnit = view.getSelectedTimeUnit() + ) + } + + val query = if (view.searchTerm.isSelected) { + QueryString.SearchTermQueryString( + searchTerm = view.querySearchTerm.text + ) + } else { + QueryString.InsightsQueryString( + query = view.queryBox.text + ) + } + + return QueryDetails( + connectionSettings = initialQueryDetails.connectionSettings, + logGroups = view.logGroupTable.getSelectedLogGroups(), + timeRange = timeRange, + query = query + ) + } + + private inner class QueryLogGroupOkAction : OkAction() { + init { + putValue(Action.NAME, message("cloudwatch.logs.query.form.ok_Button")) + } + + override fun doAction(e: ActionEvent?) { + super.doAction(e) + if (doValidateAll().isNotEmpty()) return + beginQuerying() + } + } + + // TODO: Exposed for testing only, should be refactored to be private + internal fun validateEditorEntries(view: QueryEditor): ValidationInfo? { + if (!view.absoluteTimeRadioButton.isSelected && !view.relativeTimeRadioButton.isSelected) { + return ValidationInfo(message("cloudwatch.logs.validation.timerange"), view.absoluteTimeRadioButton) + } + if (view.relativeTimeRadioButton.isSelected && view.relativeTimeNumber.text.isEmpty()) { + return ValidationInfo(message("cloudwatch.logs.no_relative_time_number"), view.relativeTimeNumber) + } + if (view.absoluteTimeRadioButton.isSelected && view.startDate.date > view.endDate.date) { + return ValidationInfo(message("cloudwatch.logs.compare.start.end.date"), view.startDate) + } + if (view.queryLogGroupsRadioButton.isSelected && view.queryBox.text.isEmpty()) { + return ValidationInfo(message("cloudwatch.logs.no_query_entered"), view.queryBox) + } + if (view.searchTerm.isSelected && view.querySearchTerm.text.isEmpty()) { + return ValidationInfo(message("cloudwatch.logs.no_term_entered"), view.querySearchTerm) + } + if (view.logGroupTable.getSelectedLogGroups().isEmpty()) { + return ValidationInfo(message("cloudwatch.logs.no_log_group"), view.logGroupTable) + } + return null + } + + // TODO: Exposed for testing only, should be refactored to be private + internal fun startQueryAsync(queryDetails: QueryDetails) = coroutineScope.async { + val (credentials, region) = queryDetails.connectionSettings + val client = AwsClientManager.getInstance().getClient(credentials, region) + val timeRange = queryDetails.getQueryRange() + val queryString = queryDetails.getQueryString() + var result = Result.Succeeded + try { + val response = client.startQuery { + it.logGroupNames(queryDetails.logGroups) + it.startTime(timeRange.start.epochSecond) + it.endTime(timeRange.end.epochSecond) + it.queryString(queryString) + // 1k is default + it.limit(1000) + } + + return@async response.queryId() + } catch (e: Exception) { + notifyError(message("cloudwatch.logs.query_result_completion_status"), e.toString()) + result = Result.Failed + throw e + } finally { + val timeType = when (queryDetails.timeRange) { + is TimeRange.AbsoluteRange -> InsightsQueryTimeType.Absolute + is TimeRange.RelativeRange -> InsightsQueryTimeType.Relative + } + val searchStringType = when (queryDetails.query) { + is QueryString.InsightsQueryString -> InsightsQueryStringType.Insights + is QueryString.SearchTermQueryString -> InsightsQueryStringType.SearchTerm + } + CloudwatchinsightsTelemetry.executeQuery(project, result, timeType, searchStringType) + } + } + + companion object { + private fun defaultQuery(connection: ConnectionSettings, logGroupName: String) = QueryDetails( + connection, + mutableListOf(logGroupName), + TimeRange.RelativeRange(10, ChronoUnit.MINUTES), + QueryString.InsightsQueryString(DEFAULT_INSIGHTS_QUERY_STRING) + ) + + // TODO: Exposed for testing only, should be refactored to be private + internal fun getFields(query: String): List { + val fieldsIdentifier = "fields" + val fieldList = mutableListOf>() + query.replace("\\|", "") + val queries = query.split("|") + for (item in queries) { + val splitQuery = item.trim() + if (splitQuery.startsWith(fieldsIdentifier, ignoreCase = false)) { + val fields = splitQuery.substringAfter(fieldsIdentifier) + fieldList.add(fields.split(",").map { it.trim() }) + } + } + if (fieldList.isEmpty()) { + return listOf("@timestamp", "@message") + } + return fieldList.flatten() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditorUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditorUtils.kt new file mode 100644 index 0000000000..d67bddc6c6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryEditorUtils.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.services.cloudwatch.logs.insights + +/** + * The default insights query string autofilled into the box. If this changes, + * change the one in InsightsQueryTest as well + */ +const val DEFAULT_INSIGHTS_QUERY_STRING = +"""fields @timestamp, @message +| sort @timestamp desc +| limit 20 +""" diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryResultPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryResultPanel.kt new file mode 100644 index 0000000000..25234ffce4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryResultPanel.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.services.cloudwatch.logs.insights + +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.launch +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.InsightsQueryResultsActor +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudwatchinsightsTelemetry +import software.aws.toolkits.telemetry.InsightsDialogOpenSource + +class QueryResultPanel( + private val project: Project, + fields: List, + queryId: String, + private val queryDetails: QueryDetails +) : SimpleToolWindowPanel(false, true), Disposable { + private val coroutineScope = disposableCoroutineScope(this) + init { + val resultsTable = QueryResultsTable(project, queryDetails.connectionSettings, fields, queryId) + Disposer.register(this, resultsTable) + setContent(resultsTable.component) + toolbar = ActionManager.getInstance().createActionToolbar( + ID, + DefaultActionGroup( + object : AnAction(message("cloudwatch.logs.open_query_editor"), null, AllIcons.Actions.EditSource) { + override fun actionPerformed(e: AnActionEvent) { + QueryEditorDialog(project, queryDetails).show() + CloudwatchinsightsTelemetry.openEditor(project, InsightsDialogOpenSource.ResultsWindow) + } + } + ), + false + ).component + coroutineScope.launch { resultsTable.channel.send(InsightsQueryResultsActor.Message.StartLoadingAll) } + } + + override fun dispose() { + } + + private companion object { + const val ID = "QueryResultPanel" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryResultsTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryResultsTable.kt new file mode 100644 index 0000000000..cae8c2ba58 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/QueryResultsTable.kt @@ -0,0 +1,76 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.ui.DoubleClickListener +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.table.TableView +import com.intellij.util.ui.ListTableModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.InsightsQueryResultsActor +import software.aws.toolkits.resources.message +import java.awt.event.MouseEvent +import javax.swing.JComponent + +class QueryResultsTable( + private val project: Project, + connectionSettings: ConnectionSettings, + fields: List, + queryId: String +) : Disposable { + private val coroutineScope = disposableCoroutineScope(this) + private val client = let { + val (credentials, region) = connectionSettings + AwsClientManager.getInstance().getClient(credentials, region) + } + private val insightsQueryActor: InsightsQueryResultsActor + val component: JComponent + val channel: Channel + val resultsTable: TableView + + init { + val tableModel = ListTableModel( + *fields.map { + LogResultColumnInfo(it) + }.toTypedArray() + ) + + resultsTable = TableView(tableModel).apply { + setPaintBusy(true) + autoscrolls = true + emptyText.text = message("loading_resource.loading") + tableHeader.reorderingAllowed = false + tableHeader.resizingAllowed = true + } + + installDetailedLogRecordOpenListener() + insightsQueryActor = InsightsQueryResultsActor(project, client, resultsTable, queryId).also { + Disposer.register(this, it) + } + channel = insightsQueryActor.channel + component = ScrollPaneFactory.createScrollPane(resultsTable) + } + + private fun installDetailedLogRecordOpenListener() { + object : DoubleClickListener() { + override fun onDoubleClick(e: MouseEvent): Boolean { + // assume you can't double click multiple selection + val identifier = resultsTable.selectedObject?.identifier() ?: return false + coroutineScope.launch { CloudWatchLogWindow.getInstance(project).showDetailedEvent(client, identifier) } + return true + } + }.installOn(resultsTable) + } + + override fun dispose() {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/RetrieveSavedQueryDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/RetrieveSavedQueryDialog.kt new file mode 100644 index 0000000000..a022eb9daa --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/RetrieveSavedQueryDialog.kt @@ -0,0 +1,67 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import software.amazon.awssdk.services.cloudwatchlogs.model.QueryDefinition +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudwatchinsightsTelemetry +import software.aws.toolkits.telemetry.Result +import java.awt.event.ActionEvent +import javax.swing.Action +import javax.swing.JComponent + +class RetrieveSavedQueryDialog( + private val parentEditor: QueryEditor, + private val project: Project, + connectionSettings: ConnectionSettings +) : DialogWrapper(project) { + private val view = SelectSavedQuery(connectionSettings) + + private val action: OkAction = object : OkAction() { + override fun doAction(e: ActionEvent?) { + super.doAction(e) + if (doValidateAll().isNotEmpty()) return + + val selected = view.resourceSelector.selected() ?: throw IllegalStateException("No query definition was selected") + populateParentEditor(parentEditor, selected) + + close(OK_EXIT_CODE) + CloudwatchinsightsTelemetry.retrieveQuery(project, Result.Succeeded) + } + } + + init { + super.init() + title = message("cloudwatch.logs.select_saved_query_dialog_name") + } + + override fun doCancelAction() { + CloudwatchinsightsTelemetry.retrieveQuery(project, Result.Cancelled) + super.doCancelAction() + } + + override fun createCenterPanel(): JComponent? = view.getComponent() + override fun getOKAction(): Action = action + + override fun doValidate(): ValidationInfo? { + if (view.resourceSelector.selected() == null) { + return ValidationInfo(message("cloudwatch.logs.no_query_entered"), view.resourceSelector) + } + + return null + } + + companion object { + fun populateParentEditor(editor: QueryEditor, selection: QueryDefinition) { + if (selection.hasLogGroupNames()) { + editor.logGroupTable.selectLogGroups(selection.logGroupNames().toSet()) + } + editor.setQueryLanguage() + editor.queryBox.text = selection.queryString() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/SaveQueryDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/SaveQueryDialog.kt new file mode 100644 index 0000000000..0ee7fc0ae8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/SaveQueryDialog.kt @@ -0,0 +1,122 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.resources.CloudWatchResources +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudwatchinsightsTelemetry +import software.aws.toolkits.telemetry.Result +import java.awt.event.ActionEvent +import javax.swing.Action +import javax.swing.JComponent + +class SaveQueryDialog( + private val project: Project, + private val connectionSettings: ConnectionSettings, + private val query: String, + private val logGroups: List +) : DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + val view = EnterQueryName() + private val action: OkAction = object : OkAction() { + init { + putValue(Action.NAME, message("cloudwatch.logs.save_query")) + } + + override fun doAction(e: ActionEvent?) { + super.doAction(e) + if (doValidateAll().isNotEmpty()) return + saveQuery() + + close(OK_EXIT_CODE) + } + } + private val client = let { + val (credentials, region) = connectionSettings + AwsClientManager.getInstance().getClient(credentials, region) + } + private val resourceCache = AwsResourceCache.getInstance() + + init { + super.init() + title = message("cloudwatch.logs.save_query_dialog_name") + } + + override fun doCancelAction() { + CloudwatchinsightsTelemetry.saveQuery(project, Result.Cancelled) + super.doCancelAction() + } + + override fun createCenterPanel(): JComponent = view.saveQueryPanel + override fun doValidate(): ValidationInfo? = validateQueryName(view) + override fun getOKAction(): Action = action + + private suspend fun getExistingQueryId(queryName: String): String? { + val definitions = withContext(getCoroutineBgContext()) { + resourceCache.getResourceNow( + CloudWatchResources.DESCRIBE_QUERY_DEFINITIONS, + region = connectionSettings.region, + credentialProvider = connectionSettings.credentials, + forceFetch = true + ) + } + + return definitions.find { it.name() == queryName }?.queryDefinitionId() + } + + fun saveQuery() = coroutineScope.launch { + var result = Result.Succeeded + try { + val queryName = view.queryName.text + action.isEnabled = false + + val existingQueryId = getExistingQueryId(queryName) + client.putQueryDefinition { + it.queryDefinitionId(existingQueryId) + it.name(queryName) + it.logGroupNames(logGroups) + it.queryString(query) + } + notifyInfo(message("cloudwatch.logs.saved_query_status"), message("cloudwatch.logs.query_saved_successfully"), project) + // invalidate cache + resourceCache.clear( + CloudWatchResources.DESCRIBE_QUERY_DEFINITIONS, + connectionSettings + ) + } catch (e: Exception) { + LOG.error(e) { "Failed to save insights query" } + notifyError(message("cloudwatch.logs.failed_to_save_query"), e.toString()) + result = Result.Failed + } finally { + action.isEnabled = true + CloudwatchinsightsTelemetry.saveQuery(project, result) + } + } + + fun validateQueryName(view: EnterQueryName): ValidationInfo? { + if (view.queryName.text.isEmpty()) { + return ValidationInfo(message("cloudwatch.logs.query_name_missing"), view.queryName) + } + return null + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/SelectSavedQuery.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/SelectSavedQuery.form new file mode 100644 index 0000000000..8467e3a500 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/SelectSavedQuery.form @@ -0,0 +1,87 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/SelectSavedQuery.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/SelectSavedQuery.kt new file mode 100644 index 0000000000..3591e756d8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/SelectSavedQuery.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.services.cloudwatch.logs.insights + +import com.intellij.icons.AllIcons +import com.intellij.ui.SimpleListCellRenderer +import software.amazon.awssdk.services.cloudwatchlogs.model.QueryDefinition +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.resources.CloudWatchResources +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTextArea + +class SelectSavedQuery( + private val connectionSettings: ConnectionSettings +) { + lateinit var resourceSelector: ResourceSelector + private set + private lateinit var basePanel: JPanel + private lateinit var logGroups: JTextArea + private lateinit var queryString: JTextArea + private lateinit var refreshButton: JButton + + private fun createUIComponents() { + resourceSelector = ResourceSelector.builder() + .resource { CloudWatchResources.DESCRIBE_QUERY_DEFINITIONS } + .awsConnection { connectionSettings } + .customRenderer(SimpleListCellRenderer.create("") { it.name() }) + .build() + + // select the first entry, if applicable + resourceSelector.selectedItem { true } + + resourceSelector.addActionListener { + resourceSelector.selected()?.let { + logGroups.text = it.logGroupNames().joinToString("\n") + queryString.text = it.queryString() + // reset to the start, since setting the text moves the cursor to the end, + // which results in scrolling to the bottom right corner if there's enough text + logGroups.caretPosition = 0 + queryString.caretPosition = 0 + } + } + } + + init { + refreshButton.icon = AllIcons.Actions.Refresh + refreshButton.addActionListener { + logGroups.text = "" + queryString.text = "" + resourceSelector.reload(forceFetch = true) + } + } + + fun getComponent(): JComponent = basePanel +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/TimeUnit.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/TimeUnit.kt new file mode 100644 index 0000000000..5eeb09a3c5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/TimeUnit.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.services.cloudwatch.logs.insights + +import software.aws.toolkits.resources.message +import java.time.temporal.ChronoUnit + +enum class TimeUnit(val unit: ChronoUnit, val text: String) { + MINUTES(ChronoUnit.MINUTES, message("cloudwatch.logs.time_minutes")), + HOURS(ChronoUnit.HOURS, message("cloudwatch.logs.time_hours")), + DAYS(ChronoUnit.DAYS, message("cloudwatch.logs.time_days")), + WEEKS(ChronoUnit.WEEKS, message("cloudwatch.logs.time_weeks")) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/actions/QueryGroupAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/actions/QueryGroupAction.kt new file mode 100644 index 0000000000..1785618bb1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/insights/actions/QueryGroupAction.kt @@ -0,0 +1,29 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.core.credentials.activeCredentialProvider +import software.aws.toolkits.jetbrains.core.credentials.activeRegion +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogsNode +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.insights.QueryEditorDialog +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CloudwatchinsightsTelemetry +import software.aws.toolkits.telemetry.InsightsDialogOpenSource + +class QueryGroupAction : SingleResourceNodeAction(message("cloudwatch.logs.open_query_editor")), DumbAware { + override fun actionPerformed(selected: CloudWatchLogsNode, e: AnActionEvent) { + val project = selected.nodeProject + + QueryEditorDialog( + project, + ConnectionSettings(project.activeCredentialProvider(), project.activeRegion()), + selected.logGroupName + ).show() + CloudwatchinsightsTelemetry.openEditor(project, InsightsDialogOpenSource.Explorer) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/resources/CloudWatchResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/resources/CloudWatchResources.kt index 2dd82aa2e1..8ca3d3864b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/resources/CloudWatchResources.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/resources/CloudWatchResources.kt @@ -4,6 +4,7 @@ package software.aws.toolkits.jetbrains.services.cloudwatch.logs.resources import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient +import software.amazon.awssdk.services.cloudwatchlogs.model.QueryDefinition import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource object CloudWatchResources { @@ -11,4 +12,19 @@ object CloudWatchResources { ClientBackedCachedResource(CloudWatchLogsClient::class, "cwl.log_groups") { describeLogGroupsPaginator().logGroups().filterNotNull().toList() } + + val DESCRIBE_QUERY_DEFINITIONS = + ClientBackedCachedResource(CloudWatchLogsClient::class, "cwl.query_definitions") { + // unfortunately there is no paginator for this + sequence { + var token: String? = null + do { + val result = describeQueryDefinitions { + it.nextToken(token) + } + token = result.nextToken() + yieldAll(result.queryDefinitions()) + } while (token != null) + }.toList() + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/toolwindow/CloudWatchLogsToolWindowFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/toolwindow/CloudWatchLogsToolWindowFactory.kt new file mode 100644 index 0000000000..2086560f38 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cloudwatch/logs/toolwindow/CloudWatchLogsToolWindowFactory.kt @@ -0,0 +1,29 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cloudwatch.logs.toolwindow + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import software.aws.toolkits.resources.message + +class CloudWatchLogsToolWindowFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + runInEdt { + toolWindow.installWatcher(toolWindow.contentManager) + } + } + + override fun init(toolWindow: ToolWindow) { + toolWindow.stripeTitle = message("cloudwatch.logs.toolwindow") + } + + override fun shouldBeAvailable(project: Project): Boolean = false + + companion object { + const val TOOLWINDOW_ID = "aws.cloudwatchlogs" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ArtifactHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ArtifactHandler.kt new file mode 100644 index 0000000000..54fcde74b1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ArtifactHandler.kt @@ -0,0 +1,234 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.ChangeListManager +import com.intellij.openapi.vcs.changes.patch.ApplyPatchDefaultExecutor +import com.intellij.openapi.vcs.changes.patch.ApplyPatchDifferentiatedDialog +import com.intellij.openapi.vcs.changes.patch.ApplyPatchMode +import com.intellij.openapi.vcs.changes.patch.ImportToShelfExecutor +import com.intellij.openapi.vfs.VirtualFile +import kotlinx.coroutines.launch +import software.aws.toolkits.core.utils.error +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.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.services.codemodernizer.client.GumbyClient +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerArtifact +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobId +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeTransformTelemetryState +import software.aws.toolkits.jetbrains.services.codemodernizer.summary.CodeModernizerSummaryEditorProvider +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.jetbrains.utils.notifyWarn +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodeTransformApiNames +import software.aws.toolkits.telemetry.CodeTransformPatchViewerCancelSrcComponents +import software.aws.toolkits.telemetry.CodeTransformVCSViewerSrcComponents +import software.aws.toolkits.telemetry.CodetransformTelemetry +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean + +data class DownloadArtifactResult(val artifact: CodeModernizerArtifact?, val zipPath: String) +class ArtifactHandler(private val project: Project, private val clientAdaptor: GumbyClient) { + private val downloadedArtifacts = mutableMapOf() + private val downloadedSummaries = mutableMapOf() + + private var isCurrentlyDownloading = AtomicBoolean(false) + internal suspend fun displayDiff(job: JobId) { + if (isCurrentlyDownloading.get()) return + val result = downloadArtifact(job) + if (result.artifact == null) { + notifyUnableToApplyPatch(result.zipPath) + } else { + displayDiffUsingPatch(result.artifact.patch, job) + } + } + + private fun notifyDownloadStart() { + notifyInfo( + message("codemodernizer.notification.info.download.started.title"), + message("codemodernizer.notification.info.download.started.content"), + project, + ) + } + + suspend fun downloadArtifact(job: JobId): DownloadArtifactResult { + isCurrentlyDownloading.set(true) + try { + // 1. Attempt reusing previously downloaded artifact for job + val previousArtifact = downloadedArtifacts.getOrDefault(job, null) + if (previousArtifact != null && previousArtifact.exists()) { + val zipPath = previousArtifact.toAbsolutePath().toString() + return try { + val artifact = CodeModernizerArtifact.create(zipPath) + downloadedSummaries[job] = artifact.summary + DownloadArtifactResult(artifact, zipPath) + } catch (e: RuntimeException) { + LOG.error { e.message.toString() } + DownloadArtifactResult(null, zipPath) + } + } + + // 2. Download the data + notifyDownloadStart() + LOG.warn { "About to download the export result archive" } + var apiStartTime: Instant = Instant.now() + lateinit var apiEndTime: Instant + val downloadResultsResponse: MutableList + try { + downloadResultsResponse = clientAdaptor.downloadExportResultArchive(job) + } catch (e: Exception) { + CodetransformTelemetry.logApiError( + codeTransformApiNames = CodeTransformApiNames.ExportResultArchive, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformApiErrorMessage = e.message.toString(), + codeTransformJobId = job.id, + ) + throw e // pass along error to callee + } finally { + apiEndTime = Instant.now() + } + + // 3. Convert to zip + LOG.warn { "Downloaded the export result archive, about to transform to zip" } + val path = Files.createTempFile(null, ".zip") + var totalDownloadBytes = 0 + Files.newOutputStream(path).use { + for (bytes in downloadResultsResponse) { + it.write(bytes) + totalDownloadBytes += bytes.size + } + } + CodetransformTelemetry.logApiLatency( + codeTransformApiNames = CodeTransformApiNames.ExportResultArchive, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(apiStartTime, apiEndTime), + codeTransformJobId = job.id, + codeTransformTotalByteSize = totalDownloadBytes + ) + LOG.warn { "Successfully converted the download to a zip at ${path.toAbsolutePath()}." } + val zipPath = path.toAbsolutePath().toString() + + // 4. Deserialize zip to CodeModernizerArtifact + var telemetryErrorMessage = "" + return try { + apiStartTime = Instant.now() + val output = DownloadArtifactResult(CodeModernizerArtifact.create(zipPath), zipPath) + apiEndTime = Instant.now() + downloadedArtifacts[job] = path + output + } catch (e: RuntimeException) { + LOG.error { e.message.toString() } + telemetryErrorMessage = e.message.toString() + apiEndTime = Instant.now() + DownloadArtifactResult(null, zipPath) + } finally { + CodetransformTelemetry.jobArtifactDownloadAndDeserializeTime( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(apiStartTime, apiEndTime), + codeTransformJobId = job.id, + codeTransformTotalByteSize = totalDownloadBytes, + codeTransformRuntimeError = telemetryErrorMessage + ) + } + } finally { + isCurrentlyDownloading.set(false) + } + } + + /** + * Opens the built-in patch dialog to display the diff and allowing users to apply the changes locally. + */ + internal fun displayDiffUsingPatch(patchFile: VirtualFile, jobId: JobId) { + runInEdt { + val dialog = ApplyPatchDifferentiatedDialog( + project, + ApplyPatchDefaultExecutor(project), + listOf(ImportToShelfExecutor(project)), + ApplyPatchMode.APPLY, + patchFile, + null, + ChangeListManager.getInstance(project) + .addChangeList(message("codemodernizer.patch.name"), ""), + null, + null, + null, + false, + ) + dialog.isModal = true + + CodetransformTelemetry.vcsDiffViewerVisible( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = jobId.id + ) + + if (dialog.showAndGet()) { + CodetransformTelemetry.vcsViewerSubmitted( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = jobId.id + ) + } else { + CodetransformTelemetry.vcsViewerCanceled( + codeTransformPatchViewerCancelSrcComponents = CodeTransformPatchViewerCancelSrcComponents.CancelButton, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = jobId.id + ) + } + } + } + + fun notifyUnableToApplyPatch(patchPath: String) { + LOG.warn { "Unable to find patch for file: $patchPath" } + notifyWarn( + message("codemodernizer.notification.warn.view_diff_failed.title"), + message("codemodernizer.notification.warn.view_diff_failed.content"), + project, + listOf(), + ) + } + + fun notifyUnableToShowSummary() { + LOG.warn { "Unable to display summary" } + notifyWarn( + message("codemodernizer.notification.warn.view_summary_failed.title"), + message("codemodernizer.notification.warn.view_summary_failed.content"), + project, + listOf(), + ) + } + + fun displayDiffAction(jobId: JobId) = runReadAction { + CodetransformTelemetry.vcsViewerClicked( + codeTransformVCSViewerSrcComponents = CodeTransformVCSViewerSrcComponents.ToastNotification, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = jobId.id + ) + projectCoroutineScope(project).launch { + displayDiff(jobId) + } + } + + fun getSummary(job: JobId) = downloadedSummaries[job] + + fun showTransformationSummary(job: JobId) { + if (isCurrentlyDownloading.get()) return + runReadAction { + projectCoroutineScope(project).launch { + val result = downloadArtifact(job) + val summary = result.artifact?.summary ?: return@launch notifyUnableToShowSummary() + runInEdt { CodeModernizerSummaryEditorProvider.openEditor(project, summary) } + } + } + } + + companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerManager.kt new file mode 100644 index 0000000000..0115099a55 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerManager.kt @@ -0,0 +1,733 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.codemodernizer + +import com.intellij.icons.AllIcons +import com.intellij.notification.NotificationAction +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.modules +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.ToolWindowManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationJob +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStatus +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnection +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnectionType +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.checkBearerConnectionValidity +import software.aws.toolkits.jetbrains.services.codemodernizer.client.GumbyClient +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerException +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerJobCompletedResult +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerSessionContext +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerStartJobResult +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CustomerSelection +import software.aws.toolkits.jetbrains.services.codemodernizer.model.InvalidTelemetryReason +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobId +import software.aws.toolkits.jetbrains.services.codemodernizer.model.MAVEN_CONFIGURATION_FILE_NAME +import software.aws.toolkits.jetbrains.services.codemodernizer.model.ValidationResult +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.managers.CodeModernizerBottomWindowPanelManager +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeModernizerSessionState +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeModernizerState +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeTransformTelemetryState +import software.aws.toolkits.jetbrains.services.codemodernizer.state.StateFlags +import software.aws.toolkits.jetbrains.services.codemodernizer.state.buildState +import software.aws.toolkits.jetbrains.services.codemodernizer.state.getLatestJobId +import software.aws.toolkits.jetbrains.services.codemodernizer.state.toSessionContext +import software.aws.toolkits.jetbrains.services.codemodernizer.toolwindow.CodeModernizerBottomToolWindowFactory +import software.aws.toolkits.jetbrains.services.codemodernizer.ui.components.BuildErrorDialog +import software.aws.toolkits.jetbrains.services.codemodernizer.ui.components.PreCodeTransformUserDialog +import software.aws.toolkits.jetbrains.services.codemodernizer.ui.components.ValidationErrorDialog +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.jetbrains.utils.notifyStickyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodeTransformCancelSrcComponents +import software.aws.toolkits.telemetry.CodeTransformJavaSourceVersionsAllowed +import software.aws.toolkits.telemetry.CodeTransformJavaTargetVersionsAllowed +import software.aws.toolkits.telemetry.CodeTransformPreValidationError +import software.aws.toolkits.telemetry.CodeTransformStartSrcComponents +import software.aws.toolkits.telemetry.CodetransformTelemetry +import software.aws.toolkits.telemetry.Result +import java.io.File +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.Icon + +@State(name = "codemodernizerStates", storages = [Storage("aws.xml", roamingType = RoamingType.PER_OS)]) +class CodeModernizerManager(private val project: Project) : PersistentStateComponent, Disposable { + private var managerState = CodeModernizerState() + val codeModernizerBottomWindowPanelManager by lazy { CodeModernizerBottomWindowPanelManager(project) } + private val codeModernizerBottomWindowPanelContent by lazy { + val contentManager = getBottomToolWindow().contentManager + contentManager.removeAllContents(true) + contentManager.factory.createContent( + codeModernizerBottomWindowPanelManager, + message("codemodernizer.toolwindow.scan_display"), + false, + ).also { + Disposer.register(contentManager, it) + } + } + private val supportedBuildFileNames = listOf(MAVEN_CONFIGURATION_FILE_NAME) + private val supportedJavaMappings = mapOf( + JavaSdkVersion.JDK_1_8 to setOf(JavaSdkVersion.JDK_17), + JavaSdkVersion.JDK_11 to setOf(JavaSdkVersion.JDK_17), + ) + private val isModernizationInProgress = AtomicBoolean(false) + private val isResumingJob = AtomicBoolean(false) + + private val transformationStoppedByUsr = AtomicBoolean(false) + private var codeTransformationSession: CodeModernizerSession? = null + set(session) { + if (session != null) { + Disposer.register(this, session) + } + field = session + } + private val artifactHandler = ArtifactHandler(project, GumbyClient.getInstance(project)) + + init { + CodeModernizerSessionState.getInstance(project).setDefaults() + } + + fun validate(project: Project): ValidationResult { + if (isRunningOnRemoteBackend()) { + return ValidationResult( + false, + message("codemodernizer.notification.warn.invalid_project.description.reason.remote_backend"), + InvalidTelemetryReason( + CodeTransformPreValidationError.RemoteRunProject + ) + ) + } + val connection = checkBearerConnectionValidity(project, BearerTokenFeatureSet.Q) + if (!(connection.connectionType == ActiveConnectionType.IAM_IDC && connection is ActiveConnection.ValidBearer)) { + return ValidationResult( + false, + message("codemodernizer.notification.warn.invalid_project.description.reason.not_logged_in"), + InvalidTelemetryReason( + CodeTransformPreValidationError.NonSsoLogin + ) + ) + } + + if (ProjectRootManager.getInstance(project).contentRoots.isEmpty()) { + return ValidationResult( + false, + message("codemodernizer.notification.warn.invalid_project.description.reason.missing_content_roots"), + InvalidTelemetryReason( + CodeTransformPreValidationError.NoPom + ) + ) + } + val supportedModules = getSupportedModulesInProject().toSet() + val validProjectJdk = project.getSupportedJavaMappingsForProject(supportedJavaMappings).isNotEmpty() + if (supportedModules.isEmpty() && !validProjectJdk) { + return ValidationResult( + false, + message("codemodernizer.notification.warn.invalid_project.description.reason.invalid_jdk_versions", supportedJavaMappings.keys.joinToString()), + InvalidTelemetryReason( + CodeTransformPreValidationError.UnsupportedJavaVersion, + project.tryGetJdk().toString() + ) + ) + } + val validatedBuildFiles = getSupportedBuildFilesInProject() + return if (validatedBuildFiles.isNotEmpty()) { + ValidationResult(true, validatedBuildFiles = validatedBuildFiles) + } else { + ValidationResult( + false, + message("codemodernizer.notification.warn.invalid_project.description.reason.no_valid_files", supportedBuildFileNames.joinToString()), + InvalidTelemetryReason( + CodeTransformPreValidationError.NonMavenProject, + if (isGradleProject(project)) "Gradle build" else "other build" + ) + ) + } + } + + /** + * The initial landing UI for the results view panel. + * This method adds code content to the problems view if not already added. + * When [setSelected] is true, code scan panel is set to be in focus. + */ + fun addCodeModernizeUI(setSelected: Boolean = false, moduleOrProjectNameForFile: String? = null) = runInEdt { + val appModernizerBottomWindow = getBottomToolWindow() + if (!appModernizerBottomWindow.contentManager.contents.contains(codeModernizerBottomWindowPanelContent)) { + appModernizerBottomWindow.contentManager.addContent(codeModernizerBottomWindowPanelContent) + } + codeModernizerBottomWindowPanelContent.displayName = message("codemodernizer.toolwindow.scan_display") + if (moduleOrProjectNameForFile == null) { + appModernizerBottomWindow.stripeTitle = message("codemodernizer.toolwindow.label_no_job") + } else { + appModernizerBottomWindow.stripeTitle = message("codemodernizer.toolwindow.label", moduleOrProjectNameForFile) + } + + if (setSelected) { + appModernizerBottomWindow.contentManager.setSelectedContent(codeModernizerBottomWindowPanelContent) + appModernizerBottomWindow.show() + } + } + + /** + * @description Main function for triggering the start of elastic gumby migration. + */ + fun initModernizationJobUI(shouldOpenBottomWindowOnStart: Boolean = true, moduleOrProjectNameForFile: String) { + isModernizationInProgress.set(true) + // Refresh CodeWhisperer Explorer tree node to reflect scan in progress. + project.refreshCwQTree() + // Initialize the bottom toolkit window with content + addCodeModernizeUI(shouldOpenBottomWindowOnStart, moduleOrProjectNameForFile) + codeModernizerBottomWindowPanelManager.setJobStartingUI() + } + + fun validateAndStart(srcStartComponent: CodeTransformStartSrcComponents = CodeTransformStartSrcComponents.DevToolsStartButton) = + projectCoroutineScope(project).launch { + if (isModernizationInProgress.getAndSet(true)) return@launch + val validationResult = validate(project) + runInEdt { + if (validationResult.valid) { + runModernize(validationResult.validatedBuildFiles) ?: isModernizationInProgress.set(false) + } else { + warnUnsupportedProject(validationResult.invalidReason) + isModernizationInProgress.set(false) + } + } + sendValidationResultTelemetry(validationResult, srcStartComponent) + } + + private fun sendValidationResultTelemetry(validationResult: ValidationResult, srcStartComponent: CodeTransformStartSrcComponents) { + CodeTransformTelemetryState.instance.setSessionId() + CodeTransformTelemetryState.instance.setStartTime() + if (validationResult.valid) { + CodetransformTelemetry.isDoubleClickedToTriggerUserModal( + codeTransformStartSrcComponents = srcStartComponent, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + ) + return + } + CodetransformTelemetry.isDoubleClickedToTriggerInvalidProject( + codeTransformPreValidationError = validationResult.invalidTelemetryReason.category ?: CodeTransformPreValidationError.Unknown, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + result = Result.Failed, + reason = validationResult.invalidTelemetryReason.additonalInfo + ) + } + + fun stopModernize() { + if (isModernizationJobActive()) { + userInitiatedStopCodeModernization() + CodetransformTelemetry.jobIsCancelledByUser( + codeTransformCancelSrcComponents = CodeTransformCancelSrcComponents.DevToolsStopButton, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId() + ) + } + } + + fun runModernize(validatedBuildFiles: List): Job? { + initStopParameters() + val customerSelection = getCustomerSelection(validatedBuildFiles) ?: return null + CodetransformTelemetry.jobStartedCompleteFromPopupDialog( + codeTransformJavaSourceVersionsAllowed = CodeTransformJavaSourceVersionsAllowed.from(customerSelection.sourceJavaVersion.name), + codeTransformJavaTargetVersionsAllowed = CodeTransformJavaTargetVersionsAllowed.from(customerSelection.targetJavaVersion.name), + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId() + ) + initModernizationJobUI(true, project.getModuleOrProjectNameForFile(customerSelection.configurationFile)) + val session = createCodeModernizerSession(customerSelection, project) + codeTransformationSession = session + return launchModernizationJob(session) + } + + private fun initStopParameters() { + codeTransformationSession = null + transformationStoppedByUsr.set(false) + CodeModernizerSessionState.getInstance(project).currentJobStatus = TransformationStatus.UNKNOWN_TO_SDK_VERSION + CodeModernizerSessionState.getInstance(project).currentJobCreationTime = Instant.MIN + CodeModernizerSessionState.getInstance(project).currentJobStopTime = Instant.MIN + } + + fun getCustomerSelection(validatedBuildFiles: List): CustomerSelection? = PreCodeTransformUserDialog( + project, + validatedBuildFiles, + supportedJavaMappings, + ).create() + + private fun notifyJobFailure(failureReason: String?, retryable: Boolean) { + val reason = failureReason ?: message("codemodernizer.notification.info.modernize_failed.unknown_failure_reason") // should not happen + val retryablestring = if (retryable) message("codemodernizer.notification.info.modernize_failed.failure_is_retryable") else "" + notifyStickyInfo( + message("codemodernizer.notification.info.modernize_failed.title"), + message("codemodernizer.notification.info.modernize_failed.description", reason, retryablestring), + project, + ) + } + + internal fun notifyTransformationStopped() { + notifyInfo( + message("codemodernizer.notification.info.transformation_stop.title"), + message("codemodernizer.notification.info.transformation_stop.content"), + project, + ) + } + + internal fun notifyUnableToResumeJob() { + notifyStickyInfo( + message("codemodernizer.notification.info.transformation_resume.title"), + message("codemodernizer.notification.info.transformation_resume.content"), + project, + ) + } + + internal fun notifyTransformationStartStopping() { + notifyInfo( + message("codemodernizer.notification.info.transformation_start_stopping.title"), + message("codemodernizer.notification.info.transformation_start_stopping.content"), + project, + ) + } + + internal fun notifyTransformationStartCannotYetStop() { + notifyError( + message("codemodernizer.notification.info.transformation_start_stopping.failed_title"), + message("codemodernizer.notification.info.transformation_start_stopping.failed_as_job_not_started"), + project, + ) + } + + internal fun notifyTransformationFailedToStop(message: String) { + notifyError( + message("codemodernizer.notification.info.transformation_start_stopping.failed_title"), + message("codemodernizer.notification.info.transformation_start_stopping.failed_content", message), + project, + ) + } + + fun launchModernizationJob(session: CodeModernizerSession) = projectCoroutineScope(project).launch { + val result = initModernizationJob(session) + postModernizationJob(result) + } + + fun resumeJob(session: CodeModernizerSession, lastJobId: JobId, currentJobResult: TransformationJob) = projectCoroutineScope(project).launch { + if (isModernizationJobActive()) { + runInEdt { getBottomToolWindow().show() } + return@launch + } + try { + val plan = if (currentJobResult.status() in STATES_WHERE_PLAN_EXIST) { + try { + delay(1000) + session.fetchPlan(lastJobId).transformationPlan() + } catch (_: Exception) { + null + } + } else { + null + } + CodeModernizerSessionState.getInstance(project).currentJobCreationTime = currentJobResult.creationTime() + codeTransformationSession = session + initModernizationJobUI(false, project.getModuleOrProjectNameForFile(session.sessionContext.configurationFile)) + codeModernizerBottomWindowPanelManager.setResumeJobUI(currentJobResult, plan, session.sessionContext.sourceJavaVersion) + session.resumeJob(currentJobResult.creationTime()) + val result = handleJobStarted(lastJobId, session) + postModernizationJob(result) + } catch (e: Exception) { + notifyUnableToResumeJob() + LOG.warn(e) { e.message.toString() } + return@launch + } + } + + fun setJobOngoing(jobId: JobId, sessionContext: CodeModernizerSessionContext) { + isModernizationInProgress.set(true) + managerState = buildState(sessionContext, true, jobId) + } + + fun setJobNotOngoing() { + isModernizationInProgress.set(false) + managerState.flags[StateFlags.IS_ONGOING] = false + } + + /** + *Start the modernization job and return the reference + */ + internal suspend fun initModernizationJob(session: CodeModernizerSession): CodeModernizerJobCompletedResult { + val result = session.createModernizationJob() + return when (result) { + is CodeModernizerStartJobResult.ZipCreationFailed -> { + CodeModernizerJobCompletedResult.UnableToCreateJob( + message("codemodernizer.notification.warn.zip_creation_failed", result.reason), + false, + ) + } + + is CodeModernizerStartJobResult.UnableToStartJob -> { + CodeModernizerJobCompletedResult.UnableToCreateJob( + message("codemodernizer.notification.warn.unable_to_start_job", result.exception), // TODO maybe not display the entire message + true, + ) + } + + is CodeModernizerStartJobResult.Started -> { + handleJobStarted(result.jobId, session) + } + + is CodeModernizerStartJobResult.Disposed -> { + CodeModernizerJobCompletedResult.ManagerDisposed + } + + is CodeModernizerStartJobResult.Cancelled -> { + CodeModernizerJobCompletedResult.JobAbortedBeforeStarting + } + } + } + + suspend fun handleJobStarted(jobId: JobId, session: CodeModernizerSession): CodeModernizerJobCompletedResult { + setJobOngoing(jobId, session.sessionContext) + // Init the splitter panel to show progress and progress steps + // https://plugins.jetbrains.com/docs/intellij/general-threading-rules.html#write-access + ApplicationManager.getApplication().invokeLater { + codeModernizerBottomWindowPanelManager.setJobRunningUI() + } + + return session.pollUntilJobCompletion(jobId) { new, plan -> + codeModernizerBottomWindowPanelManager.handleJobTransition(new, plan, session.sessionContext.sourceJavaVersion) + } + } + + internal fun postModernizationJob(result: CodeModernizerJobCompletedResult) { + if (result is CodeModernizerJobCompletedResult.ManagerDisposed) { + return + } + // https://plugins.jetbrains.com/docs/intellij/general-threading-rules.html#write-access + ApplicationManager.getApplication().invokeLater { + setJobNotOngoing() + project.refreshCwQTree() + if (!transformationStoppedByUsr.get()) { + informUserOfCompletion(result) + codeModernizerBottomWindowPanelManager.setJobFinishedUI(result) + } else { + codeModernizerBottomWindowPanelManager.userInitiatedStopCodeModernizationUI() + notifyTransformationStopped() + transformationStoppedByUsr.set(false) + } + } + } + + fun tryResumeJob() = projectCoroutineScope(project).launch { + try { + val notYetResumed = isResumingJob.compareAndSet(false, true) + if (!notYetResumed) return@launch + + LOG.warn { "Attempting to resume job, current state is: $managerState" } + if (!managerState.flags.getOrDefault(StateFlags.IS_ONGOING, false)) return@launch + val context = managerState.toSessionContext(project) + val session = CodeModernizerSession(context) + val lastJobId = managerState.getLatestJobId() + LOG.warn { "Attempting to resume job with id $lastJobId" } + val result = session.getJobDetails(lastJobId) + when (result.status()) { + TransformationStatus.COMPLETED -> { + notifyStickyInfo( + message("codemodernizer.manager.job_finished_title"), + message("codemodernizer.manager.job_finished_content"), + project, + listOf(displayDiffNotificationAction(lastJobId), displaySummaryNotificationAction(lastJobId), viewTransformationHubAction()) + ) + resumeJob(session, lastJobId, result) + setJobNotOngoing() + } + + TransformationStatus.PARTIALLY_COMPLETED -> { + notifyStickyInfo( + message("codemodernizer.notification.info.modernize_failed.title"), + message("codemodernizer.manager.job_failed_content", result.reason()), + project, + listOf(displayDiffNotificationAction(lastJobId), displaySummaryNotificationAction(lastJobId), viewTransformationHubAction()) + ) + resumeJob(session, lastJobId, result) + setJobNotOngoing() + } + + TransformationStatus.UNKNOWN_TO_SDK_VERSION -> { + notifyStickyInfo( + message("codemodernizer.notification.warn.on_resume.unknown_status_response.title"), + message("codemodernizer.notification.warn.on_resume.unknown_status_response.content"), + ) + setJobNotOngoing() + } + + TransformationStatus.STOPPED, TransformationStatus.STOPPING -> { + setJobNotOngoing() + } + + else -> { + resumeJob(session, lastJobId, result) + notifyStickyInfo( + message("codemodernizer.manager.job_ongoing_title"), + message("codemodernizer.manager.job_ongoing_content"), + project, + listOf(resumeJobNotificationAction(session, lastJobId, result)), + ) + } + } + CodetransformTelemetry.jobIsResumedAfterIdeClose( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = lastJobId.id, + codeTransformStatus = result.status().toString() + ) + } catch (e: AccessDeniedException) { + LOG.warn { "Unable to resume job as credentials are invalid" } + // User is logged in with old or invalid credentials, nothing to do until they log in with valid credentials + } catch (e: Exception) { + LOG.warn { "Unable to resume job as an unexpected exception occurred ${e.stackTraceToString()}" } + } finally { + isResumingJob.set(false) + } + } + + private fun viewTransformationHubAction() = NotificationAction.createSimple(message("codemodernizer.notification.info.modernize_complete.view_summary")) { + getBottomToolWindow().show() + } + + private fun resumeJobNotificationAction(session: CodeModernizerSession, lastJobId: JobId, currentJobResult: TransformationJob) = + NotificationAction.createSimple(message("codemodernizer.notification.info.modernize_ongoing.view_status")) { + resumeJob(session, lastJobId, currentJobResult) + } + + private fun displayDiffNotificationAction(jobId: JobId): NotificationAction = NotificationAction.createSimple( + message("codemodernizer.notification.info.modernize_complete.view_diff") + ) { + artifactHandler.displayDiffAction(jobId) + } + + private fun displaySummaryNotificationAction(jobId: JobId) = + NotificationAction.createSimple(message("codemodernizer.notification.info.modernize_complete.view_summary")) { + artifactHandler.showTransformationSummary(jobId) + } + + fun informUserOfCompletion(result: CodeModernizerJobCompletedResult) { + CodetransformTelemetry.totalRunTime( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformResultStatusMessage = result.toString(), + codeTransformRunTimeLatency = calculateTotalLatency(CodeTransformTelemetryState.instance.getStartTime(), Instant.now()) + ) + when (result) { + is CodeModernizerJobCompletedResult.UnableToCreateJob -> notifyJobFailure( + result.failureReason, + result.retryable, + ) + + is CodeModernizerJobCompletedResult.RetryableFailure -> notifyJobFailure( + result.failureReason, + true, + ) + + is CodeModernizerJobCompletedResult.JobFailed -> notifyJobFailure( + result.failureReason, + false, + ) + + is CodeModernizerJobCompletedResult.JobFailedInitialBuild -> { + ApplicationManager.getApplication().invokeLater { + runInEdt { BuildErrorDialog.create(result.failureReason) } + } + } + + is CodeModernizerJobCompletedResult.JobPartiallySucceeded -> notifyStickyInfo( + message("codemodernizer.notification.info.modernize_partial_complete.title"), + message("codemodernizer.notification.info.modernize_partial_complete.content", result.targetJavaVersion.description), + project, + listOf(displayDiffNotificationAction(result.jobId), displaySummaryNotificationAction(result.jobId)), + ) + + is CodeModernizerJobCompletedResult.JobCompletedSuccessfully -> notifyStickyInfo( + message("codemodernizer.notification.info.modernize_complete.title"), + message("codemodernizer.notification.info.modernize_complete.content"), + project, + listOf(displayDiffNotificationAction(result.jobId), displaySummaryNotificationAction(result.jobId)), + ) + + is CodeModernizerJobCompletedResult.ManagerDisposed -> LOG.warn { "Manager disposed" } + is CodeModernizerJobCompletedResult.JobAbortedBeforeStarting -> LOG.warn { "Job was aborted" } + } + } + + fun createCodeModernizerSession(customerSelection: CustomerSelection, project: Project) = CodeModernizerSession( + CodeModernizerSessionContext( + project, + customerSelection.configurationFile, + customerSelection.sourceJavaVersion, + customerSelection.targetJavaVersion, + ), + ) + + fun showModernizationProgressUI() = codeModernizerBottomWindowPanelManager.showUnalteredJobUI() + + fun showPreviousJobHistoryUI() { + codeModernizerBottomWindowPanelManager.setPreviousJobHistoryUI(isModernizationJobActive()) + } + + fun userInitiatedStopCodeModernization() { + notifyTransformationStartStopping() + if (transformationStoppedByUsr.getAndSet(true)) return + val currentId = codeTransformationSession?.getActiveJobId()?.id + projectCoroutineScope(project).launch { + try { + val success = codeTransformationSession?.stopTransformation(currentId) ?: true // no session -> no job to stop + if (!success) { + // This should not happen + throw CodeModernizerException(message("codemodernizer.notification.info.transformation_start_stopping.as_no_response")) + } else { + // Code successfully stopped toast will display when post job is run after this + CodetransformTelemetry.totalRunTime( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformResultStatusMessage = "User initiated stop", + codeTransformRunTimeLatency = calculateTotalLatency(CodeTransformTelemetryState.instance.getStartTime(), Instant.now()) + ) + } + } catch (e: Exception) { + LOG.error(e) { e.message.toString() } + notifyTransformationFailedToStop(e.localizedMessage) + CodetransformTelemetry.totalRunTime( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformResultStatusMessage = "User initiated stop", + codeTransformRunTimeLatency = calculateTotalLatency(CodeTransformTelemetryState.instance.getStartTime(), Instant.now()) + ) + } + } + } + + fun isModernizationJobActive(): Boolean = isModernizationInProgress.get() + + fun getBottomToolWindow() = ToolWindowManager.getInstance(project).getToolWindow(CodeModernizerBottomToolWindowFactory.id) + ?: error(message("codemodernizer.toolwindow.problems_window_not_found")) + + fun getRunActionButtonIcon(): Icon = + if (isModernizationInProgress.get()) AllIcons.Actions.Suspend else AllIcons.Actions.Execute + + override fun getState(): CodeModernizerState = CodeModernizerState().apply { + lastJobContext.putAll(managerState.lastJobContext) + flags.putAll(managerState.flags) + } + + override fun loadState(state: CodeModernizerState) { + managerState.lastJobContext.clear() + managerState.lastJobContext.putAll(state.lastJobContext) + managerState.flags.clear() + managerState.flags.putAll(state.flags) + } + + private fun findBuildFiles(sourceFolder: File): List { + /** + * For every dir, check if any supported build files (pom.xml etc) exists. + * If found store it and stop further recursion. + */ + val buildFiles = mutableListOf() + sourceFolder.walkTopDown() + .maxDepth(5) + .onEnter { currentDir -> + supportedBuildFileNames.forEach { + val maybeSupportedFile = currentDir.resolve(MAVEN_CONFIGURATION_FILE_NAME) + if (maybeSupportedFile.exists()) { + buildFiles.add(maybeSupportedFile) + return@onEnter false + } + } + return@onEnter true + }.forEach { + // noop, collects the sequence + } + return buildFiles + } + + private fun getSupportedBuildFilesInProject(): List { + /** + * Strategy: + * 1. Find folders with pom.xml or build.gradle.kts or build.gradle + * 2. Filter out subdirectories + */ + val projectRootManager = ProjectRootManager.getInstance(project) + val probableProjectRoot = project.basePath?.toVirtualFile() // May point to only one intellij module (the first opened one) + val probableContentRoots = projectRootManager.contentRoots.toMutableSet() // May not point to the topmost folder of modules + probableContentRoots.add(probableProjectRoot) // dedupe + val topLevelRoots = filterOnlyParentFiles(probableContentRoots) + val detectedBuildFiles = topLevelRoots.flatMap { root -> + findBuildFiles(root.toNioPath().toFile()).mapNotNull { it.path.toVirtualFile() } + } + + val supportedModules = getSupportedModulesInProject().toSet() + val validProjectJdk = project.getSupportedJavaMappingsForProject(supportedJavaMappings).isNotEmpty() + return detectedBuildFiles.filter { + val moduleOfFile = runReadAction { projectRootManager.fileIndex.getModuleForFile(it) } + return@filter (moduleOfFile in supportedModules) || (moduleOfFile == null && validProjectJdk) + } + } + + private fun getSupportedModulesInProject() = project.modules.filter { + val moduleJdk = it.tryGetJdk(project) ?: return@filter false + moduleJdk in supportedJavaMappings + } + + fun warnUnsupportedProject(reason: String?) { + addCodeModernizeUI(true) + val maybeUnknownReason = reason ?: message("codemodernizer.notification.warn.invalid_project.description.reason.unknown") + codeModernizerBottomWindowPanelManager.setProjectInvalidUI(maybeUnknownReason) + ApplicationManager.getApplication().invokeLater { + runInEdt { ValidationErrorDialog.create(maybeUnknownReason) } + } + } + + fun getTransformationPlan(): TransformationPlan? = codeTransformationSession?.getTransformationPlan() + fun getTransformationSummary(): TransformationSummary? { + val job = codeTransformationSession?.getActiveJobId() ?: return null + return artifactHandler.getSummary(job) + } + + companion object { + fun getInstance(project: Project): CodeModernizerManager = project.service() + val LOG = getLogger() + } + + override fun dispose() {} + fun showTransformationSummary() { + val job = codeTransformationSession?.getActiveJobId() ?: return + artifactHandler.showTransformationSummary(job) + } + + fun showTransformationPlan() { + codeTransformationSession?.tryOpenTransformationPlanEditor() + } + + fun showDiff() { + val job = codeTransformationSession?.getActiveJobId() ?: return + artifactHandler.displayDiffAction(job) + } + + fun handleCredentialsChanged() { + codeTransformationSession?.dispose() + codeModernizerBottomWindowPanelManager.reset() + isModernizationInProgress.set(false) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt new file mode 100644 index 0000000000..b4b147656c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerSession.kt @@ -0,0 +1,543 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.runInEdt +import com.intellij.serviceContainer.AlreadyDisposedException +import com.intellij.util.io.HttpRequests +import kotlinx.coroutines.delay +import org.apache.commons.codec.digest.DigestUtils +import software.amazon.awssdk.services.codewhispererruntime.model.StartTransformationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationJob +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationLanguage +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStatus +import software.aws.toolkits.core.utils.error +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.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.services.codemodernizer.client.GumbyClient +import software.aws.toolkits.jetbrains.services.codemodernizer.model.AwaitModernizationPlanResult +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerException +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerJobCompletedResult +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerSessionContext +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerStartJobResult +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobId +import software.aws.toolkits.jetbrains.services.codemodernizer.model.ZipCreationResult +import software.aws.toolkits.jetbrains.services.codemodernizer.plan.CodeModernizerPlanEditorProvider +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeModernizerSessionState +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeTransformTelemetryState +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanSession +import software.aws.toolkits.jetbrains.utils.notifyStickyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodeTransformApiNames +import software.aws.toolkits.telemetry.CodetransformTelemetry +import java.io.File +import java.io.FileInputStream +import java.net.HttpURLConnection +import java.time.Instant +import java.util.Base64 +import java.util.concurrent.CancellationException +import java.util.concurrent.atomic.AtomicBoolean + +const val ZIP_SOURCES_PATH = "sources" +const val UPLOAD_ZIP_MANIFEST_VERSION = 1.0F + +class CodeModernizerSession( + val sessionContext: CodeModernizerSessionContext, + val initialPollingSleepDurationMillis: Long = 2000, + val totalPollingSleepDurationMillis: Long = 5000, +) : Disposable { + private val clientAdaptor = GumbyClient.getInstance(sessionContext.project) + private val state = CodeModernizerSessionState.getInstance(sessionContext.project) + private val isDisposed = AtomicBoolean(false) + private val shouldStop = AtomicBoolean(false) + + /** + * Note that this function makes network calls and needs to be run from a background thread. + * Runs a code modernizer session which comprises the following steps: + * 1. Generate zip file with the files in the selected module + * 2. CreateUploadURL to upload the zip. + * 3. Upload the zip files using the URL. + * 4. Call startMigrationJob to start a migration job + * + * Based on [CodeWhispererCodeScanSession] + */ + fun createModernizationJob(): CodeModernizerStartJobResult { + LOG.warn { "In Create Modernization Job" } + val payload: File? + + try { + if (isDisposed.get()) { + LOG.warn { "Disposed when about to create zip to upload" } + return CodeModernizerStartJobResult.Disposed + } + val startTime = Instant.now() + val result = sessionContext.createZipWithModuleFiles() + payload = when (result) { + is ZipCreationResult.Missing1P -> { + notifyStickyInfo( + message("codemodernizer.notification.info.maven_failed.title"), + message("codemodernizer.notification.info.maven_failed.content") + ) + result.payload + } + + is ZipCreationResult.Succeeded -> result.payload + } + CodetransformTelemetry.jobCreateZipEndTime( + codeTransformTotalByteSize = payload.length().toInt(), + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(startTime, Instant.now()) + ) + } catch (e: Exception) { + LOG.error(e) { e.message.toString() } + CodetransformTelemetry.logGeneralError( + codeTransformApiErrorMessage = e.message.toString(), + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + ) + state.currentJobStatus = TransformationStatus.FAILED + return when (e) { + is CodeModernizerException -> CodeModernizerStartJobResult.ZipCreationFailed(e.message) + else -> CodeModernizerStartJobResult.ZipCreationFailed(message("codemodernizer.notification.warn.zip_creation_failed.reasons.unknown")) + } + } + + return try { + if (shouldStop.get()) { + LOG.warn { "Job was cancelled by user before upload was called" } + return CodeModernizerStartJobResult.Cancelled + } + val uploadId = uploadPayload(payload) + if (shouldStop.get()) { + LOG.warn { "Job was cancelled by user before start job was called" } + return CodeModernizerStartJobResult.Cancelled + } + val startJobResponse = startJob(uploadId) + state.putJobHistory(sessionContext, "STARTED") + state.currentJobStatus = TransformationStatus.STARTED + CodeModernizerStartJobResult.Started(JobId(startJobResponse.transformationJobId())) + } catch (e: AlreadyDisposedException) { + LOG.warn { e.localizedMessage } + return CodeModernizerStartJobResult.Disposed + } catch (e: Exception) { + LOG.warn { e.message.toString() } + state.putJobHistory(sessionContext, "FAILED TO START") + state.currentJobStatus = TransformationStatus.FAILED + CodetransformTelemetry.logGeneralError( + codeTransformApiErrorMessage = e.message.toString(), + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + ) + CodeModernizerStartJobResult.UnableToStartJob(e.message.toString()) + } finally { + deleteUploadArtifact(payload) + } + } + + internal fun deleteUploadArtifact(payload: File) { + if (!payload.delete()) { + LOG.warn { "Unable to delete upload artifact." } + } + } + + private suspend fun awaitModernizationPlan( + jobId: JobId, + jobTransitionHandler: (currentStatus: TransformationStatus, migrationPlan: TransformationPlan?) -> Unit, + ): AwaitModernizationPlanResult { + var passedBuild = false + var passedStart = false + val result = jobId.pollTransformationStatusAndPlan( + succeedOn = STATES_WHERE_PLAN_EXIST, + failOn = STATES_WHERE_JOB_STOPPED_PRE_PLAN_READY, + clientAdaptor, + initialPollingSleepDurationMillis, + totalPollingSleepDurationMillis, + isDisposed, + sessionContext.project, + ) { old, new, plan -> + LOG.warn { "in awaitModernizationPlan, state changed for job $jobId: $old -> $new" } + state.currentJobStatus = new + sessionContext.project.refreshCwQTree() + val instant = Instant.now() + state.updateJobHistory(sessionContext, new.name, instant) + setCurrentJobStopTime(new, instant) + setCurrentJobSummary(new) + jobTransitionHandler(new, plan) + if (!passedStart && new in STATES_AFTER_STARTED) { + passedStart = true + } + if (!passedBuild && new in STATES_AFTER_INITIAL_BUILD) { + passedBuild = true + } + } + return when { + result.succeeded && result.transformationPlan != null -> AwaitModernizationPlanResult.Success(result.transformationPlan) + result.state == TransformationStatus.UNKNOWN_TO_SDK_VERSION -> AwaitModernizationPlanResult.UnknownStatusWhenPolling + !passedStart && result.state == TransformationStatus.FAILED -> AwaitModernizationPlanResult.Failure( + result.jobDetails?.reason() ?: message("codemodernizer.notification.warn.unknown_start_failure") + ) + + !passedBuild && result.state == TransformationStatus.FAILED -> AwaitModernizationPlanResult.BuildFailed( + result.jobDetails?.reason() ?: message("codemodernizer.notification.warn.unknown_build_failure") + ) + + else -> AwaitModernizationPlanResult.Failure(message("codemodernizer.notification.warn.unknown_status_response")) + } + } + + private fun startJob(uploadId: String): StartTransformationResponse { + val sourceLanguage = sessionContext.sourceJavaVersion.name.toTransformationLanguage() + val targetLanguage = sessionContext.targetJavaVersion.name.toTransformationLanguage() + if (sourceLanguage == TransformationLanguage.UNKNOWN_TO_SDK_VERSION) { + throw RuntimeException("Source language is not supported") + } + if (targetLanguage == TransformationLanguage.UNKNOWN_TO_SDK_VERSION) { + throw RuntimeException("Target language is not supported") + } + LOG.warn { "Starting job with uploadId $uploadId for $sourceLanguage -> $targetLanguage" } + val apiStartTime = Instant.now() + try { + val startTransformResult = clientAdaptor.startCodeModernization(uploadId, sourceLanguage, targetLanguage) + LOG.warn { "Started job with uploadId $uploadId" } + CodetransformTelemetry.logApiLatency( + codeTransformApiNames = CodeTransformApiNames.StartTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(apiStartTime, Instant.now()), + codeTransformUploadId = startTransformResult.transformationJobId(), + codeTransformRequestId = startTransformResult.responseMetadata().requestId() + ) + return startTransformResult + } catch (e: Exception) { + CodetransformTelemetry.logApiError( + codeTransformApiNames = CodeTransformApiNames.StartTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformApiErrorMessage = e.message.toString(), + ) + throw e // pass along error to callee + } + } + + /** + * Will perform a single call, checking if a modernization job is finished. + */ + fun getJobDetails(jobId: JobId): TransformationJob { + LOG.warn { "In isJobFinished " } + val apiStartTime = Instant.now() + val transformationResponse = try { + clientAdaptor.getCodeModernizationJob(jobId.id) + } catch (e: Exception) { + CodetransformTelemetry.logApiError( + codeTransformApiErrorMessage = e.message.toString(), + codeTransformApiNames = CodeTransformApiNames.GetTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = jobId.id, + ) + throw e // pass along error to callee + } + + CodetransformTelemetry.logApiLatency( + codeTransformApiNames = CodeTransformApiNames.GetTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(apiStartTime, Instant.now()), + codeTransformJobId = jobId.id, + codeTransformRequestId = transformationResponse.responseMetadata().requestId(), + ) + return transformationResponse.transformationJob() + } + + /** + * This will resume the job, i.e. it will resume the main job loop kicked of by [createModernizationJob] + */ + fun resumeJob(startTime: Instant) = state.putJobHistory(sessionContext, "Started", startTime) + + /* + * Adapted from [CodeWhispererCodeScanSession] + */ + fun uploadArtifactToS3(url: String, fileToUpload: File, checksum: String, kmsArn: String) { + HttpRequests.put(url, APPLICATION_ZIP).userAgent(AwsClientManager.userAgent).tuner { + it.setRequestProperty(CONTENT_SHA256, checksum) + if (kmsArn.isNotEmpty()) { + it.setRequestProperty(CodeWhispererCodeScanSession.SERVER_SIDE_ENCRYPTION, CodeWhispererCodeScanSession.AWS_KMS) + it.setRequestProperty(CodeWhispererCodeScanSession.SERVER_SIDE_ENCRYPTION_AWS_KMS_KEY_ID, kmsArn) + } + } + .connect { request -> + val connection = request.connection as HttpURLConnection + connection.setFixedLengthStreamingMode(fileToUpload.length()) + fileToUpload.inputStream().use { inputStream -> + connection.outputStream.use { + val bufferSize = 4096 + val array = ByteArray(bufferSize) + var n = inputStream.readNBytes(array, 0, bufferSize) + while (0 != n) { + if (shouldStop.get()) break + it.write(array, 0, n) + n = inputStream.readNBytes(array, 0, bufferSize) + } + } + } + } + } + + /** + * Adapted from [CodeWhispererCodeScanSession] + */ + fun uploadPayload(payload: File): String { + val sha256checksum: String = Base64.getEncoder().encodeToString(DigestUtils.sha256(FileInputStream(payload))) + LOG.warn { "About to create an upload url" } + if (isDisposed.get()) { + throw AlreadyDisposedException("Disposed when about to create upload URL") + } + val clientAdaptor = GumbyClient.getInstance(sessionContext.project) + val createUploadStartTime = Instant.now() + val createUploadUrlResponse = try { + clientAdaptor.createGumbyUploadUrl(sha256checksum) + } catch (e: Exception) { + CodetransformTelemetry.logApiError( + codeTransformApiErrorMessage = e.message.toString(), + codeTransformApiNames = CodeTransformApiNames.CreateUploadUrl, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + ) + throw e // pass along error to callee + } + CodetransformTelemetry.logApiLatency( + codeTransformApiNames = CodeTransformApiNames.CreateUploadUrl, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(createUploadStartTime, Instant.now()), + codeTransformUploadId = createUploadUrlResponse.uploadId() + ) + + LOG.warn { + "Uploading zip with checksum $sha256checksum using uploadId: ${ + createUploadUrlResponse.uploadId() + } and size ${(payload.length() / 1000).toInt()}kB" + } + if (isDisposed.get()) { + throw AlreadyDisposedException("Disposed when about to upload zip to s3") + } + val uploadStartTime = Instant.now() + try { + uploadArtifactToS3(createUploadUrlResponse.uploadUrl(), payload, sha256checksum, createUploadUrlResponse.kmsKeyArn().orEmpty()) + } catch (e: Exception) { + CodetransformTelemetry.logApiError( + codeTransformApiErrorMessage = e.message.toString(), + codeTransformApiNames = CodeTransformApiNames.UploadZip, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + ) + throw e // pass along error to callee + } + if (!shouldStop.get()) { + CodetransformTelemetry.logApiLatency( + codeTransformApiNames = CodeTransformApiNames.UploadZip, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(uploadStartTime, Instant.now()), + codeTransformTotalByteSize = payload.length().toInt() + ) + LOG.warn { "Upload complete" } + } + return createUploadUrlResponse.uploadId() + } + + suspend fun pollUntilJobCompletion( + jobId: JobId, + jobTransitionHandler: (currentStatus: TransformationStatus, migrationPlan: TransformationPlan?) -> Unit, + ): CodeModernizerJobCompletedResult { + try { + state.currentJobId = jobId + val apiStartTime = Instant.now() + try { + // add delay to avoid the throttling error + delay(1000) + val modernizationResult = clientAdaptor.getCodeModernizationJob(jobId.id) + state.currentJobCreationTime = modernizationResult.transformationJob().creationTime() + CodetransformTelemetry.logApiLatency( + codeTransformApiNames = CodeTransformApiNames.GetTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(apiStartTime, Instant.now()), + codeTransformJobId = jobId.id, + codeTransformRequestId = modernizationResult.responseMetadata().requestId() + ) + } catch (e: Exception) { + CodetransformTelemetry.logApiError( + codeTransformApiNames = CodeTransformApiNames.GetTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = jobId.id, + codeTransformApiErrorMessage = e.message.toString() + ) + throw e // pass along the error to the parent callee + } + + val modernizationPlan = when (val result = awaitModernizationPlan(jobId, jobTransitionHandler)) { + is AwaitModernizationPlanResult.Success -> result.plan + is AwaitModernizationPlanResult.BuildFailed -> return CodeModernizerJobCompletedResult.JobFailedInitialBuild(jobId, result.failureReason) + is AwaitModernizationPlanResult.Failure -> return CodeModernizerJobCompletedResult.JobFailed( + jobId, + result.failureReason, + ) + + is AwaitModernizationPlanResult.UnknownStatusWhenPolling -> return CodeModernizerJobCompletedResult.JobFailed( + jobId, + message("codemodernizer.notification.warn.unknown_status_response"), + ) + } + + state.transformationPlan = modernizationPlan + tryOpenTransformationPlanEditor() + + var isPartialSuccess = false + val result = jobId.pollTransformationStatusAndPlan( + succeedOn = setOf( + TransformationStatus.COMPLETED, + TransformationStatus.STOPPING, + TransformationStatus.STOPPED, + TransformationStatus.PARTIALLY_COMPLETED, + ), + failOn = setOf( + TransformationStatus.FAILED, + TransformationStatus.UNKNOWN_TO_SDK_VERSION, + ), + clientAdaptor, + initialPollingSleepDurationMillis, + totalPollingSleepDurationMillis, + isDisposed, + sessionContext.project, + ) { old, new, plan -> + // Always refresh the dev tool tree so status will be up-to-date + state.currentJobStatus = new + state.transformationPlan = plan + sessionContext.project.refreshCwQTree() + if (new == TransformationStatus.PARTIALLY_COMPLETED) { + isPartialSuccess = true + } + val instant = Instant.now() + state.updateJobHistory(sessionContext, new.name, instant) + setCurrentJobStopTime(new, instant) + jobTransitionHandler(new, plan) + LOG.warn { "in awaitJobCompletion, state changed for job $jobId: $old -> $new" } + } + return when { + isPartialSuccess -> CodeModernizerJobCompletedResult.JobPartiallySucceeded(jobId, sessionContext.targetJavaVersion) + result.succeeded -> CodeModernizerJobCompletedResult.JobCompletedSuccessfully(jobId) + result.state == TransformationStatus.UNKNOWN_TO_SDK_VERSION -> CodeModernizerJobCompletedResult.JobFailed( + jobId, + message("codemodernizer.notification.warn.unknown_status_response") + ) + + else -> CodeModernizerJobCompletedResult.JobFailed(jobId, result.jobDetails?.reason()) + } + } catch (e: Exception) { + return when (e) { + is AlreadyDisposedException, is CancellationException -> { + LOG.warn { "The session was disposed while polling for job details." } + CodeModernizerJobCompletedResult.ManagerDisposed + } + + else -> { + LOG.warn(e) { e.message.toString() } + CodeModernizerJobCompletedResult.RetryableFailure( + jobId, + message("codemodernizer.notification.info.modernize_failed.connection_failed", e.localizedMessage), + ) + } + } + } + } + + fun stopTransformation(transformationId: String?): Boolean { + shouldStop.set(true) // allows the zipping and upload to cancel + return if (transformationId != null) { + // Means job exists in backend and we have to call the stop api + try { + val apiStartTime = Instant.now() + val result = clientAdaptor.stopTransformation(transformationId) + CodetransformTelemetry.logApiLatency( + codeTransformApiNames = CodeTransformApiNames.StopTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(apiStartTime, Instant.now()), + codeTransformJobId = transformationId, + codeTransformRequestId = result.responseMetadata().requestId() + ) + return true + } catch (e: Exception) { + CodetransformTelemetry.logApiError( + codeTransformApiNames = CodeTransformApiNames.StopTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformApiErrorMessage = e.message.toString(), + codeTransformJobId = transformationId, + ) + throw e // pass along error to callee + } + } else { + true // We did not yet call the start API so no need to call the stop job api + } + } + + fun getTransformationPlan(): TransformationPlan? = state.transformationPlan + + fun setCurrentJobStopTime(status: TransformationStatus, instant: Instant) { + if (status in setOf( + TransformationStatus.COMPLETED, // successfully transformed + TransformationStatus.STOPPED, // manually stopped, + TransformationStatus.PARTIALLY_COMPLETED, // partially successfully transformed + TransformationStatus.FAILED, // unable to generate transformation plan + TransformationStatus.UNKNOWN_TO_SDK_VERSION, + ) + ) { + state.currentJobStopTime = instant + } + } + + private fun setCurrentJobSummary(status: TransformationStatus) { + state.transformationSummary ?: return + if (status in setOf( + TransformationStatus.COMPLETED, + TransformationStatus.STOPPED, + TransformationStatus.PARTIALLY_COMPLETED, + TransformationStatus.FAILED, + ) + ) { + // val response = TODO() + val summary = TransformationSummary( + """ + # Transformation summary + + This is pretty + and now it has been updated from api call... + """.trimIndent() + ) + state.transformationSummary = summary + } + } + + fun tryOpenTransformationPlanEditor() { + val transformationPlan = getTransformationPlan() + if (transformationPlan != null) { + runInEdt { + CodeModernizerPlanEditorProvider.openEditor( + sessionContext.project, + transformationPlan, + sessionContext.project.getModuleOrProjectNameForFile(sessionContext.configurationFile), + sessionContext.sourceJavaVersion.description, + ) + } + } + } + + companion object { + private val LOG = getLogger() + const val APPLICATION_ZIP = "application/zip" + const val CONTENT_SHA256 = "x-amz-checksum-sha256" + } + + override fun dispose() { + isDisposed.set(true) + } + + fun getActiveJobId() = state.currentJobId + fun fetchPlan(lastJobId: JobId) = clientAdaptor.getCodeModernizationPlan(lastJobId) + + fun didJobStart() = state.currentJobId != null +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerStartupActivity.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerStartupActivity.kt new file mode 100644 index 0000000000..a096e1ac8e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeModernizerStartupActivity.kt @@ -0,0 +1,19 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer + +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity + +class CodeModernizerStartupActivity : StartupActivity.DumbAware { + + /** + * Will be run on startup of the IDE + * Prompts users of jobs that finished while IDE was closed. + */ + override fun runActivity(project: Project) { + if (!isCodeModernizerAvailable(project)) return + CodeModernizerManager.getInstance(project).tryResumeJob() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeTransformProjectStartupSettingListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeTransformProjectStartupSettingListener.kt new file mode 100644 index 0000000000..ff6b169c13 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/CodeTransformProjectStartupSettingListener.kt @@ -0,0 +1,31 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ex.ToolWindowManagerListener +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.managers.CodeModernizerBottomWindowPanelManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererActivationChangedListener + +class CodeTransformProjectStartupSettingListener(private val project: Project) : + CodeWhispererActivationChangedListener, + ToolWindowManagerListener, + ToolkitConnectionManagerListener, + BearerTokenProviderListener { + + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + runInEdt { + val isAvailable = isCodeModernizerAvailable(project) + CodeModernizerBottomWindowPanelManager.getInstance(project).toolWindow?.isAvailable = isAvailable + CodeModernizerManager.getInstance(project).handleCredentialsChanged() + if (isAvailable) { + CodeModernizerManager.getInstance(project).tryResumeJob() + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ModuleUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ModuleUtils.kt new file mode 100644 index 0000000000..27c0bbf129 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ModuleUtils.kt @@ -0,0 +1,37 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer + +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.openapi.projectRoots.impl.JavaSdkImpl +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.vfs.VirtualFile + +fun Module.tryGetJdk(project: Project): JavaSdkVersion? { + val sdk = ModuleRootManager.getInstance(this).sdk ?: ProjectRootManager.getInstance(project).projectSdk ?: return null + val javaSdk = JavaSdkImpl.getInstance() + return javaSdk.getVersion(sdk) +} + +fun Project.getSupportedJavaMappingsForProject(supportedJavaMappings: Map>): List { + val projectSdk = ProjectRootManager.getInstance(this).projectSdk + val javaSdk = JavaSdkImpl.getInstance() + return if (projectSdk == null) { + listOf() + } else { + supportedJavaMappings.getOrDefault(javaSdk.getVersion(projectSdk), listOf()).map { it.name }.toList() + } +} + +fun Project.tryGetJdk(): JavaSdkVersion? { + val projectSdk = ProjectRootManager.getInstance(this).projectSdk + val javaSdk = JavaSdkImpl.getInstance() + return javaSdk.getVersion(projectSdk ?: return null) +} + +fun Project.getModuleOrProjectNameForFile(file: VirtualFile) = ModuleUtil.findModuleForFile(file, this)?.name ?: this.name diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/TransformationSummary.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/TransformationSummary.kt new file mode 100644 index 0000000000..e4a2a6f099 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/TransformationSummary.kt @@ -0,0 +1,6 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer + +data class TransformationSummary(val content: String) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/Utils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/Utils.kt new file mode 100644 index 0000000000..86c80cf859 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/Utils.kt @@ -0,0 +1,287 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer + +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.serviceContainer.AlreadyDisposedException +import org.jetbrains.plugins.gradle.settings.GradleSettings +import software.amazon.awssdk.awscore.exception.AwsServiceException +import software.amazon.awssdk.core.exception.SdkClientException +import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException +import software.amazon.awssdk.services.codewhispererruntime.model.GetTransformationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.InternalServerException +import software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationJob +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationLanguage +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStatus +import software.amazon.awssdk.services.codewhispererruntime.model.ValidationException +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.utils.WaiterUnrecoverableException +import software.aws.toolkits.core.utils.Waiters.waitUntil +import software.aws.toolkits.core.utils.createParentDirectories +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnection +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnectionType +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.checkBearerConnectionValidity +import software.aws.toolkits.jetbrains.services.codemodernizer.client.GumbyClient +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobId +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeTransformTelemetryState +import software.aws.toolkits.telemetry.CodeTransformApiNames +import software.aws.toolkits.telemetry.CodetransformTelemetry +import java.io.FileOutputStream +import java.lang.Thread.sleep +import java.nio.file.Path +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +import java.util.zip.ZipFile +import kotlin.io.path.Path + +val STATES_WHERE_PLAN_EXIST = setOf( + TransformationStatus.PLANNED, + TransformationStatus.TRANSFORMING, + TransformationStatus.TRANSFORMED, + TransformationStatus.PARTIALLY_COMPLETED, + TransformationStatus.COMPLETED, +) + +val STATES_AFTER_INITIAL_BUILD = setOf( + TransformationStatus.PREPARED, + TransformationStatus.PLANNING, + *STATES_WHERE_PLAN_EXIST.toTypedArray() +) + +val STATES_AFTER_STARTED = setOf( + TransformationStatus.STARTED, + TransformationStatus.PREPARING, + *STATES_AFTER_INITIAL_BUILD.toTypedArray(), +) + +val STATES_WHERE_JOB_STOPPED_PRE_PLAN_READY = setOf( + TransformationStatus.FAILED, + TransformationStatus.STOPPED, + TransformationStatus.STOPPING, + TransformationStatus.REJECTED, + TransformationStatus.UNKNOWN_TO_SDK_VERSION, +) + +val TERMINAL_STATES = setOf( + TransformationStatus.FAILED, + TransformationStatus.STOPPED, + TransformationStatus.REJECTED, + TransformationStatus.PARTIALLY_COMPLETED, + TransformationStatus.COMPLETED, +) + +fun String.toVirtualFile() = VirtualFileManager.getInstance().findFileByUrl(VfsUtilCore.pathToUrl(this)) +fun Project.moduleFor(path: String) = ModuleUtil.findModuleForFile( + path.toVirtualFile() ?: throw RuntimeException("File not found $path"), + this, +) + +/** + * Unzips a zip into a dir. Returns the true when successfully unzips the file pointed to by [zipFilePath] to [destDir] + */ +fun unzipFile(zipFilePath: Path, destDir: Path): Boolean { + if (!zipFilePath.exists()) return false + val zipFile = ZipFile(zipFilePath.toFile()) + zipFile.use { file -> + file.entries().asSequence() + .filterNot { it.isDirectory } + .map { zipEntry -> + val destPath = destDir.resolve(zipEntry.name) + destPath.createParentDirectories() + FileOutputStream(destPath.toFile()).use { targetFile -> + zipFile.getInputStream(zipEntry).copyTo(targetFile) + } + }.toList() + } + return true +} + +fun String.toTransformationLanguage() = when (this) { + "JDK_1_8" -> TransformationLanguage.JAVA_8 + "JDK_11" -> TransformationLanguage.JAVA_11 + "JDK_17" -> TransformationLanguage.JAVA_17 + else -> TransformationLanguage.UNKNOWN_TO_SDK_VERSION +} + +fun calculateTotalLatency(startTime: Instant, endTime: Instant) = (endTime.toEpochMilli() - startTime.toEpochMilli()).toInt() + +data class PollingResult( + val succeeded: Boolean, + val jobDetails: TransformationJob?, + val state: TransformationStatus, + val transformationPlan: TransformationPlan? +) + +fun refreshToken(project: Project) { + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) + val provider = (connection?.getConnectionSettings() as TokenConnectionSettings).tokenProvider.delegate as BearerTokenProvider + provider.refresh() +} + +/** + * Wrapper around [waitUntil] that polls the API DescribeMigrationJob to check the migration job status. + */ +suspend fun JobId.pollTransformationStatusAndPlan( + succeedOn: Set, + failOn: Set, + clientAdaptor: GumbyClient, + initialSleepDurationMillis: Long, + sleepDurationMillis: Long, + isDisposed: AtomicBoolean, + project: Project, + maxDuration: Duration = Duration.ofSeconds(604800), + onStateChange: (previousStatus: TransformationStatus?, currentStatus: TransformationStatus, transformationPlan: TransformationPlan?) -> Unit, +): PollingResult { + var state = TransformationStatus.UNKNOWN_TO_SDK_VERSION + var transformationResponse: GetTransformationResponse? = null + var transformationPlan: TransformationPlan? = null + var didSleepOnce = false + val maxRefreshes = 10 + var numRefreshes = 0 + refreshToken(project) + + try { + waitUntil( + succeedOn = { state in succeedOn }, + failOn = { state in failOn }, + maxDuration = maxDuration, + exceptionsToStopOn = setOf( + InternalServerException::class, + ValidationException::class, + AccessDeniedException::class, + AwsServiceException::class, + SdkClientException::class, + CodeWhispererRuntimeException::class, + RuntimeException::class, + ), + exceptionsToIgnore = setOf(ThrottlingException::class) + ) { + try { + if (!didSleepOnce) { + sleep(initialSleepDurationMillis) + didSleepOnce = true + } + if (isDisposed.get()) throw AlreadyDisposedException("The invoker is disposed.") + val apiStartTime = Instant.now() + try { + transformationResponse = clientAdaptor.getCodeModernizationJob(this.id) + CodetransformTelemetry.logApiLatency( + codeTransformApiNames = CodeTransformApiNames.GetTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(apiStartTime, Instant.now()), + codeTransformJobId = this.id, + codeTransformRequestId = transformationResponse?.responseMetadata()?.requestId() + ) + } catch (e: Exception) { + CodetransformTelemetry.logApiError( + codeTransformApiErrorMessage = e.message.toString(), + codeTransformApiNames = CodeTransformApiNames.GetTransformation, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = this.id, + ) + throw e // pass along error to callee + } + val newStatus = transformationResponse?.transformationJob()?.status() ?: throw RuntimeException("Unable to get job status") + var newPlan: TransformationPlan? = null + if (newStatus in STATES_WHERE_PLAN_EXIST) { + sleep(sleepDurationMillis) + val transformationPlanApiStartTime = Instant.now() + try { + newPlan = clientAdaptor.getCodeModernizationPlan(this).transformationPlan() + } catch (e: Exception) { + CodetransformTelemetry.logApiError( + codeTransformApiErrorMessage = e.message.toString(), + codeTransformApiNames = CodeTransformApiNames.GetTransformationPlan, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = this.id + ) + throw e // pass along error to callee + } finally { + CodetransformTelemetry.logApiLatency( + codeTransformApiNames = CodeTransformApiNames.GetTransformationPlan, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformRunTimeLatency = calculateTotalLatency(transformationPlanApiStartTime, Instant.now()), + codeTransformJobId = this.id + ) + } + } + if (newStatus != state) { + CodetransformTelemetry.jobStatusChanged( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = this.id, + codeTransformStatus = newStatus.toString() + ) + } + if (newPlan != transformationPlan) { + CodetransformTelemetry.jobStatusChanged( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformJobId = this.id, + codeTransformStatus = "PLAN_UPDATED" + ) + } + if (newStatus != state || newPlan != transformationPlan) { + transformationPlan = newPlan + onStateChange(state, newStatus, transformationPlan) + } + state = newStatus + numRefreshes = 0 + } catch (e: AccessDeniedException) { + if (numRefreshes++ > maxRefreshes) throw e + refreshToken(project) + } finally { + sleep(sleepDurationMillis) + } + } + } catch (e: WaiterUnrecoverableException) { + return PollingResult(false, transformationResponse?.transformationJob(), state, transformationPlan) + } + return PollingResult(true, transformationResponse?.transformationJob(), state, transformationPlan) +} + +fun filterOnlyParentFiles(filePaths: Set): List { + if (filePaths.isEmpty()) return listOf() + // sorts it like: + // foo + // foo/bar + // foo/bar/bas + val sorted = filePaths.sortedBy { Path(it.path).nameCount } + val uniquePrefixes = mutableSetOf(Path(sorted.first().path).parent) + val shortestRoots = mutableSetOf(sorted.first()) + shortestRoots.add(sorted.first()) + sorted.drop(1).forEach { file -> + if (uniquePrefixes.none { Path(file.path).startsWith(it) }) { + shortestRoots.add(file) + uniquePrefixes.add(Path(file.path).parent) + } else if (Path(file.path).parent in uniquePrefixes) { + shortestRoots.add(file) // handles multiple parent files on the same level + } + } + return shortestRoots.toList() +} + +fun isIntellij(): Boolean { + val productCode = ApplicationInfo.getInstance().build.productCode + return productCode == "IC" || productCode == "IU" +} + +fun isCodeModernizerAvailable(project: Project): Boolean { + if (!isIntellij()) return false + val connection = checkBearerConnectionValidity(project, BearerTokenFeatureSet.Q) + return connection.connectionType == ActiveConnectionType.IAM_IDC && connection is ActiveConnection.ValidBearer +} + +fun isGradleProject(project: Project) = !GradleSettings.getInstance(project).linkedProjectsSettings.isEmpty() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowActiveJobDetailsAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowActiveJobDetailsAction.kt new file mode 100644 index 0000000000..5ea330a618 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowActiveJobDetailsAction.kt @@ -0,0 +1,28 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.actions + +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.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.resources.message + +class CodeModernizerShowActiveJobDetailsAction : + AnAction( + message("codemodernizer.explorer.show_active_job_history"), + message("codemodernizer.explorer.show_active_job_history_description"), + AllIcons.Vcs.History + ), + DumbAware { + override fun update(event: AnActionEvent) { + event.presentation.icon = AllIcons.Vcs.History + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + CodeModernizerManager.getInstance(project).showModernizationProgressUI() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowJobHistoryAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowJobHistoryAction.kt new file mode 100644 index 0000000000..d300cebd31 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowJobHistoryAction.kt @@ -0,0 +1,30 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.actions + +import com.intellij.icons.AllIcons +import com.intellij.icons.AllIcons.Vcs.History +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.resources.message + +class CodeModernizerShowJobHistoryAction : + AnAction( + message("codemodernizer.explorer.show_job_history"), + message("codemodernizer.explorer.show_job_history_description"), + History + ), + DumbAware { + override fun update(event: AnActionEvent) { + event.presentation.icon = AllIcons.Vcs.Changelist + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + val codeModernizerManager = CodeModernizerManager.getInstance(project) + codeModernizerManager.showPreviousJobHistoryUI() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowTransformationPlanAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowTransformationPlanAction.kt new file mode 100644 index 0000000000..4063404541 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowTransformationPlanAction.kt @@ -0,0 +1,31 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.actions + +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.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.resources.message + +class CodeModernizerShowTransformationPlanAction : + AnAction( + message("codemodernizer.explorer.show_transformation_plan_title"), + null, + AllIcons.Actions.Annotate + ), + DumbAware { + override fun update(event: AnActionEvent) { + val project = event.project ?: return + val codeModernizerManager = CodeModernizerManager.getInstance(project) + event.presentation.isEnabled = codeModernizerManager.getTransformationPlan() != null + event.presentation.icon = AllIcons.Actions.Annotate + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + CodeModernizerManager.getInstance(project).showTransformationPlan() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowTransformationSummaryAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowTransformationSummaryAction.kt new file mode 100644 index 0000000000..aa919ad8cc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerShowTransformationSummaryAction.kt @@ -0,0 +1,31 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.actions + +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.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.resources.message + +class CodeModernizerShowTransformationSummaryAction : + AnAction( + message("codemodernizer.explorer.show_transformation_summary_title"), + null, + AllIcons.Actions.Annotate + ), + DumbAware { + override fun update(event: AnActionEvent) { + val project = event.project ?: return + val codeModernizerManager = CodeModernizerManager.getInstance(project) + event.presentation.isEnabled = codeModernizerManager.getTransformationSummary() != null + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + val codeModernizerManager = CodeModernizerManager.getInstance(project) + codeModernizerManager.showTransformationSummary() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerStartModernizerAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerStartModernizerAction.kt new file mode 100644 index 0000000000..ff209ff2da --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerStartModernizerAction.kt @@ -0,0 +1,31 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.actions + +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.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodeTransformStartSrcComponents + +class CodeModernizerStartModernizerAction : + AnAction( + message("codemodernizer.explorer.start_migration_job"), + null, + AllIcons.Actions.Execute + ), + DumbAware { + override fun update(event: AnActionEvent) { + val project = event.project ?: return + val codeModernizerManager = CodeModernizerManager.getInstance(project) + event.presentation.isEnabledAndVisible = !codeModernizerManager.isModernizationJobActive() + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + CodeModernizerManager.getInstance(project).validateAndStart(CodeTransformStartSrcComponents.BottomPanelSideNavButton) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerStopModernizerAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerStopModernizerAction.kt new file mode 100644 index 0000000000..475932a869 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/actions/CodeModernizerStopModernizerAction.kt @@ -0,0 +1,37 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.actions + +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.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeTransformTelemetryState +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodeTransformCancelSrcComponents +import software.aws.toolkits.telemetry.CodetransformTelemetry + +class CodeModernizerStopModernizerAction : + AnAction( + message("codemodernizer.explorer.stop_migration_job"), + null, + AllIcons.Actions.Suspend + ), + DumbAware { + override fun update(event: AnActionEvent) { + val project = event.project ?: return + val codeModernizerManager = CodeModernizerManager.getInstance(project) + event.presentation.isEnabledAndVisible = codeModernizerManager.isModernizationJobActive() + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + CodeModernizerManager.getInstance(project).userInitiatedStopCodeModernization() + CodetransformTelemetry.jobIsCancelledByUser( + codeTransformCancelSrcComponents = CodeTransformCancelSrcComponents.BottomPanelSideNavButton, + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId() + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt new file mode 100644 index 0000000000..139498e5b9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/client/GumbyClient.kt @@ -0,0 +1,105 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.client + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.future.await +import software.amazon.awssdk.services.codewhispererruntime.CodeWhispererRuntimeClient +import software.amazon.awssdk.services.codewhispererruntime.model.ContentChecksumType +import software.amazon.awssdk.services.codewhispererruntime.model.CreateUploadUrlResponse +import software.amazon.awssdk.services.codewhispererruntime.model.GetTransformationPlanResponse +import software.amazon.awssdk.services.codewhispererruntime.model.GetTransformationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.StartTransformationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.StopTransformationResponse +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationLanguage +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationType +import software.amazon.awssdk.services.codewhispererruntime.model.UploadIntent +import software.amazon.awssdk.services.codewhispererstreaming.CodeWhispererStreamingAsyncClient +import software.amazon.awssdk.services.codewhispererstreaming.model.ExportIntent +import software.amazon.awssdk.services.codewhispererstreaming.model.ExportResultArchiveRequest +import software.amazon.awssdk.services.codewhispererstreaming.model.ExportResultArchiveResponseHandler +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobId +import java.util.concurrent.atomic.AtomicReference + +@Service(Service.Level.PROJECT) +class GumbyClient(private val project: Project) { + private fun connection() = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) + ?: error("Attempted to use connection while one does not exist") + + private fun bearerClient() = connection().getConnectionSettings().awsClient() + private fun streamingBearerClient() = connection().getConnectionSettings().awsClient() + + fun createGumbyUploadUrl(sha256Checksum: String): CreateUploadUrlResponse = bearerClient().createUploadUrl { + it.contentChecksumType(ContentChecksumType.SHA_256) + .contentChecksum(sha256Checksum) + .uploadIntent(UploadIntent.TRANSFORMATION) + } + + fun getCodeModernizationJob(jobId: String): GetTransformationResponse = bearerClient().getTransformation { + it.transformationJobId(jobId) + } + + fun startCodeModernization( + uploadId: String, + sourceLanguage: TransformationLanguage, + targetLanguage: TransformationLanguage + ): StartTransformationResponse = bearerClient().startTransformation { request -> + request.workspaceState { state -> + state.programmingLanguage { it.languageName("java") } + .uploadId(uploadId) + } + request.transformationSpec { spec -> + spec.transformationType(TransformationType.LANGUAGE_UPGRADE) + .source { it.language(sourceLanguage) } + .target { it.language(targetLanguage) } + } + } + + fun getCodeModernizationPlan(jobId: JobId): GetTransformationPlanResponse = bearerClient().getTransformationPlan { + it.transformationJobId(jobId.id) + } + + fun stopTransformation(transformationId: String): StopTransformationResponse = bearerClient().stopTransformation { + it.transformationJobId(transformationId) + } + + suspend fun downloadExportResultArchive(jobId: JobId): MutableList { + val byteBufferList = mutableListOf() + val checksum = AtomicReference("") + val result = streamingBearerClient().exportResultArchive( + ExportResultArchiveRequest.builder() + .exportId(jobId.id) + .exportIntent(ExportIntent.TRANSFORMATION) + .build(), + ExportResultArchiveResponseHandler.builder().subscriber( + ExportResultArchiveResponseHandler.Visitor.builder() + .onBinaryMetadataEvent { + checksum.set(it.contentChecksum()) + }.onBinaryPayloadEvent { + val payloadBytes = it.bytes().asByteArray() + byteBufferList.add(payloadBytes) + }.onDefault { + LOG.warn { "Received unknown payload stream: $it" } + } + .build() + ) + .build() + ) + result.await() + return byteBufferList + } + + companion object { + private val LOG = getLogger() + + fun getInstance(project: Project) = project.service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/constants/CodeModernizerUIConstants.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/constants/CodeModernizerUIConstants.kt new file mode 100644 index 0000000000..3ecf503e5f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/constants/CodeModernizerUIConstants.kt @@ -0,0 +1,104 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.constants + +import com.intellij.ui.JBColor +import icons.AwsIcons +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil +import java.awt.Color +import java.awt.GridBagConstraints +import javax.swing.BorderFactory + +class CodeModernizerUIConstants { + + object HEADER { + const val PADDING_TOP = 7 + const val PADDING_RIGHT = 14 + const val PADDING_BOTTOM = 7 + const val PADDING_LEFT = 14 + const val FONT_SIZE = 14.0f + } + + object SCROLL_PANEL { + const val PADDING_TOP = 7 + const val PADDING_RIGHT = 14 + const val PADDING_BOTTOM = 7 + const val PADDING_LEFT = 14 + } + + object PLAN_CONSTRAINTS { + const val PLAN_PADDING_TOP = 50 + const val PLAN_PADDING_LEFT = 50 + const val PLAN_PADDING_BOTTOM = 50 + const val PLAN_PADDING_RIGHT = 50 + + const val TITLE_FONT_SIZE = 24f + const val TRANSFORMATION_STEP_TITLE_FONT_SIZE = 14f + const val STEP_FONT_SIZE = 14f + + const val NAME_PADDING_TOP = 10 + const val NAME_PADDING_LEFT = 10 + const val NAME_PADDING_BOTTOM = 10 + const val NAME_PADDING_RIGHT = 10 + + const val DESCRP_PADDING_TOP = 0 + const val DESCRP_PADDING_LEFT = 10 + const val DESCRP_PADDING_BOTTOM = 10 + const val DESCRP_PADDING_RIGHT = 10 + } + + object FONT_CONSTRAINTS { + const val BOLD = 1 + const val ITALIC = 2 + } + + companion object { + const val SINGLE_SPACE_STRING: String = " " + const val EMPTY_SPACE_STRING: String = "" + val transformationPlanPlaneConstraint = GridBagConstraints().apply { + gridx = 0 + weightx = 1.0 + weighty = 0.0 + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.NORTH + } + val TRANSFORMATION_PLAN_PANEL_BORDER = BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(10, 10, 10, 10), + BorderFactory.createLineBorder(CodeWhispererColorUtil.POPUP_BUTTON_BORDER, 1, true) + + ) + val STEP_INTRO_BORDER = BorderFactory.createEmptyBorder(10, 10, 10, 10) + val STEP_INTRO_TITLE_BORDER = BorderFactory.createEmptyBorder(0, 0, 5, 0) + val TRANSFORMATION_STEP_PANEL_COMPOUND_BORDER = BorderFactory.createCompoundBorder( + BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(0, 20, 20, 20), + BorderFactory.createLineBorder(Color.GRAY, 1, true) + ), + BorderFactory.createEmptyBorder(5, 5, 5, 5) + ) + val TRANSFORMATION_STEPS_INFO_BORDER = BorderFactory.createCompoundBorder( + BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(0, 10, 0, 0), + BorderFactory.createLineBorder(Color.GRAY, 1, true) + ), + BorderFactory.createEmptyBorder(10, 10, 10, 10) + ) + val TRANSFORMATION_STEPS_INFO_AWSQ_BORDER = BorderFactory.createCompoundBorder( + BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(0, 5, 0, 5), + BorderFactory.createLineBorder(Color.GRAY, 1, true) + ), + BorderFactory.createEmptyBorder(10, 10, 10, 10) + ) + val TRANSOFORMATION_PLAN_INFO_BORDER = BorderFactory.createEmptyBorder(10, 10, 10, 10) + val FILLER_CONSTRAINT = GridBagConstraints().apply { + gridy = 1 + weighty = 1.0 + } + + fun getGreenThemeFontColor(): Color = if (JBColor.isBright()) JBColor.GREEN.darker() else JBColor.GREEN + fun getRedThemeFontColor(): Color = JBColor.RED + fun getStepIcon() = if (JBColor.isBright()) AwsIcons.CodeTransform.TIMELINE_STEP_LIGHT else AwsIcons.CodeTransform.TIMELINE_STEP_DARK + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/explorer/nodes/CodeModernizerRunModernizeNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/explorer/nodes/CodeModernizerRunModernizeNode.kt new file mode 100644 index 0000000000..ea87810d24 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/explorer/nodes/CodeModernizerRunModernizeNode.kt @@ -0,0 +1,59 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.explorer.nodes + +import com.intellij.ide.projectView.PresentationData +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStatus +import software.aws.toolkits.jetbrains.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.jetbrains.services.codemodernizer.constants.CodeModernizerUIConstants +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeModernizerSessionState +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.CodeWhispererActionNode +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodeTransformStartSrcComponents +import software.aws.toolkits.telemetry.UiTelemetry +import java.awt.event.MouseEvent + +const val RUN_NODE_INDEX = 5 +class CodeModernizerRunModernizeNode(private val nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + message("codemodernizer.explorer.start_migration_job"), + RUN_NODE_INDEX, + CodeModernizerManager.getInstance(nodeProject).getRunActionButtonIcon() +) { + private val codeModernizerManager = CodeModernizerManager.getInstance(project) + + override fun onDoubleClick(event: MouseEvent) { + if (!codeModernizerManager.isModernizationJobActive()) { + codeModernizerManager.validateAndStart(CodeTransformStartSrcComponents.DevToolsStartButton) + } else { + codeModernizerManager.stopModernize() + } + UiTelemetry.click(nodeProject, "amazonq_transform") + } + + override fun update(presentation: PresentationData) { + super.update(presentation) + val transformationStatus = when (CodeModernizerSessionState.getInstance(project).currentJobStatus) { + TransformationStatus.CREATED -> message("codemodernizer.manager.job_status.created") + TransformationStatus.ACCEPTED -> message("codemodernizer.manager.job_status.accepted") + TransformationStatus.REJECTED -> message("codemodernizer.manager.job_status.rejected") + TransformationStatus.STARTED -> message("codemodernizer.manager.job_status.started") + TransformationStatus.PREPARING -> message("codemodernizer.manager.job_status.preparing") + TransformationStatus.PREPARED -> message("codemodernizer.manager.job_status.prepared") + TransformationStatus.PLANNING -> message("codemodernizer.manager.job_status.planning") + TransformationStatus.PLANNED -> message("codemodernizer.manager.job_status.planned") + TransformationStatus.TRANSFORMING -> message("codemodernizer.manager.job_status.transforming") + TransformationStatus.TRANSFORMED -> message("codemodernizer.manager.job_status.transformed") + TransformationStatus.FAILED -> message("codemodernizer.manager.job_status.failed") + TransformationStatus.COMPLETED -> message("codemodernizer.manager.job_status.completed") + TransformationStatus.STOPPING -> message("codemodernizer.manager.job_status.stopping") + TransformationStatus.STOPPED -> message("codemodernizer.manager.job_status.stopped") + TransformationStatus.PARTIALLY_COMPLETED -> message("codemodernizer.manager.job_status.partially_completed") + else -> return + } + presentation.addText(CodeModernizerUIConstants.SINGLE_SPACE_STRING + transformationStatus, SimpleTextAttributes.GRAY_ATTRIBUTES) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformMavenRunner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformMavenRunner.kt new file mode 100644 index 0000000000..d3ddcef385 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformMavenRunner.kt @@ -0,0 +1,35 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.ideMaven + +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.runners.ProgramRunner +import com.intellij.execution.ui.RunContentDescriptor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import org.jetbrains.idea.maven.execution.MavenRunConfigurationType +import org.jetbrains.idea.maven.execution.MavenRunnerParameters +import org.jetbrains.idea.maven.execution.MavenRunnerSettings + +class TransformMavenRunner(val project: Project) { + + fun run(parameters: MavenRunnerParameters, settings: MavenRunnerSettings, onComplete: TransformRunnable) { + FileDocumentManager.getInstance().saveAllDocuments() + val callback = ProgramRunner.Callback { descriptor: RunContentDescriptor -> + val handler = descriptor.processHandler + if (handler == null) { + // add log error here + onComplete.exitCode(-1) + return@Callback + } + handler.addProcessListener(object : ProcessAdapter() { + override fun processTerminated(event: ProcessEvent) { + onComplete.exitCode(event.exitCode) + } + }) + } + MavenRunConfigurationType.runConfiguration(project, parameters, null, settings, callback, false) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformRunnable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformRunnable.kt new file mode 100644 index 0000000000..46be8802a9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ideMaven/TransformRunnable.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.ideMaven + +class TransformRunnable : Runnable { + private var isComplete: Int? = null + + fun exitCode(i: Int) { + isComplete = i + } + + override fun run() { + // do nothing + } + + fun isComplete(): Int? = isComplete +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildProgressStepTreeItem.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildProgressStepTreeItem.kt new file mode 100644 index 0000000000..1bc888a6ff --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildProgressStepTreeItem.kt @@ -0,0 +1,22 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +data class BuildProgressStepTreeItem( + val text: String, + var status: BuildStepStatus, + val id: ProgressStepId, + var runtime: String? = null, + var finishedTime: String? = null, + val transformationStepId: Int? = null +) + +enum class ProgressStepId(val order: Int) { + ACCEPTED(1), + BUILDING(2), + PLANNING(3), + TRANSFORMING(4), + PLAN_STEP(5), + ROOT_STEP(99) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildProgressTimelineStepDetailItem.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildProgressTimelineStepDetailItem.kt new file mode 100644 index 0000000000..4dcf6b7e6c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildProgressTimelineStepDetailItem.kt @@ -0,0 +1,12 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +data class BuildProgressTimelineStepDetailItem( + val text: String, + val description: String, + val status: BuildStepStatus, + var runtime: String? = null, + var finishedTime: String? = null +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildProgressTimelineStepDetailsList.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildProgressTimelineStepDetailsList.kt new file mode 100644 index 0000000000..50e3d67283 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildProgressTimelineStepDetailsList.kt @@ -0,0 +1,38 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan + +class BuildProgressTimelineStepDetailsList : ArrayList() { + + // custom add method + fun addWithIndex(element: BuildProgressTimelineStepDetailItem, index: Int) { + add(index, element) + } +} + +fun getTransformationProgressStepsByTransformationStepId( + stepId: Int, + transformationPlan: TransformationPlan? +): BuildProgressTimelineStepDetailsList { + val stepList = BuildProgressTimelineStepDetailsList() + val transformationStep = transformationPlan?.transformationSteps()?.get(stepId - 1) + transformationStep?.progressUpdates()?.let { progressUpdates -> + for (progressStep in progressUpdates) { + if (progressStep != null) { + val itemToAdd = BuildProgressTimelineStepDetailItem( + progressStep.name(), + progressStep.description(), + mapTransformationPlanApiStatus(progressStep.status()), + progressStep.startTime()?.toString(), + progressStep.endTime()?.toString() + ) + stepList.add(itemToAdd) + } + } + } + + return stepList +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildStepStatus.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildStepStatus.kt new file mode 100644 index 0000000000..6068089e99 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/BuildStepStatus.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationProgressUpdateStatus + +enum class BuildStepStatus { + DONE, + ERROR, + WARNING, + WORKING +} + +fun mapTransformationPlanApiStatus(apiStatus: TransformationProgressUpdateStatus): BuildStepStatus = when (apiStatus) { + TransformationProgressUpdateStatus.COMPLETED -> BuildStepStatus.DONE + TransformationProgressUpdateStatus.FAILED -> BuildStepStatus.WARNING + TransformationProgressUpdateStatus.IN_PROGRESS -> BuildStepStatus.WORKING + TransformationProgressUpdateStatus.UNKNOWN_TO_SDK_VERSION -> BuildStepStatus.ERROR +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerArtifact.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerArtifact.kt new file mode 100644 index 0000000000..38ba72bf88 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerArtifact.kt @@ -0,0 +1,107 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.util.io.FileUtil.createTempDirectory +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.io.isDirectory +import software.aws.toolkits.core.utils.error +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.jetbrains.services.codemodernizer.TransformationSummary +import software.aws.toolkits.jetbrains.services.codemodernizer.unzipFile +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.Path +import kotlin.io.path.walk + +/** + * Represents a CodeModernizer artifact. Essentially a wrapper around the manifest file in the downloaded artifact zip. + */ +open class CodeModernizerArtifact( + val zipPath: String, + val manifest: CodeModernizerManifest, + private val patches: List, + val summary: TransformationSummary +) { + val patch: VirtualFile + get() = patches.first() + + companion object { + private const val maxSupportedVersion = 1.0 + private val tempDir = createTempDirectory("codeTransformArtifacts", null) + private const val manifestPathInZip = "manifest.json" + private const val summaryNameInZip = "summary.md" + val LOG = getLogger() + private val MAPPER = jacksonObjectMapper() + + /** + * Extracts the file at [zipPath] and uses its contents to produce a [CodeModernizerArtifact]. + * If anything goes wrong during this process an exception is thrown. + */ + fun create(zipPath: String): CodeModernizerArtifact { + val path = Path(zipPath) + if (path.exists()) { + if (!unzipFile(path, tempDir.toPath())) { + LOG.error { "Could not unzip artifact" } + throw RuntimeException("Could not unzip artifact") + } + val manifest = loadManifest() + if (manifest.version > maxSupportedVersion) { + // If not supported we can still try to use it, i.e. the versions should largely be backwards compatible + // TODO change to notify that user should consider upgrading the toolkit version. + LOG.warn { "Unsupported version: ${manifest.version}" } + } + val patches = extractPatches(manifest) + val summary = extractSummary(manifest) + if (patches.size != 1) throw RuntimeException("Expected 1 patch, but found ${patches.size}") + return CodeModernizerArtifact(zipPath, manifest, patches, summary) + } + throw RuntimeException("Could not find artifact") + } + + private fun extractSummary(manifest: CodeModernizerManifest): TransformationSummary { + val summaryFile = tempDir.toPath().resolve(manifest.summaryRoot).resolve(summaryNameInZip).toFile() + if (!summaryFile.exists() || summaryFile.isDirectory) { + throw RuntimeException("The summary in the downloaded zip had an unknown format") + } + return TransformationSummary(summaryFile.readText()) + } + + /** + * Attempts to load the manifest from the zip file. Throws an exception if the manifest is not found or cannot be serialized. + */ + private fun loadManifest(): CodeModernizerManifest { + val manifestFile = tempDir.listFiles() + ?.firstOrNull { Path(it.name).endsWith(manifestPathInZip) } + ?: throw RuntimeException("Could not find manifest") + try { + val manifest = MAPPER.readValue(manifestFile, CodeModernizerManifest::class.java) + if (manifest.version == 0.0F || manifest.patchesRoot == null || manifest.summaryRoot == null) { + throw RuntimeException( + "Unable to deserialize the manifest" + ) + } + return manifest + } catch (exception: JsonProcessingException) { + throw RuntimeException("Unable to deserialize the manifest") + } + } + + @OptIn(ExperimentalPathApi::class) + private fun extractPatches(manifest: CodeModernizerManifest): List { + val fileSystem = LocalFileSystem.getInstance() + val patchesDir = tempDir.toPath().resolve(manifest.patchesRoot) + if (!patchesDir.isDirectory()) { + throw RuntimeException("Expected root for patches was not a directory.") + } + return patchesDir.walk() + .map { fileSystem.findFileByNioFile(it) ?: throw RuntimeException("Could not find patch") } + .toList() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerAwaitModernizationJobResult.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerAwaitModernizationJobResult.kt new file mode 100644 index 0000000000..dc77fdb570 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerAwaitModernizationJobResult.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan + +sealed class CodeModernizerAwaitModernizationJobResult { + data class ZipCreationFailed(val reason: String) : CodeModernizerAwaitModernizationJobResult() + data class Started(val jobId: JobId) : CodeModernizerAwaitModernizationJobResult() + data class UnableToStartJob(val exception: String) : CodeModernizerAwaitModernizationJobResult() +} + +sealed class AwaitModernizationPlanResult { + object UnknownStatusWhenPolling : AwaitModernizationPlanResult() + data class Success(val plan: TransformationPlan) : AwaitModernizationPlanResult() + + data class Failure(val failureReason: String) : AwaitModernizationPlanResult() + data class BuildFailed(val failureReason: String) : AwaitModernizationPlanResult() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerException.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerException.kt new file mode 100644 index 0000000000..ccf00515fd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerException.kt @@ -0,0 +1,6 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +data class CodeModernizerException(override val message: String) : RuntimeException() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerJobCompletedResult.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerJobCompletedResult.kt new file mode 100644 index 0000000000..30ffe31821 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerJobCompletedResult.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import com.intellij.openapi.projectRoots.JavaSdkVersion + +sealed class CodeModernizerJobCompletedResult { + data class RetryableFailure(val jobId: JobId, val failureReason: String) : CodeModernizerJobCompletedResult() + data class UnableToCreateJob(val failureReason: String, val retryable: Boolean = false) : CodeModernizerJobCompletedResult() + data class JobFailed(val jobId: JobId, val failureReason: String?) : CodeModernizerJobCompletedResult() + + data class JobCompletedSuccessfully(val jobId: JobId) : CodeModernizerJobCompletedResult() + data class JobPartiallySucceeded(val jobId: JobId, val targetJavaVersion: JavaSdkVersion) : CodeModernizerJobCompletedResult() + data class JobFailedInitialBuild(val jobId: JobId, val failureReason: String) : CodeModernizerJobCompletedResult() + object ManagerDisposed : CodeModernizerJobCompletedResult() + object JobAbortedBeforeStarting : CodeModernizerJobCompletedResult() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerManifest.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerManifest.kt new file mode 100644 index 0000000000..a09ded600c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerManifest.kt @@ -0,0 +1,9 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CodeModernizerManifest(val version: Float, val patchesRoot: String, val artifactsRoot: String, val summaryRoot: String) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerSessionContext.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerSessionContext.kt new file mode 100644 index 0000000000..b39d7ac68d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerSessionContext.kt @@ -0,0 +1,352 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.ProcessNotCreatedException +import com.intellij.execution.process.ProcessOutput +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.ToolWindowManager +import org.jetbrains.idea.maven.execution.MavenRunner +import org.jetbrains.idea.maven.execution.MavenRunnerParameters +import software.aws.toolkits.core.utils.createTemporaryZipFile +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.putNextEntry +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.codemodernizer.ideMaven.TransformMavenRunner +import software.aws.toolkits.jetbrains.services.codemodernizer.ideMaven.TransformRunnable +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.managers.CodeModernizerBottomWindowPanelManager +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeTransformTelemetryState +import software.aws.toolkits.jetbrains.services.codemodernizer.toolwindow.CodeModernizerBottomToolWindowFactory +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodeTransformMavenBuildCommand +import software.aws.toolkits.telemetry.CodetransformTelemetry +import java.io.File +import java.io.IOException +import java.lang.Thread.sleep +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import kotlin.io.NoSuchFileException +import kotlin.io.byteInputStream +import kotlin.io.deleteRecursively +import kotlin.io.inputStream +import kotlin.io.path.Path +import kotlin.io.relativeTo +import kotlin.io.resolve +import kotlin.io.resolveSibling +import kotlin.io.walkTopDown + +const val MANIFEST_PATH = "manifest.json" +const val ZIP_SOURCES_PATH = "sources" +const val ZIP_DEPENDENCIES_PATH = "dependencies" +const val MAVEN_CONFIGURATION_FILE_NAME = "pom.xml" +const val MAVEN_DEFAULT_BUILD_DIRECTORY_NAME = "target" +const val IDEA_DIRECTORY_NAME = ".idea" + +data class CodeModernizerSessionContext( + val project: Project, + val configurationFile: VirtualFile, + val sourceJavaVersion: JavaSdkVersion, + val targetJavaVersion: JavaSdkVersion, +) { + private val mapper = jacksonObjectMapper() + + fun File.isMavenTargetFolder(): Boolean { + val hasPomSibling = this.resolveSibling(MAVEN_CONFIGURATION_FILE_NAME).exists() + val isMavenTargetDirName = this.isDirectory && this.name == MAVEN_DEFAULT_BUILD_DIRECTORY_NAME + return isMavenTargetDirName && hasPomSibling + } + + fun File.isIdeaFolder(): Boolean { + val isIdea = this.isDirectory && this.name == IDEA_DIRECTORY_NAME + return isIdea + } + + /** + * TODO use an approach based on walkTopDown instead of VfsUtil.collectChildrenRecursively(root) in createZipWithModuleFiles. + * We now recurse the file tree twice and then filter which hurts performance for large projects. + */ + private fun findDirectoriesToExclude(sourceFolder: File): List { + val excluded = mutableListOf() + sourceFolder.walkTopDown().onEnter { + if (it.isMavenTargetFolder() || it.isIdeaFolder()) { + excluded.add(it) + return@onEnter false + } + return@onEnter true + }.forEach { + // noop, collects the sequence + } + return excluded + } + + fun createZipWithModuleFiles(): ZipCreationResult { + val root = configurationFile.parent + val sourceFolder = File(root.path) + val depDirectory = runMavenCommand(sourceFolder) + if (depDirectory != null) { + CodetransformTelemetry.dependenciesCopied( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + ) + } + return runReadAction { + try { + val directoriesToExclude = findDirectoriesToExclude(sourceFolder) + val files = VfsUtil.collectChildrenRecursively(root).filter { child -> + val childPath = Path(child.path) + !child.isDirectory && directoriesToExclude.none { childPath.startsWith(it.toPath()) } + } + val dependencyfiles = if (depDirectory != null) { + iterateThroughDependencies(depDirectory) + } else { + mutableListOf() + } + + val dependenciesRoot = if (depDirectory != null) "$ZIP_DEPENDENCIES_PATH/${depDirectory.name}" else null + val zipManifest = mapper.writeValueAsString(ZipManifest(dependenciesRoot = dependenciesRoot)).byteInputStream() + + val zipSources = File(ZIP_SOURCES_PATH) + val depSources = File(ZIP_DEPENDENCIES_PATH) + val outputFile = createTemporaryZipFile { + it.putNextEntry(Path(MANIFEST_PATH).toString(), zipManifest) + if (depDirectory != null) { + dependencyfiles.forEach { depfile -> + val relativePath = File(depfile.path).relativeTo(depDirectory.parentFile) + val paddedPath = depSources.resolve(relativePath) + it.putNextEntry(paddedPath.toPath().toString(), depfile.inputStream()) + } + } + files.forEach { file -> + val relativePath = File(file.path).relativeTo(sourceFolder) + val paddedPath = zipSources.resolve(relativePath) + it.putNextEntry(paddedPath.toPath().toString(), file.inputStream) + } + }.toFile() + if (depDirectory != null) ZipCreationResult.Succeeded(outputFile) else ZipCreationResult.Missing1P(outputFile) + } catch (e: NoSuchFileException) { + throw CodeModernizerException("Source folder not found: ${root.path}") + } catch (e: Exception) { + LOG.error(e) { e.message.toString() } + throw CodeModernizerException("Unknown exception occurred ${root.path}") + } finally { + depDirectory?.deleteRecursively() + } + } + } + + /** + * @description + * this command is used to run the maven commmand which copies all the dependencies to a temp file which we will use to zip our own files to + */ + fun runMavenCommand(sourceFolder: File): File? { + val currentTimestamp = System.currentTimeMillis() + val destinationDir = Files.createTempDirectory("transformation_dependencies_temp_" + currentTimestamp) + val commandList = listOf( + "dependency:copy-dependencies", + "-DoutputDirectory=$destinationDir", + "-Dmdep.useRepositoryLayout=true", + "-Dmdep.copyPom=true", + "-Dmdep.addParentPoms=true" + ) + fun runCommand(mavenCommand: String): ProcessOutput { + val command = buildList { + add(mavenCommand) + addAll(commandList) + } + val commandLine = GeneralCommandLine(command) + .withWorkDirectory(sourceFolder) + .withRedirectErrorStream(true) + val output = ExecUtil.execAndGetOutput(commandLine) + return output + } + + // 1. Try to execute Maven Wrapper Command + LOG.warn { "Executing ./mvnw" } + var shouldTryMvnCommand = true + try { + val output = runCommand("./mvnw") + if (output.exitCode != 0) { + LOG.error { "mvnw command output:\n$output" } + val error = "The exitCode should be 0 while it was ${output.exitCode}" + CodetransformTelemetry.mvnBuildFailed( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformMavenBuildCommand = CodeTransformMavenBuildCommand.Mvnw, + reason = error + ) + return null + } else { + LOG.warn { "mvnw executed successfully" } + shouldTryMvnCommand = false + } + } catch (e: ProcessNotCreatedException) { + val error = "./mvnw failed to execute as its likely not a unix machine" + CodetransformTelemetry.mvnBuildFailed( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformMavenBuildCommand = CodeTransformMavenBuildCommand.Mvnw, + reason = error + ) + LOG.warn { error } + } catch (e: Exception) { + CodetransformTelemetry.mvnBuildFailed( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformMavenBuildCommand = CodeTransformMavenBuildCommand.Mvnw, + reason = e.message + ) + when { + e.message?.contains("Cannot run program \"./mvnw\"") == true -> {} // noop + else -> throw e + } + } + + // 2. maybe execute maven wrapper command + if (shouldTryMvnCommand) { + LOG.warn { "Executing mvn" } + try { + val output = runCommand("mvn") + if (output.exitCode != 0) { + LOG.error { "Maven command output:\n$output" } + val error = "The exitCode should be 0 while it was ${output.exitCode}" + CodetransformTelemetry.mvnBuildFailed( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformMavenBuildCommand = CodeTransformMavenBuildCommand.Mvn, + reason = error + ) + return null + } else { + shouldTryMvnCommand = false + LOG.warn { "Maven executed successfully" } + } + } catch (e: ProcessNotCreatedException) { + val error = "Maven failed to execute as its likely not installed to the PATH" + CodetransformTelemetry.mvnBuildFailed( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformMavenBuildCommand = CodeTransformMavenBuildCommand.Mvn, + reason = error + ) + LOG.warn { error } + } catch (e: Exception) { + CodetransformTelemetry.mvnBuildFailed( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformMavenBuildCommand = CodeTransformMavenBuildCommand.Mvn, + reason = e.message + ) + LOG.error(e) { e.message.toString() } + throw e + } + } + + // 3. intellij-bundled maven runner + if (shouldTryMvnCommand) { + LOG.warn { "Executing IntelliJ bundled Maven" } + val explicitenabled = emptyList() + try { + val params = MavenRunnerParameters( + false, + sourceFolder.absolutePath, + null, + commandList, + explicitenabled, + null + ) + + // Create MavenRunnerParametersMavenRunnerParameters + val mvnrunner = MavenRunner.getInstance(project) + val transformMvnRunner = TransformMavenRunner(project) + val mvnsettings = mvnrunner.settings + val createdDependencies = TransformRunnable() + runInEdt { + try { + transformMvnRunner.run(params, mvnsettings, createdDependencies) + } catch (t: Throwable) { + createdDependencies.exitCode(Integer.MIN_VALUE) // to stop looking for the exitCode + LOG.error { t.message.toString() } + CodetransformTelemetry.mvnBuildFailed( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformMavenBuildCommand = CodeTransformMavenBuildCommand.IDEBundledMaven, + reason = t.message + ) + } + } + while (createdDependencies.isComplete() == null) { + // waiting mavenrunner building + sleep(50) + } + if (createdDependencies.isComplete() == 0) { + LOG.warn { "IntelliJ bundled Maven executed successfully" } + } else if (createdDependencies.isComplete() != Integer.MIN_VALUE) { + val error = "The exitCode should be 0 while it was ${createdDependencies.isComplete()}" + LOG.error { error } + CodetransformTelemetry.mvnBuildFailed( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformMavenBuildCommand = CodeTransformMavenBuildCommand.IDEBundledMaven, + reason = error + ) + return null + } else { + // when exit code is MIN_VALUE + // return null + return null + } + } catch (t: Throwable) { + LOG.error { t.message.toString() } + CodetransformTelemetry.mvnBuildFailed( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + codeTransformMavenBuildCommand = CodeTransformMavenBuildCommand.IDEBundledMaven, + reason = t.message + ) + return null + } finally { + // after the ide bundled maven building finished + // change the bottom window to transformation hub + showTransformationHub() + } + } + + return destinationDir.toFile() + } + + private fun iterateThroughDependencies(depDirectory: File): MutableList { + val dependencyfiles = mutableListOf() + Files.walkFileTree( + depDirectory.toPath(), + setOf(FileVisitOption.FOLLOW_LINKS), + Int.MAX_VALUE, + object : SimpleFileVisitor() { + override fun visitFile(file: Path?, attrs: BasicFileAttributes?): FileVisitResult { + if (file != null) { + dependencyfiles.add(file.toFile()) + } + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path?, exc: IOException?): FileVisitResult = + FileVisitResult.CONTINUE + } + ) + return dependencyfiles + } + + fun showTransformationHub() = runInEdt { + val appModernizerBottomWindow = ToolWindowManager.getInstance(project).getToolWindow(CodeModernizerBottomToolWindowFactory.id) + ?: error(message("codemodernizer.toolwindow.problems_window_not_found")) + appModernizerBottomWindow.show() + CodeModernizerBottomWindowPanelManager.getInstance(project).setJobStartingUI() + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerStartJobResult.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerStartJobResult.kt new file mode 100644 index 0000000000..bea79b35bb --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CodeModernizerStartJobResult.kt @@ -0,0 +1,12 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +sealed class CodeModernizerStartJobResult { + data class ZipCreationFailed(val reason: String) : CodeModernizerStartJobResult() + data class Started(val jobId: JobId) : CodeModernizerStartJobResult() + data class UnableToStartJob(val exception: String) : CodeModernizerStartJobResult() + object Cancelled : CodeModernizerStartJobResult() + object Disposed : CodeModernizerStartJobResult() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CustomerSelection.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CustomerSelection.kt new file mode 100644 index 0000000000..50e0c13e2d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/CustomerSelection.kt @@ -0,0 +1,13 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.openapi.vfs.VirtualFile + +data class CustomerSelection( + val configurationFile: VirtualFile, + val sourceJavaVersion: JavaSdkVersion, + val targetJavaVersion: JavaSdkVersion +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/InvalidTelemetryReason.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/InvalidTelemetryReason.kt new file mode 100644 index 0000000000..9f1fbd02c0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/InvalidTelemetryReason.kt @@ -0,0 +1,8 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import software.aws.toolkits.telemetry.CodeTransformPreValidationError + +data class InvalidTelemetryReason(val category: CodeTransformPreValidationError? = CodeTransformPreValidationError.Unknown, val additonalInfo: String = "") diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/JobHistoryItem.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/JobHistoryItem.kt new file mode 100644 index 0000000000..e6279f2a9a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/JobHistoryItem.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import java.time.Instant +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration + +data class JobHistoryItem(val moduleName: String, val status: String, val startTime: Instant, val runTime: java.time.Duration) { + operator fun get(col: Int): Any = when (col) { + 0 -> moduleName + 1 -> status + 2 -> startTime + 3 -> runTime.toKotlinDuration().inWholeSeconds.seconds.toString() + else -> throw IllegalArgumentException("Invalid column $col") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/JobHistoryTableModel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/JobHistoryTableModel.kt new file mode 100644 index 0000000000..563329fcc7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/JobHistoryTableModel.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import javax.swing.table.AbstractTableModel + +class JobHistoryTableModel( + private val data: Array, + private val columnNames: Array +) : AbstractTableModel() { + override fun getRowCount(): Int = data.size + + override fun getColumnCount(): Int = columnNames.size + + override fun getValueAt(row: Int, col: Int): Any = data[row][col] + + override fun getColumnName(col: Int): String = columnNames[col] + + override fun isCellEditable(row: Int, col: Int): Boolean = false +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/JobId.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/JobId.kt new file mode 100644 index 0000000000..1c90e2b6d8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/JobId.kt @@ -0,0 +1,6 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +data class JobId(val id: String) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/MigrationStep.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/MigrationStep.kt new file mode 100644 index 0000000000..b5ea080c2c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/MigrationStep.kt @@ -0,0 +1,6 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +data class MigrationStep(val id: String) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/ValidationResult.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/ValidationResult.kt new file mode 100644 index 0000000000..a8695d9658 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/ValidationResult.kt @@ -0,0 +1,13 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import com.intellij.openapi.vfs.VirtualFile + +data class ValidationResult( + val valid: Boolean, + val invalidReason: String? = null, + val invalidTelemetryReason: InvalidTelemetryReason = InvalidTelemetryReason(), + val validatedBuildFiles: List = emptyList() +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/ZipCreationResult.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/ZipCreationResult.kt new file mode 100644 index 0000000000..fa9605f7f9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/ZipCreationResult.kt @@ -0,0 +1,11 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import java.io.File + +sealed class ZipCreationResult(open val payload: File) { + data class Missing1P(override val payload: File) : ZipCreationResult(payload) + data class Succeeded(override val payload: File) : ZipCreationResult(payload) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/ZipManifest.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/ZipManifest.kt new file mode 100644 index 0000000000..5aa01d79ce --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/model/ZipManifest.kt @@ -0,0 +1,13 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.model + +import software.aws.toolkits.jetbrains.services.codemodernizer.UPLOAD_ZIP_MANIFEST_VERSION +import software.aws.toolkits.jetbrains.services.codemodernizer.ZIP_SOURCES_PATH + +data class ZipManifest( + val sourcesRoot: String = ZIP_SOURCES_PATH, + val dependenciesRoot: String? = null, + val version: String = UPLOAD_ZIP_MANIFEST_VERSION.toString() +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/BuildProgressStepDetailsPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/BuildProgressStepDetailsPanel.kt new file mode 100644 index 0000000000..a0ceb990ba --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/BuildProgressStepDetailsPanel.kt @@ -0,0 +1,163 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.panels + +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.components.JBLabel +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan +import software.aws.toolkits.jetbrains.services.codemodernizer.constants.CodeModernizerUIConstants +import software.aws.toolkits.jetbrains.services.codemodernizer.model.BuildProgressTimelineStepDetailItem +import software.aws.toolkits.jetbrains.services.codemodernizer.model.BuildStepStatus +import software.aws.toolkits.jetbrains.services.codemodernizer.model.getTransformationProgressStepsByTransformationStepId +import software.aws.toolkits.jetbrains.services.codemodernizer.ui.components.PanelHeaderFactory +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import java.awt.Component +import java.awt.GridLayout +import javax.swing.BorderFactory +import javax.swing.BoxLayout +import javax.swing.DefaultListCellRenderer +import javax.swing.DefaultListModel +import javax.swing.JList +import javax.swing.JPanel + +class BuildProgressStepDetailsPanel : JPanel(BorderLayout()) { + var stepDetailsList: JList = JList(DefaultListModel()) + var headerLabel = PanelHeaderFactory().createPanelHeader("Transformation step progress details") + val scrollPane = ScrollPaneFactory.createScrollPane(stepDetailsList, true) + var transformationPlanLocal: TransformationPlan? = null + var currentStepIdRendered: Int = 1 + + init { + add(BorderLayout.NORTH, headerLabel) + add(BorderLayout.CENTER, scrollPane) + } + + fun setDefaultUI() { + val model = stepDetailsList.model as DefaultListModel + model.removeAllElements() + stepDetailsList.setCellRenderer(CustomBuildProgressStepDetailCellRenderer()) + stepDetailsList.putClientProperty(AnimatedIcon.ANIMATION_IN_RENDERER_ALLOWED, true) + scrollPane.border = BorderFactory.createEmptyBorder( + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_TOP, + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_LEFT, + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_BOTTOM, + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_RIGHT, + ) + repaint() + revalidate() + } + + fun setHeaderText(newText: String) { + val newHeaderLabel = PanelHeaderFactory().createPanelHeader(newText) + removeAll() + add(BorderLayout.NORTH, newHeaderLabel) + add(BorderLayout.CENTER, scrollPane) + } + + class CustomBuildProgressStepDetailCellRenderer : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) + val it = value as BuildProgressTimelineStepDetailItem + val row2TextStr = if (it.description.isNotEmpty()) { + it.description + } else { + if (it.status == BuildStepStatus.DONE) { + message("codemodernizer.migration_plan.substeps.description_succeed") + } else if (it.status == BuildStepStatus.ERROR) { + message("codemodernizer.migration_plan.substeps.description_failed") + } else { + it.description + } + } + val row1Text = JBLabel(it.text) + val row2Text = JBLabel(row2TextStr) + + val rowIcon = when (it.status) { + BuildStepStatus.DONE, BuildStepStatus.ERROR, BuildStepStatus.WARNING -> JBLabel(CodeModernizerUIConstants.getStepIcon()) + BuildStepStatus.WORKING -> JBLabel(AnimatedIcon.Default.INSTANCE) + } + + row2Text.apply { + // We don't show description text until step finished. + when (it.status) { + BuildStepStatus.DONE -> setForeground(CodeModernizerUIConstants.getGreenThemeFontColor()) + BuildStepStatus.ERROR -> setForeground(CodeModernizerUIConstants.getRedThemeFontColor()) + BuildStepStatus.WARNING -> setForeground(CodeModernizerUIConstants.getRedThemeFontColor()) + BuildStepStatus.WORKING -> text = null + } + } + + val rowLayoutPanel = JPanel() + rowLayoutPanel.apply { + setLayout(GridLayout(2, 1)) + setAlignmentY(Component.CENTER_ALIGNMENT) + add(row1Text) + // We only show the text when the status + // is NOT working. This means success and + // error states will show text + if (it.status == BuildStepStatus.WORKING) { + // This layout centers the text in the row + setLayout(GridLayout(1, 1)) + } else { + add(row2Text) + setLayout(GridLayout(2, 1)) + } + repaint() + revalidate() + } + + val rowLayoutXPanel = JPanel() + rowLayoutXPanel.apply { + BoxLayout(this, BoxLayout.X_AXIS) + add(rowIcon) + add(rowLayoutPanel) + repaint() + revalidate() + } + + val cellPanel = JPanel(BorderLayout()) + cellPanel.apply { + add(BorderLayout.WEST, rowLayoutXPanel) + repaint() + revalidate() + } + + return cellPanel + } + } + + fun updateListData(stepId: Int) { + currentStepIdRendered = stepId + val model = stepDetailsList.model as DefaultListModel + val newElements = getTransformationProgressStepsByTransformationStepId(stepId, transformationPlanLocal) + + // Clear the existing elements + model.removeAllElements() + + // Add the new elements + for (element in newElements) { + model.addElement(element) + } + stepDetailsList.model = model + val stepName = transformationPlanLocal?.transformationSteps()?.get(stepId - 1)?.name().orEmpty() + setHeaderText("$stepName details") + revalidate() + repaint() + } + + fun setTransformationPlan(newTransformationPlan: TransformationPlan) { + transformationPlanLocal = newTransformationPlan + updateListData(currentStepIdRendered) + revalidate() + repaint() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/BuildProgressTreePanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/BuildProgressTreePanel.kt new file mode 100644 index 0000000000..6b6fded44d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/BuildProgressTreePanel.kt @@ -0,0 +1,188 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.panels + +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.runInEdt +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.JBColor +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.components.JBLabel +import com.intellij.ui.treeStructure.Tree +import icons.AwsIcons +import software.aws.toolkits.jetbrains.services.codemodernizer.constants.CodeModernizerUIConstants +import software.aws.toolkits.jetbrains.services.codemodernizer.model.BuildProgressStepTreeItem +import software.aws.toolkits.jetbrains.services.codemodernizer.model.BuildStepStatus +import software.aws.toolkits.jetbrains.services.codemodernizer.model.ProgressStepId +import software.aws.toolkits.jetbrains.services.codemodernizer.ui.components.PanelHeaderFactory +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.addHorizontalGlue +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import java.awt.Component +import java.awt.GridBagLayout +import javax.swing.BorderFactory +import javax.swing.BoxLayout +import javax.swing.JPanel +import javax.swing.JTree +import javax.swing.event.TreeExpansionEvent +import javax.swing.event.TreeWillExpandListener +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.DefaultTreeCellRenderer +import javax.swing.tree.DefaultTreeModel +import javax.swing.tree.ExpandVetoException + +class BuildProgressTreePanel : JPanel(BorderLayout()) { + var headerLabel = PanelHeaderFactory().createPanelHeader(message("codemodernizer.toolwindow.transformation.progress.header")) + + val headerPanel = JPanel(GridBagLayout()).apply { + layout = GridBagLayout() + add(headerLabel, CodeWhispererLayoutConfig.inlineLabelConstraints) + } + + val headerInfoLabelPanel = JPanel(GridBagLayout()).apply { + add(headerPanel) + addHorizontalGlue() + } + + val tree = Tree() + val scrollPane = ScrollPaneFactory.createScrollPane(tree, true) + var root = defaultRoot() + + var treeModel = DefaultTreeModel(root) + val buildNodes = mutableListOf() + + init { + add(BorderLayout.NORTH, headerInfoLabelPanel) + add(BorderLayout.CENTER, scrollPane) + } + + private fun defaultRoot() = DefaultMutableTreeNode( + BuildProgressStepTreeItem(message("codemodernizer.toolwindow.progress.parent"), BuildStepStatus.WORKING, ProgressStepId.ROOT_STEP) + ) + + fun setDefaultUI() { + isVisible = true + scrollPane.border = BorderFactory.createEmptyBorder( + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_TOP, + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_LEFT, + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_BOTTOM, + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_RIGHT + ) + renderTree() + } + + fun renderTree() { + tree.apply { + isRootVisible = false + showsRootHandles = false + cellRenderer = CustomTreeCellRenderer() + model = treeModel + expandAllRowsAndSubTrees(tree) + revalidate() + repaint() + putClientProperty(AnimatedIcon.ANIMATION_IN_RENDERER_ALLOWED, true) + } + + // Remove default Collapse event for tree so users can NEVER collapse + // the tree + tree.addTreeWillExpandListener(object : TreeWillExpandListener { + override fun treeWillExpand(event: TreeExpansionEvent) {} + override fun treeWillCollapse(event: TreeExpansionEvent) { + throw ExpandVetoException(event, "Collapsing tree not allowed") + } + }) + + revalidate() + repaint() + } + + fun renderTree(updatedStatuses: List) = runInEdt { + setUpTreeData(updatedStatuses) + renderTree() + } + + fun clearTree() { + root = defaultRoot() + buildNodes.clear() + treeModel = DefaultTreeModel(root) + } + + fun setUpTreeData(items: List) { + root = defaultRoot() + buildNodes.clear() + buildNodes.addAll(items) + treeModel = DefaultTreeModel(root) + val allStagesNodes = items.filter { it.id != ProgressStepId.PLAN_STEP } + val planSteps = items.filter { it.id == ProgressStepId.PLAN_STEP } + + allStagesNodes.forEach { + val node = DefaultMutableTreeNode(it) + if (it.id == ProgressStepId.TRANSFORMING) { + planSteps.forEach { planStep -> + node.add(DefaultMutableTreeNode(planStep)) + } + } + root.add(node) + } + } + + private fun expandAllRowsAndSubTrees(tree: Tree) { + for (i in 0 until tree.getRowCount()) { + tree.expandRow(i) + } + } + + fun getCurrentElements() = buildNodes + + private class CustomTreeCellRenderer : DefaultTreeCellRenderer() { + override fun getTreeCellRendererComponent( + tree: JTree, + value: Any, + sel: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ): Component { + val treeRenderer = super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus) + if (value is DefaultMutableTreeNode) { + val userObject = value.userObject + if (userObject is BuildProgressStepTreeItem) { + val iconLabel = when (userObject.status) { + BuildStepStatus.DONE -> JBLabel(AwsIcons.CodeTransform.CHECKMARK_GREEN) + BuildStepStatus.WARNING -> JBLabel(AwsIcons.CodeTransform.CHECKMARK_GRAY) + BuildStepStatus.ERROR -> JBLabel(AllIcons.General.Error) + BuildStepStatus.WORKING -> JBLabel(AnimatedIcon.Default.INSTANCE) + } + val progressLabel = JBLabel().apply { + text = if (userObject.finishedTime != null) { + userObject.text + " finished at " + userObject.finishedTime + } else { + userObject.text + } + border = BorderFactory.createEmptyBorder(0, 5, 0, 0) + } + + val stepRunningtimeLabel = JBLabel().apply { + if (userObject.runtime != null) { + text = userObject.runtime + foreground = JBColor.GRAY + border = BorderFactory.createEmptyBorder(0, 10, 0, 0) + } + } + val panel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + add(iconLabel) + add(progressLabel, CodeWhispererLayoutConfig.inlineLabelConstraints) + add(stepRunningtimeLabel, CodeWhispererLayoutConfig.inlineLabelConstraints) + } + return panel + } + } + + return treeRenderer + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/CodeModernizerBanner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/CodeModernizerBanner.kt new file mode 100644 index 0000000000..b214979b8c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/CodeModernizerBanner.kt @@ -0,0 +1,141 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.panels + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import com.intellij.serviceContainer.AlreadyDisposedException +import com.intellij.ui.JBColor +import com.intellij.ui.border.CustomLineBorder +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBInsets +import com.intellij.util.ui.JBUI +import icons.AwsIcons +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.addHorizontalGlue +import software.aws.toolkits.jetbrains.ui.feedback.FeedbackDialog +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.time.Duration +import javax.swing.BorderFactory +import javax.swing.Icon +import javax.swing.JPanel +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration + +class CodeModernizerBanner(val project: Project) : JPanel(BorderLayout()) { + private val currentlyShownOptions = mutableSetOf() + + private val infoLabelPrefix = JBLabel(message("codemodernizer.toolwindow.banner.run_scan_info"), JBLabel.LEFT).apply { + icon = AllIcons.General.BalloonInformation + } + + private val infoLabelRunningTime = JBLabel().apply { + foreground = JBColor.GRAY + border = BorderFactory.createEmptyBorder(0, 5, 0, 0) + } + + private val infoPanel = JPanel(GridBagLayout()) + + val showDiffAction = ActionLink(message("codemodernizer.toolwindow.banner.action.diff")) { + CodeModernizerManager.getInstance(project).showDiff() + } + val showPlanAction = ActionLink(message("codemodernizer.toolwindow.banner.action.plan")) { + CodeModernizerManager.getInstance(project).showTransformationPlan() + } + val showSummaryAction = ActionLink(message("codemodernizer.toolwindow.banner.action.summary")) { + CodeModernizerManager.getInstance(project).showTransformationSummary() + } + + private val feedbackPanel = JPanel(GridBagLayout()).apply { + add( + JBLabel(AwsIcons.Misc.SMILE_GREY).apply { + border = BorderFactory.createEmptyBorder(0, 5, 0, 5) + }, + CodeWhispererLayoutConfig.inlineLabelConstraints + ) + add( + ActionLink(message("codemodernizer.toolwindow.banner.action.feedback")) { + FeedbackDialog(project, productName = "Amazon Q").showAndGet() + }, + CodeWhispererLayoutConfig.inlineLabelConstraints + ) + addHorizontalGlue() + } + + private fun buildContent() { + infoPanel.apply { + layout = GridBagLayout() + add(infoLabelPrefix, CodeWhispererLayoutConfig.inlineLabelConstraints) + currentlyShownOptions.forEach { + add( + it, + GridBagConstraints().apply { + anchor = GridBagConstraints.WEST + insets = JBInsets.create(0, 10) + } + ) + } + add(infoLabelRunningTime, CodeWhispererLayoutConfig.kebabMenuConstraints) + } + infoPanel.revalidate() + infoPanel.repaint() + } + + init { + border = BorderFactory.createCompoundBorder( + CustomLineBorder(JBUI.insetsBottom(1)), + BorderFactory.createEmptyBorder(7, 11, 8, 11), + ) + add(infoPanel, BorderLayout.LINE_START) + add(feedbackPanel, BorderLayout.LINE_END) + } + + fun updateActions(vararg actions: ActionLink) { + currentlyShownOptions.addAll(actions) + buildContent() + } + + fun updateContent(text: String, icon: Icon = AllIcons.General.BalloonInformation) { + infoPanel.isVisible = true + infoLabelPrefix.icon = icon + infoLabelPrefix.text = text + infoLabelPrefix.repaint() + infoLabelPrefix.isVisible = true + infoPanel.removeAll() + buildContent() + } + + fun updateRunningTime(runTime: Duration?) { + try { + if (runTime == null) { + infoLabelRunningTime.text = "" + } else { + val timeTaken = runTime.toKotlinDuration().inWholeSeconds.seconds.toString() + infoLabelRunningTime.text = message( + "codemodernizer.toolwindow.transformation.progress.running_time", + timeTaken + ) + } + } catch (exception: AlreadyDisposedException) { + LOG.warn { "Disposed when about to create the loading panel" } + return + } + } + + fun clearActions() { + currentlyShownOptions.clear() + buildContent() + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/CodeModernizerJobHistoryTablePanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/CodeModernizerJobHistoryTablePanel.kt new file mode 100644 index 0000000000..136b045948 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/CodeModernizerJobHistoryTablePanel.kt @@ -0,0 +1,55 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.panels + +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.table.JBTable +import software.aws.toolkits.jetbrains.services.codemodernizer.constants.CodeModernizerUIConstants +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobHistoryItem +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobHistoryTableModel +import software.aws.toolkits.jetbrains.services.codemodernizer.ui.components.PanelHeaderFactory +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import javax.swing.BorderFactory +import javax.swing.JPanel +import javax.swing.JScrollPane + +class CodeModernizerJobHistoryTablePanel : JPanel(BorderLayout()) { + var headerLabel = PanelHeaderFactory().createPanelHeader(message("codemodernizer.toolwindow.transformation.history.header")) + val columnNames = arrayOf( + message("codemodernizer.toolwindow.table.header.module_name"), + message("codemodernizer.toolwindow.table.header.status"), + message("codemodernizer.toolwindow.table.header.date"), + message("codemodernizer.toolwindow.table.header.run_length") + ) + var tableData: Array = emptyArray() + var tableModel = JobHistoryTableModel(tableData, columnNames) + var jbTable: JBTable = JBTable(tableModel) + var scrollPane: JScrollPane = ScrollPaneFactory.createScrollPane(jbTable, true) + + init { + add(BorderLayout.NORTH, headerLabel) + add(BorderLayout.CENTER, scrollPane) + } + + fun setDefaultUI() { + scrollPane.border = BorderFactory.createEmptyBorder( + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_TOP, + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_LEFT, + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_BOTTOM, + CodeModernizerUIConstants.SCROLL_PANEL.PADDING_RIGHT + ) + revalidate() + repaint() + } + + fun updateTableData(updateTableData: Array) { + tableData = updateTableData + tableModel = JobHistoryTableModel(tableData, columnNames) + jbTable.model = tableModel + jbTable.repaint() + revalidate() + repaint() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/LoadingPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/LoadingPanel.kt new file mode 100644 index 0000000000..6e683398d0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/LoadingPanel.kt @@ -0,0 +1,117 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.panels + +import com.intellij.openapi.project.Project +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.components.JBLabel +import software.aws.toolkits.jetbrains.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.BorderFactory +import javax.swing.JButton +import javax.swing.JPanel + +class LoadingPanel(private val project: Project) : JPanel(BorderLayout()) { + + val defaultLoadingText = message("codemodernizer.toolwindow.scan_in_progress") + val progressIndicatorLabel = JBLabel( + formatLoadingLabelWidth(defaultLoadingText), + AnimatedIcon.Default(), + JBLabel.CENTER, + ).apply { + border = BorderFactory.createEmptyBorder(7, 7, 7, 7) + } + val stopCodeScanButton = JButton(message("codemodernizer.toolwindow.stop_scan")).apply { + addActionListener { + CodeModernizerManager.getInstance(project).userInitiatedStopCodeModernization() + } + } + + val progressIndicatorPanel = JPanel(GridBagLayout()).apply { + add(progressIndicatorLabel, GridBagConstraints()) + add(stopCodeScanButton, GridBagConstraints().apply { gridy = 1 }) + } + private val fixedWidthCSS = "width:420px" + + init { + reset() + } + + /** + * @description Shows the top completion label to the user and empty + * the main CENTER panel. + */ + fun showSuccessUI() { + stopCodeScanButton.isVisible = false + progressIndicatorLabel.isVisible = false + progressIndicatorPanel.isVisible = false + renderDefaultLayout() + } + + /** + * @description Shows the top completion label to the user and empty + * the main CENTER panel. + */ + fun showFailureUI() { + stopCodeScanButton.isVisible = false + progressIndicatorLabel.isVisible = false + progressIndicatorPanel.isVisible = false + renderDefaultLayout() + } + + fun showOnlyLabelUI() { + stopCodeScanButton.isVisible = false + progressIndicatorLabel.isVisible = true + progressIndicatorPanel.isVisible = false + renderDefaultLayout() + } + + /** + * @description Shows in progress indicator indicating that the modernization is in progress + * in the CENTER layout position. This should hide the NORTH info label and show the + * CENTER progress label + */ + fun showInProgressIndicator() { + progressIndicatorLabel.isVisible = true + stopCodeScanButton.isVisible = false // we are unable to stop the job at this point for now just disable + progressIndicatorPanel.isVisible = true + renderDefaultLayout() + } + + fun updateProgressIndicatorText(text: String) { + progressIndicatorLabel.text = formatLoadingLabelWidth(text) + revalidate() + repaint() + } + + /** + * @description The default behavior is to show the NORTH + * info label visible and set all other elements to their + * default states. + */ + fun setDefaultUI() { + stopCodeScanButton.isVisible = false + progressIndicatorLabel.isVisible = true + progressIndicatorPanel.isVisible = true + renderDefaultLayout() + } + + fun renderDefaultLayout() { + removeAll() + add(BorderLayout.CENTER, progressIndicatorPanel) + revalidate() + repaint() + } + + fun reset() { + progressIndicatorLabel.text = formatLoadingLabelWidth(defaultLoadingText) + add(BorderLayout.CENTER, progressIndicatorPanel) + showOnlyLabelUI() + } + + private fun formatLoadingLabelWidth(inputText: String): String = "
$inputText
" +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/managers/BuildProgressSplitterPanelManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/managers/BuildProgressSplitterPanelManager.kt new file mode 100644 index 0000000000..30fa102e74 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/managers/BuildProgressSplitterPanelManager.kt @@ -0,0 +1,341 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.panels.managers + +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.ui.OnePixelSplitter +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStatus +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStep +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStepStatus +import software.aws.toolkits.jetbrains.services.codemodernizer.model.BuildProgressStepTreeItem +import software.aws.toolkits.jetbrains.services.codemodernizer.model.BuildStepStatus +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerException +import software.aws.toolkits.jetbrains.services.codemodernizer.model.ProgressStepId +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.BuildProgressStepDetailsPanel +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.BuildProgressTreePanel +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.LoadingPanel +import software.aws.toolkits.resources.message +import java.text.SimpleDateFormat +import java.time.Duration +import java.time.Instant +import java.util.Date +import javax.swing.event.TreeSelectionEvent +import javax.swing.event.TreeSelectionListener +import javax.swing.tree.DefaultMutableTreeNode +import kotlin.math.min +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration + +class BuildProgressSplitterPanelManager(private val project: Project) : + OnePixelSplitter(MODERNIZER_SPLITTER_PROPORTION_KEY, 1.0f) { + + // Active panel UI + var statusTreePanel = BuildProgressTreePanel() + var loadingPanel = LoadingPanel(project) + var buildProgressStepDetailsPanel = BuildProgressStepDetailsPanel() + val treeSelectionListener = TreeSelectionListener { e: TreeSelectionEvent? -> + if (statusTreePanel.tree.getLastSelectedPathComponent() != null) { + val selectedNode = statusTreePanel.tree.getLastSelectedPathComponent() as DefaultMutableTreeNode + val userObject = selectedNode.userObject + if (userObject is BuildProgressStepTreeItem) { + if (isValidStepClick(userObject.id) && userObject.transformationStepId != null) { + System.out.println("Is valid stepID") + buildProgressStepDetailsPanel.updateListData(userObject.transformationStepId) + revalidate() + repaint() + } else { + System.out.println("Is NOT valid stepID") + } + } + } + } + private val formatter = SimpleDateFormat("MM/dd/yy, HH:mm") + private var currentBuildingTransformationStep = 1 + private var newBuildingTransformationStep = 1 + + init { + this.autoscrolls = true + reset() + } + + fun reset() { + statusTreePanel = BuildProgressTreePanel() + statusTreePanel.setUpTreeData(defaultProgressData()) + loadingPanel.reset() + this.proportion = .5f + this.firstComponent = statusTreePanel + this.secondComponent = loadingPanel + } + + fun setSplitPanelStopView() { + // If the second component is the loadingPanel, then users + // have not yet received a modernization plan. We + // should remove the loading panel and show the left + // hand panel only + if (secondComponent == loadingPanel) { + secondComponent = null + this.proportion = 1f + } + revalidate() + repaint() + } + + fun defaultProgressData() = listOf( + BuildProgressStepTreeItem(message("codemodernizer.toolwindow.progress.waiting"), BuildStepStatus.WORKING, ProgressStepId.ACCEPTED), + ) + + fun setProgressStepsDefaultUI() { + this.secondComponent = buildProgressStepDetailsPanel + buildProgressStepDetailsPanel.setDefaultUI() + // Add tree event listener to render data in the right hand panel + statusTreePanel.tree.removeTreeSelectionListener(treeSelectionListener) + statusTreePanel.tree.addTreeSelectionListener(treeSelectionListener) + } + + /** + * Searches through the list and updates the step matching the [id] with the [status]. + * Also steps with a lower precedence [ProgressStepId.order] will be updated. + * Note, if the list size starts growing past a few entries, consider rewriting this as a map. + * Note, this only works as the transitions are fully linear as of now. + */ + fun List.update(status: BuildStepStatus, id: ProgressStepId) = this.map { + if (it.id.order <= id.order) { + it.copy(status = status) + } else { + // when transformation is finished but some plan steps have not updated their status + if (it.id == ProgressStepId.PLAN_STEP && it.status == BuildStepStatus.WORKING && id == ProgressStepId.TRANSFORMING) { + it.copy(status = status) + } else { + it + } + } + } + + fun handleProgressStateChanged(newState: TransformationStatus, transformationPlan: TransformationPlan?, jdkVersion: JavaSdkVersion) { + val currentState = statusTreePanel.getCurrentElements() + val loadingPanelText: String + // show the details panel when there are progress updates + // otherwise it would show an empty panel + val backendProgressStepsAvailable = ( + transformationPlan != null && + transformationPlan.hasTransformationSteps() && + haveProgressUpdates(transformationPlan) + ) + + fun maybeAdd(stepId: ProgressStepId, string: String) { + if (currentState.none { it.id == stepId }) { + currentState.add(BuildProgressStepTreeItem(string, BuildStepStatus.WORKING, stepId)) + } + } + + if (newState in setOf( + TransformationStatus.STARTED, + TransformationStatus.PREPARING, + ) + ) { + maybeAdd(ProgressStepId.ACCEPTED, message("codemodernizer.toolwindow.progress.waiting")) + maybeAdd(ProgressStepId.BUILDING, message("codemodernizer.toolwindow.progress.building")) + } + if (newState in setOf( + TransformationStatus.PREPARED, + TransformationStatus.PLANNING, + ) + ) { + maybeAdd(ProgressStepId.ACCEPTED, message("codemodernizer.toolwindow.progress.waiting")) + maybeAdd(ProgressStepId.BUILDING, message("codemodernizer.toolwindow.progress.building")) + maybeAdd(ProgressStepId.PLANNING, message("codemodernizer.toolwindow.progress.planning")) + } + if (newState in setOf( + TransformationStatus.PLANNED, + TransformationStatus.TRANSFORMING, + ) + ) { + maybeAdd(ProgressStepId.ACCEPTED, message("codemodernizer.toolwindow.progress.waiting")) + maybeAdd(ProgressStepId.BUILDING, message("codemodernizer.toolwindow.progress.building")) + maybeAdd(ProgressStepId.PLANNING, message("codemodernizer.toolwindow.progress.planning")) + maybeAdd(ProgressStepId.TRANSFORMING, message("codemodernizer.toolwindow.progress.transforming")) + } + + if (newState in setOf( + TransformationStatus.COMPLETED, + TransformationStatus.PARTIALLY_COMPLETED, + ) + ) { + maybeAdd(ProgressStepId.ACCEPTED, message("codemodernizer.toolwindow.progress.waiting")) + maybeAdd(ProgressStepId.BUILDING, message("codemodernizer.toolwindow.progress.building")) + maybeAdd(ProgressStepId.PLANNING, message("codemodernizer.toolwindow.progress.planning")) + maybeAdd(ProgressStepId.TRANSFORMING, message("codemodernizer.toolwindow.progress.transforming")) + } + + // Figure out if we should add plan steps + val statuses: List = if (currentState.isEmpty()) { + defaultProgressData().toList() + } else { + if (backendProgressStepsAvailable && transformationPlan != null) { + currentBuildingTransformationStep = newBuildingTransformationStep + newBuildingTransformationStep = transformationPlan.transformationSteps().size + val transformationPlanSteps = transformationPlan.transformationSteps()?.map { + getUpdatedBuildProgressStepTreeItem(it) + } + transformationPlanSteps?.sortedBy { it.transformationStepId } + val transformationPlanStepsMayAdded = maybeAddTransformationPlanSteps(transformationPlanSteps) + currentState.removeAll { it.id == ProgressStepId.PLAN_STEP } + if (transformationPlanStepsMayAdded.isNullOrEmpty()) { + currentState + } else currentState + transformationPlanStepsMayAdded + } else { + currentState + } + } + val updatedStatuses = when (newState) { + TransformationStatus.CREATED, TransformationStatus.ACCEPTED -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.accepted") + statuses.update(BuildStepStatus.WORKING, ProgressStepId.ACCEPTED) + } + + TransformationStatus.STARTED -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.accepted") + statuses.update(BuildStepStatus.DONE, ProgressStepId.ACCEPTED) + } + + TransformationStatus.PREPARING -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.building", jdkVersion.description) + statuses.update(BuildStepStatus.DONE, ProgressStepId.ACCEPTED) + } + + TransformationStatus.PREPARED -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.building", jdkVersion.description) + statuses.update(BuildStepStatus.DONE, ProgressStepId.BUILDING) + } + + TransformationStatus.PLANNING -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.planning") + statuses.update(BuildStepStatus.DONE, ProgressStepId.BUILDING) + } + + TransformationStatus.PLANNED -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.planning") + statuses.update(BuildStepStatus.DONE, ProgressStepId.PLANNING) + } + + TransformationStatus.TRANSFORMING -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.transforming") + statuses.update(BuildStepStatus.DONE, ProgressStepId.PLANNING) + } + + TransformationStatus.TRANSFORMED -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.transforming") + statuses.update(BuildStepStatus.DONE, ProgressStepId.TRANSFORMING) + } + + TransformationStatus.COMPLETED, TransformationStatus.PARTIALLY_COMPLETED -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.transforming") + statuses.update(BuildStepStatus.DONE, ProgressStepId.TRANSFORMING) + } + + TransformationStatus.STOPPING -> { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.stopping") + statuses + } // noop + + TransformationStatus.UNKNOWN_TO_SDK_VERSION -> { + throw CodeModernizerException(message("codemodernizer.notification.warn.unknown_status_response")) + } + + else -> { + if (newState == TransformationStatus.STOPPED) { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.stopping") + } else { + loadingPanelText = message("codemodernizer.toolwindow.scan_in_progress.failed") + } + statuses.map { + if (it.status == BuildStepStatus.WORKING) { + it.copy(status = BuildStepStatus.ERROR) + } else { + it + } + } + } + } + statusTreePanel.renderTree(updatedStatuses) + if (backendProgressStepsAvailable) { + if (this.secondComponent == loadingPanel) { + this.remove(loadingPanel) + setProgressStepsDefaultUI() + } + if (transformationPlan != null) { + buildProgressStepDetailsPanel.setTransformationPlan(transformationPlan) + // automatically jump to the next step's right details' panel only when the current step is finished and next step starts + if (newBuildingTransformationStep == 1 || currentBuildingTransformationStep != newBuildingTransformationStep) { + buildProgressStepDetailsPanel.updateListData(newBuildingTransformationStep) + } + } + repaint() + revalidate() + } else if (newState == TransformationStatus.STOPPED) { + setSplitPanelStopView() + revalidate() + repaint() + } else { + loadingPanel.updateProgressIndicatorText(loadingPanelText) + } + } + + private fun maybeAddTransformationPlanSteps(transformationPlanSteps: List?): List? { + if (transformationPlanSteps.isNullOrEmpty()) { + return transformationPlanSteps + } + val transformationPlanStepsMayAdded = mutableListOf() + for (step in transformationPlanSteps) { + transformationPlanStepsMayAdded.add(step) + if (step.status == BuildStepStatus.WORKING) { + // only add the first WORKING step + break + } + } + return transformationPlanStepsMayAdded + } + + private fun getUpdatedBuildProgressStepTreeItem(step: TransformationStep): BuildProgressStepTreeItem { + val startTime = step.startTime() + val finishedTime = step.endTime() + val stepId = step.id().toInt() + val (runtime, endTime) = getTransformationStepTimestamp(startTime, finishedTime) + val buildStepStatus: BuildStepStatus + if (step.status() == TransformationStepStatus.CREATED) { + newBuildingTransformationStep = min(newBuildingTransformationStep, stepId) + buildStepStatus = BuildStepStatus.WORKING + } else if (step.status() == TransformationStepStatus.FAILED || step.status() == TransformationStepStatus.UNKNOWN_TO_SDK_VERSION) { + buildStepStatus = BuildStepStatus.WARNING + } else { + buildStepStatus = BuildStepStatus.DONE + } + return BuildProgressStepTreeItem(step.name(), buildStepStatus, ProgressStepId.PLAN_STEP, runtime, endTime, step.id().toInt()) + } + + private fun getTransformationStepTimestamp(startTime: Instant?, endTime: Instant?): Pair { + if (startTime == null || endTime == null) { + return null to null + } + return Duration.between(startTime, endTime) + .toKotlinDuration().inWholeSeconds.seconds.toString() to formatter.format(Date.from(endTime)) + } + + private fun haveProgressUpdates(plan: TransformationPlan): Boolean = plan.transformationSteps().any { it.progressUpdates().size > 0 } + + private fun isValidStepClick(stepId: ProgressStepId): Boolean = when (stepId) { + ProgressStepId.PLAN_STEP -> true + ProgressStepId.ACCEPTED -> false + ProgressStepId.BUILDING -> false + ProgressStepId.PLANNING -> false + ProgressStepId.TRANSFORMING -> false + ProgressStepId.ROOT_STEP -> false + } + + private companion object { + const val MODERNIZER_SPLITTER_PROPORTION_KEY = "MODERNIZER_SPLITTER_PROPORTION" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/managers/CodeModernizerBottomWindowPanelManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/managers/CodeModernizerBottomWindowPanelManager.kt new file mode 100644 index 0000000000..858d934de8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/panels/managers/CodeModernizerBottomWindowPanelManager.kt @@ -0,0 +1,305 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.panels.managers + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.serviceContainer.AlreadyDisposedException +import com.intellij.ui.border.CustomLineBorder +import com.intellij.util.ui.JBUI +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationJob +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStatus +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerJobCompletedResult +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.CodeModernizerBanner +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.CodeModernizerJobHistoryTablePanel +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.LoadingPanel +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeModernizerSessionState +import software.aws.toolkits.jetbrains.services.codemodernizer.toolwindow.CodeModernizerBottomToolWindowFactory +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import java.awt.Component +import java.time.Duration +import java.time.Instant +import java.util.Timer +import java.util.TimerTask +import javax.swing.BorderFactory +import javax.swing.JPanel + +class CodeModernizerBottomWindowPanelManager(private val project: Project) : JPanel(BorderLayout()) { + private var lastShownProgressPanel: Component? = null + val toolbar = createToolbar().apply { + targetComponent = this@CodeModernizerBottomWindowPanelManager + component.border = BorderFactory.createCompoundBorder( + CustomLineBorder(JBUI.insetsRight(1)), + component.border + ) + } + + // Loading panel UI + var fullSizeLoadingPanel = LoadingPanel(project) + + // Active panel UI + var buildProgressSplitterPanelManager = BuildProgressSplitterPanelManager(project) + var previousJobHistoryPanel = CodeModernizerJobHistoryTablePanel() + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(CodeModernizerBottomToolWindowFactory.id) + val banner = CodeModernizerBanner(project) + var timer: Timer? = null + + init { + reset() + } + + private fun setUI(function: () -> Unit) { + lastShownProgressPanel = this.components.firstOrNull { it == fullSizeLoadingPanel || it == buildProgressSplitterPanelManager } ?: lastShownProgressPanel + removeAll() + add(BorderLayout.WEST, toolbar.component) + add(BorderLayout.NORTH, banner) + function.invoke() + updateRunTime() + revalidate() + repaint() + } + + private fun updateRunTime(now: Instant? = null) { + try { + val sessionState = CodeModernizerSessionState.getInstance(project) + val creationTime = sessionState.currentJobCreationTime + val stopTime = now ?: sessionState.currentJobStopTime + if (creationTime == Instant.MIN || stopTime == Instant.MIN) { + banner.updateRunningTime(null) + } else { + banner.updateRunningTime(Duration.between(creationTime, stopTime)) + } + } catch (e: AlreadyDisposedException) { + LOG.warn { "Disposed when about to update the runtime" } + return + } + } + + fun setJobStartingUI() = setUI { + add(BorderLayout.CENTER, fullSizeLoadingPanel) + banner.clearActions() + banner.updateContent(message("codemodernizer.toolwindow.banner.job_starting"), AllIcons.General.BalloonInformation) + fullSizeLoadingPanel.apply { + removeAll() + showInProgressIndicator() + revalidate() + repaint() + } + + buildProgressSplitterPanelManager.reset() + } + + fun setJobFailedToStartUI() = setUI { + add(BorderLayout.CENTER, fullSizeLoadingPanel) + banner.clearActions() + banner.updateContent(message("codemodernizer.toolwindow.banner.job_failed_to_start"), AllIcons.General.Error) + fullSizeLoadingPanel.apply { + removeAll() + showOnlyLabelUI() + revalidate() + repaint() + } + } + + fun setJobFailedWhileRunningUI() = setUI { + add(BorderLayout.CENTER, buildProgressSplitterPanelManager) + banner.updateContent(message("codemodernizer.toolwindow.banner.job_failed_while_running"), AllIcons.General.Error) + buildProgressSplitterPanelManager.setSplitPanelStopView() + } + + fun setJobRunningUI() = setUI { + add(BorderLayout.CENTER, buildProgressSplitterPanelManager) + banner.updateContent(message("codemodernizer.toolwindow.banner.job_is_running"), AllIcons.General.BalloonInformation) + buildProgressSplitterPanelManager.apply { + reset() + statusTreePanel.setDefaultUI() + loadingPanel.setDefaultUI() + revalidate() + repaint() + } + } + + fun setPreviousJobHistoryUI(shouldAddCurrentEndTime: Boolean): JPanel { + setUI { + add(BorderLayout.CENTER, previousJobHistoryPanel) + updatePreviousJobHistoryUI(shouldAddCurrentEndTime) + } + return this + } + + private fun updatePreviousJobHistoryUI(shouldAddCurrentEndTime: Boolean) { + try { + var history = CodeModernizerSessionState.getInstance(project).getJobHistory() + if (shouldAddCurrentEndTime) { + history = + history.map { it.copy(runTime = Duration.between(it.startTime, Instant.now())) }.toTypedArray() + } + previousJobHistoryPanel.apply { + setDefaultUI() + updateTableData(history) + revalidate() + repaint() + } + revalidate() + repaint() + } catch (e: AlreadyDisposedException) { + LOG.warn { "Disposed when about to update previous JobHistory" } + return + } + } + + fun showUnalteredJobUI() = setUI { + if (lastShownProgressPanel != null) { + add(BorderLayout.CENTER, lastShownProgressPanel) + } else { + add(BorderLayout.CENTER, fullSizeLoadingPanel) + fullSizeLoadingPanel.progressIndicatorLabel.text = "No jobs active" + fullSizeLoadingPanel.apply { + removeAll() + showOnlyLabelUI() + revalidate() + repaint() + } + } + } + + fun setJobFinishedUI(result: CodeModernizerJobCompletedResult) = setUI { + stopTimer() + buildProgressSplitterPanelManager.apply { + when (result) { + is CodeModernizerJobCompletedResult.UnableToCreateJob -> setJobFailedToStartUI() + + is CodeModernizerJobCompletedResult.RetryableFailure, + is CodeModernizerJobCompletedResult.JobFailedInitialBuild, + is CodeModernizerJobCompletedResult.JobFailed -> setJobFailedWhileRunningUI() + + is CodeModernizerJobCompletedResult.JobPartiallySucceeded, + is CodeModernizerJobCompletedResult.JobCompletedSuccessfully -> setJobCompletedSuccessfullyUI() + + is CodeModernizerJobCompletedResult.ManagerDisposed -> return@setUI + is CodeModernizerJobCompletedResult.JobAbortedBeforeStarting -> userInitiatedStopCodeModernizationUI() + } + } + } + + private fun setJobCompletedSuccessfullyUI() { + add(BorderLayout.CENTER, buildProgressSplitterPanelManager) + buildProgressSplitterPanelManager.apply { + addViewDiffToBanner() + addViewSummaryToBanner() + banner.updateContent(message("codemodernizer.toolwindow.banner.run_scan_complete"), AllIcons.Actions.Commit) + setSplitPanelStopView() + revalidate() + repaint() + } + } + + fun setProjectInvalidUI(reason: String) = setUI { + banner.updateContent(reason, AllIcons.General.Error) + fullSizeLoadingPanel.apply { + fullSizeLoadingPanel.showFailureUI() + revalidate() + repaint() + } + } + + fun userInitiatedStopCodeModernizationUI() = setUI { + stopTimer() + add(BorderLayout.CENTER, buildProgressSplitterPanelManager) + buildProgressSplitterPanelManager.setSplitPanelStopView() + banner.updateContent(message("codemodernizer.toolwindow.banner.job_is_stopped"), AllIcons.General.Error) + } + + fun createToolbar(): ActionToolbar { + val actionManager = ActionManager.getInstance() + val group = actionManager.getAction("aws.toolkit.codemodernizer.toolbar") as ActionGroup + return actionManager.createActionToolbar(ACTION_PLACE, group, false) + } + + fun handleJobTransition(new: TransformationStatus, plan: TransformationPlan?, sourceJavaVersion: JavaSdkVersion) = invokeLater { + if (new in listOf( + TransformationStatus.PLANNED, + TransformationStatus.TRANSFORMING, + TransformationStatus.TRANSFORMED, + TransformationStatus.COMPLETED, + TransformationStatus.PARTIALLY_COMPLETED + ) + ) { + addPlanToBanner() + } + buildProgressSplitterPanelManager.apply { + handleProgressStateChanged(new, plan, sourceJavaVersion) + if (timer == null) { + timer = Timer() + timer?.scheduleAtFixedRate( + object : TimerTask() { + override fun run() = try { + val currentJobCreationTime = CodeModernizerSessionState.getInstance(project).currentJobCreationTime + if (currentJobCreationTime == Instant.MIN) { + stopTimer() + } + updateRunTime(Instant.now()) + revalidate() + repaint() + } catch (_: AlreadyDisposedException) { + stopTimer() + } + }, + 0, + 1000 + ) + } + } + updatePreviousJobHistoryUI(true) + } + + fun addPlanToBanner() = banner.updateActions(banner.showPlanAction) + + fun addViewDiffToBanner() = banner.updateActions(banner.showDiffAction) + + fun addViewSummaryToBanner() = banner.updateActions(banner.showSummaryAction) + + private fun stopTimer() { + if (timer != null) { + timer?.cancel() + timer?.purge() + timer = null + } + } + + fun reset() { + stopTimer() + setUI { + banner.clearActions() + banner.updateContent(message("codemodernizer.toolwindow.banner.no_ongoing_job")) + buildProgressSplitterPanelManager.reset() + setPreviousJobHistoryUI(false) + } + } + + fun setResumeJobUI(currentJobResult: TransformationJob, plan: TransformationPlan?, sourceJavaVersion: JavaSdkVersion) { + setJobRunningUI() + buildProgressSplitterPanelManager.apply { + reset() + handleProgressStateChanged(currentJobResult.status(), plan, sourceJavaVersion) + } + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): CodeModernizerBottomWindowPanelManager = project.service() + const val ACTION_PLACE = "CodeModernizerBottomWindowPanel" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/plan/CodeModernizerPlanEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/plan/CodeModernizerPlanEditor.kt new file mode 100644 index 0000000000..1df0ec9a2e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/plan/CodeModernizerPlanEditor.kt @@ -0,0 +1,180 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.plan + +import com.intellij.icons.AllIcons +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.components.JBScrollPane +import icons.AwsIcons +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStep +import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory +import software.aws.toolkits.jetbrains.services.codemodernizer.constants.CodeModernizerUIConstants +import software.aws.toolkits.jetbrains.services.codemodernizer.plan.CodeModernizerPlanEditorProvider.Companion.JAVA_VERSION +import software.aws.toolkits.jetbrains.services.codemodernizer.plan.CodeModernizerPlanEditorProvider.Companion.MIGRATION_PLAN_KEY +import software.aws.toolkits.jetbrains.services.codemodernizer.plan.CodeModernizerPlanEditorProvider.Companion.MODULE_NAME_KEY +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.addHorizontalGlue +import software.aws.toolkits.resources.message +import java.awt.FlowLayout +import java.awt.GridBagLayout +import java.awt.GridLayout +import java.awt.Panel +import java.beans.PropertyChangeListener +import javax.swing.BorderFactory +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JEditorPane +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.event.HyperlinkEvent + +class CodeModernizerPlanEditor(val project: Project, val virtualFile: VirtualFile) : UserDataHolderBase(), FileEditor { + val plan = virtualFile.getUserData(MIGRATION_PLAN_KEY) ?: throw RuntimeException("Migration plan not found") + val module = virtualFile.getUserData(MODULE_NAME_KEY) ?: CodeModernizerUIConstants.EMPTY_SPACE_STRING + val javaVersion = virtualFile.getUserData(JAVA_VERSION).orEmpty() + private val contentPanel = JPanel(GridBagLayout()).apply { + add( + JPanel(GridBagLayout()).apply { + add( + title(message("codemodernizer.migration_plan.header.title")), + CodeModernizerUIConstants.transformationPlanPlaneConstraint + ) + add(transformationPlanInfo(plan, module), CodeModernizerUIConstants.transformationPlanPlaneConstraint) + add(transformationPlanPanel(plan), CodeModernizerUIConstants.transformationPlanPlaneConstraint) + }, + CodeModernizerUIConstants.transformationPlanPlaneConstraint + ) + add(Box.createVerticalGlue(), CodeModernizerUIConstants.FILLER_CONSTRAINT) + border = planGaps() + } + + private val rootPanel = JBScrollPane(contentPanel).apply { + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + } + + override fun dispose() {} + override fun getComponent() = rootPanel + override fun getPreferredFocusedComponent() = null + override fun getName() = "CodeModernizerTransformationPlan" + override fun getFile(): VirtualFile = virtualFile + override fun setState(state: FileEditorState) {} + override fun isModified() = false + override fun isValid() = true + override fun addPropertyChangeListener(listener: PropertyChangeListener) {} + override fun removePropertyChangeListener(listener: PropertyChangeListener) {} + + private fun title(text: String) = Panel().apply { + layout = FlowLayout(FlowLayout.LEFT) + val iconLabel = JLabel(AwsIcons.Logos.AWS_Q_GRADIENT) + val textLabel = JLabel(text).apply { + font = font.deriveFont( + CodeModernizerUIConstants.FONT_CONSTRAINTS.BOLD, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.TITLE_FONT_SIZE + ) + } + add(iconLabel) + add(textLabel) + } + + private fun transformationPlanPanel(plan: TransformationPlan) = JPanel(GridBagLayout()).apply { + val stepsIntroTitle = JLabel(message("codemodernizer.migration_plan.body.steps_intro_title")).apply { + font = font.deriveFont( + CodeModernizerUIConstants.FONT_CONSTRAINTS.BOLD, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.TRANSFORMATION_STEP_TITLE_FONT_SIZE + ) + border = CodeModernizerUIConstants.STEP_INTRO_TITLE_BORDER + } + val stepsIntro = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(stepsIntroTitle, CodeWhispererLayoutConfig.inlineLabelConstraints) + border = CodeModernizerUIConstants.STEP_INTRO_BORDER + } + add(stepsIntro, CodeModernizerUIConstants.transformationPlanPlaneConstraint) + plan.transformationSteps().forEachIndexed { step, i -> + val row = transformationStepPanel(i) + add(row, CodeModernizerUIConstants.transformationPlanPlaneConstraint) + } + border = CodeModernizerUIConstants.TRANSFORMATION_PLAN_PANEL_BORDER + } + + private fun transformationStepPanel(step: TransformationStep): JPanel { + val nameLabel = JLabel(step.name()).apply { + font = font.deriveFont( + CodeModernizerUIConstants.FONT_CONSTRAINTS.BOLD, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.TRANSFORMATION_STEP_TITLE_FONT_SIZE + ) + border = nameBoarder() + } + val descriptionLabel = + JLabel(message("codemodernizer.migration_plan.body.steps_step_description", step.description())).apply { + font = font.deriveFont(CodeModernizerUIConstants.PLAN_CONSTRAINTS.STEP_FONT_SIZE) + border = descriptionBoarder() + } + val transformationStepPanel = JPanel() + transformationStepPanel.add(nameLabel) + transformationStepPanel.add(descriptionLabel) + + return transformationStepPanel.apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = CodeModernizerUIConstants.TRANSFORMATION_STEP_PANEL_COMPOUND_BORDER + } + } + + fun transformationPlanInfo(plan: TransformationPlan, module: String) = JPanel().apply { + layout = GridLayout(1, 2) + val stepsInfo = JPanel().apply { + layout = FlowLayout(FlowLayout.LEFT) + add(JLabel(AllIcons.Actions.ListFiles)) + add(JLabel(message("codemodernizer.migration_plan.body.info", plan.transformationSteps().size))) + addHorizontalGlue() + border = CodeModernizerUIConstants.TRANSFORMATION_STEPS_INFO_BORDER + font = font.deriveFont(CodeModernizerUIConstants.PLAN_CONSTRAINTS.STEP_FONT_SIZE) + } + val awsqInfo = JPanel().apply { + layout = GridLayout() + val qChat = JEditorPane("text/html", message("codemodernizer.migration_plan.header.awsq", javaVersion, module)) + qChat.isEditable = false + qChat.isOpaque = false + qChat.addHyperlinkListener { + if (it.eventType.equals(HyperlinkEvent.EventType.ACTIVATED)) { + ToolWindowManager.getInstance(project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID) + ?.activate(null, true) + } + } + add(qChat) + border = CodeModernizerUIConstants.TRANSFORMATION_STEPS_INFO_AWSQ_BORDER + font = font.deriveFont(CodeModernizerUIConstants.PLAN_CONSTRAINTS.STEP_FONT_SIZE) + } + add(awsqInfo) + add(stepsInfo) + border = CodeModernizerUIConstants.TRANSOFORMATION_PLAN_INFO_BORDER + } + + fun planGaps() = BorderFactory.createEmptyBorder( + CodeModernizerUIConstants.PLAN_CONSTRAINTS.PLAN_PADDING_TOP, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.PLAN_PADDING_LEFT, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.PLAN_PADDING_BOTTOM, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.PLAN_PADDING_RIGHT + ) + + fun nameBoarder() = BorderFactory.createEmptyBorder( + CodeModernizerUIConstants.PLAN_CONSTRAINTS.NAME_PADDING_TOP, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.NAME_PADDING_LEFT, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.NAME_PADDING_BOTTOM, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.NAME_PADDING_RIGHT + ) + + fun descriptionBoarder() = BorderFactory.createEmptyBorder( + CodeModernizerUIConstants.PLAN_CONSTRAINTS.DESCRP_PADDING_TOP, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.DESCRP_PADDING_LEFT, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.DESCRP_PADDING_BOTTOM, + CodeModernizerUIConstants.PLAN_CONSTRAINTS.DESCRP_PADDING_RIGHT + ) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/plan/CodeModernizerPlanEditorProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/plan/CodeModernizerPlanEditorProvider.kt new file mode 100644 index 0000000000..5125a32591 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/plan/CodeModernizerPlanEditorProvider.kt @@ -0,0 +1,51 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.plan + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.VirtualFile +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend + +class CodeModernizerPlanEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile) = file is CodeModernizerPlanVirtualFile + + override fun createEditor(project: Project, file: VirtualFile) = CodeModernizerPlanEditor(project, file) + + override fun getEditorTypeId() = "CodeModernizerPlanEditor" + + override fun getPolicy() = FileEditorPolicy.HIDE_DEFAULT_EDITOR + + companion object { + private val LOG = getLogger() + val MIGRATION_PLAN_KEY = Key.create("TRANSFORMATION_PLAN") + val MODULE_NAME_KEY = Key.create("MODULE_NAME") + val JAVA_VERSION = Key.create("JAVA_VERSION") + fun openEditor(project: Project, plan: TransformationPlan, module: String?, javaVersionNumber: String) { + if (isRunningOnRemoteBackend()) return + val virtualFile = CodeModernizerPlanVirtualFile() + virtualFile.putUserData(MIGRATION_PLAN_KEY, plan) + virtualFile.putUserData(MODULE_NAME_KEY, module) + virtualFile.putUserData(JAVA_VERSION, javaVersionNumber) + runInEdt { + try { + FileEditorManager + .getInstance(project) + .openFileEditor(OpenFileDescriptor(project, virtualFile), true) + } catch (e: Exception) { + LOG.debug(e) { "Getting Started page failed to open" } + } + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/plan/CodeModernizerPlanVirtualFile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/plan/CodeModernizerPlanVirtualFile.kt new file mode 100644 index 0000000000..109c4f776c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/plan/CodeModernizerPlanVirtualFile.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.plan + +import com.intellij.testFramework.LightVirtualFile +import software.aws.toolkits.resources.message + +class CodeModernizerPlanVirtualFile : LightVirtualFile("Transformation Plan") { + override fun getPresentableName(): String = message("codemodernizer.migration_plan.header.description") + + override fun getPath(): String = "transformationPlan" + + override fun isWritable(): Boolean = false + + // This along with hashCode() is to make sure only one editor for this is opened at a time + override fun equals(other: Any?) = other is CodeModernizerPlanVirtualFile && this.hashCode() == other.hashCode() + + override fun hashCode(): Int = presentableName.hashCode() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/state/CodeModernizerSessionState.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/state/CodeModernizerSessionState.kt new file mode 100644 index 0000000000..7cbb0dcd0f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/state/CodeModernizerSessionState.kt @@ -0,0 +1,56 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.state + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationPlan +import software.amazon.awssdk.services.codewhispererruntime.model.TransformationStatus +import software.aws.toolkits.jetbrains.services.codemodernizer.TransformationSummary +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerException +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerSessionContext +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobHistoryItem +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobId +import java.time.Duration +import java.time.Instant +import kotlin.io.path.Path + +class CodeModernizerSessionState { + fun setDefaults() { + currentJobStatus = TransformationStatus.UNKNOWN_TO_SDK_VERSION + } + + var currentJobStatus: TransformationStatus = TransformationStatus.UNKNOWN_TO_SDK_VERSION + private val previousJobHistory = mutableMapOf() + var currentJobCreationTime: Instant = Instant.MIN + var currentJobStopTime: Instant = Instant.MIN + var transformationPlan: TransformationPlan? = null + var transformationSummary: TransformationSummary? = null + var currentJobId: JobId? = null + + private fun getJobItemId(sessionContext: CodeModernizerSessionContext) = Path(sessionContext.configurationFile.path).toAbsolutePath().toString() + fun putJobHistory(sessionContext: CodeModernizerSessionContext, status: String, startedAt: Instant = Instant.now()) { + val id = getJobItemId(sessionContext) + val jobHistoryItem = JobHistoryItem( + id, + status, + startedAt, + Duration.ZERO, + ) + previousJobHistory[id] = jobHistoryItem + } + + fun updateJobHistory(sessionContext: CodeModernizerSessionContext, newStatus: String, endTime: Instant) { + val id = getJobItemId(sessionContext) + val jobStatus = previousJobHistory.get(id) ?: throw CodeModernizerException("Unable to update the job history for $id") + val timeTaken = Duration.between(jobStatus.startTime, endTime) + previousJobHistory[id] = jobStatus.copy(status = newStatus, runTime = timeTaken) + } + + fun getJobHistory(): Array = previousJobHistory.values.toTypedArray() + + companion object { + fun getInstance(project: Project): CodeModernizerSessionState = project.service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/state/CodeModernizerState.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/state/CodeModernizerState.kt new file mode 100644 index 0000000000..778a0fcb70 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/state/CodeModernizerState.kt @@ -0,0 +1,61 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.state + +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.util.xmlb.annotations.Property +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CodeModernizerSessionContext +import software.aws.toolkits.jetbrains.services.codemodernizer.model.JobId +import software.aws.toolkits.jetbrains.services.codemodernizer.toVirtualFile + +fun CodeModernizerState.toSessionContext(project: Project): CodeModernizerSessionContext { + lastJobContext + val configurationFile = lastJobContext[JobDetails.CONFIGURATION_FILE_PATH]?.toVirtualFile() ?: throw RuntimeException("No build file store in the state") + val targetString = + lastJobContext[JobDetails.TARGET_JAVA_VERSION] ?: throw RuntimeException("Expected target language for migration path of previous job but was null") + val sourceString = + lastJobContext[JobDetails.SOURCE_JAVA_VERSION] ?: throw RuntimeException("Expected source language for migration path of previous job but was null") + val targetJavaSdkVersion = JavaSdkVersion.fromVersionString(targetString) ?: throw RuntimeException("Invalid Java SDK version $targetString") + val sourceJavaSdkVersion = JavaSdkVersion.fromVersionString(sourceString) ?: throw RuntimeException("Invalid Java SDK version $sourceString") + return CodeModernizerSessionContext(project, configurationFile, sourceJavaSdkVersion, targetJavaSdkVersion) +} + +fun buildState(context: CodeModernizerSessionContext, isJobOngoing: Boolean, jobId: JobId) = CodeModernizerState().apply { + lastJobContext.putAll( + setOf( + JobDetails.LAST_JOB_ID to jobId.id, + JobDetails.CONFIGURATION_FILE_PATH to context.configurationFile.path, + JobDetails.TARGET_JAVA_VERSION to context.targetJavaVersion.description, + JobDetails.SOURCE_JAVA_VERSION to context.sourceJavaVersion.description, + ) + ) + flags.putAll( + setOf( + StateFlags.IS_ONGOING to isJobOngoing + ) + ) +} + +fun CodeModernizerState.getLatestJobId() = JobId(lastJobContext[JobDetails.LAST_JOB_ID] ?: throw RuntimeException("No Job has been executed!")) + +class CodeModernizerState : BaseState() { + @get:Property + val lastJobContext by map() + + @get:Property + val flags by map() +} + +enum class JobDetails { + LAST_JOB_ID, + CONFIGURATION_FILE_PATH, + TARGET_JAVA_VERSION, + SOURCE_JAVA_VERSION, +} + +enum class StateFlags { + IS_ONGOING +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/state/CodeTransformTelemetryState.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/state/CodeTransformTelemetryState.kt new file mode 100644 index 0000000000..14d553a6c9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/state/CodeTransformTelemetryState.kt @@ -0,0 +1,37 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.state + +import com.intellij.openapi.components.BaseState +import com.intellij.util.xmlb.annotations.Property +import java.time.Instant +import java.util.UUID + +class CodeTransformTelemetryState { + private var mainState = CodeModernizerTelemetryStateBase() + + fun getSessionId() = mainState.sessionId + fun setSessionId() { + mainState.sessionId = UUID.randomUUID().toString() + } + + fun getStartTime() = mainState.sessionStartTime + fun setStartTime() { + mainState.sessionStartTime = Instant.now() + } + + // Companion object to hold the singleton instance + companion object { + // Lazy initialization of the singleton instance + val instance: CodeTransformTelemetryState by lazy { CodeTransformTelemetryState() } + } +} + +class CodeModernizerTelemetryStateBase : BaseState() { + @get:Property + var sessionId: String = UUID.randomUUID().toString() + + @get:Property + var sessionStartTime: Instant = Instant.now() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/summary/CodeModernizerSummaryEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/summary/CodeModernizerSummaryEditor.kt new file mode 100644 index 0000000000..74822e0f8f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/summary/CodeModernizerSummaryEditor.kt @@ -0,0 +1,531 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.summary + +import com.intellij.ide.BrowserUtil +import com.intellij.markdown.utils.MarkdownToHtmlConverter +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.components.JBScrollPane +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import software.aws.toolkits.jetbrains.services.codemodernizer.summary.CodeModernizerSummaryEditorProvider.Companion.MIGRATION_SUMMARY_KEY +import java.beans.PropertyChangeListener +import javax.swing.BorderFactory +import javax.swing.JEditorPane +import javax.swing.event.HyperlinkEvent + +class CodeModernizerSummaryEditor(val project: Project, val virtualFile: VirtualFile) : UserDataHolderBase(), FileEditor { + val summary = virtualFile.getUserData(MIGRATION_SUMMARY_KEY) ?: throw RuntimeException("Migration summary not found") + + private val rootPanel = buildRootPanel() + + fun renderCSSStyles(): String { + var fontFamilies = "-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans',Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'" + var mainFontColor = "#1F2328" + var mainAnchorColor = "#0969da" + var mainThemeBorder = "1px solid #d0d7de" + var mainMarkTagColor = "#fff8c5" + var secondaryThemeColor = "#d0d7de" + var tertiaryThemeColor = "#f6f8fa" + var tertiaryThemeFontColor = "#656d76" + var codeblockBgColor = "rgba(175,184,193,0.2)" + + if (EditorColorsManager.getInstance().isDarkEditor) { + mainFontColor = "#e6edf3" + mainAnchorColor = "#2f81f7" + mainThemeBorder = "1px solid #21262d" + mainMarkTagColor = "rgba(187,128,9,0.15)" + secondaryThemeColor = "#30363d" + tertiaryThemeColor = "#161b22" + tertiaryThemeFontColor = "#7d8590" + codeblockBgColor = "rgba(110,118,129,0.4)" + } + + return """ + .markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: $mainFontColor; + font-family: $fontFamilies; + font-size: 14px; + line-height: 1.5; + word-wrap: break-word; + padding: 0px 8px; + } + + .markdown-body details, + .markdown-body figcaption, + .markdown-body figure { + display: block; + } + + .markdown-body summary { + display: list-item; + } + + .markdown-body [hidden] { + display: none !important; + } + + .markdown-body a { + background-color: transparent; + color: $mainAnchorColor; + text-decoration: none; + } + + .markdown-body abbr[title] { + border-bottom: none; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + + .markdown-body b, + .markdown-body strong { + font-weight: 600; + } + + .markdown-body dfn { + font-style: italic; + } + + .markdown-body h1 { + margin: 10px 0; + font-weight: 600; + padding-bottom: 8px; + font-size: 32px; + border-bottom: $mainThemeBorder; + } + + .markdown-body mark { + background-color: $mainMarkTagColor; + color: $mainFontColor; + } + + .markdown-body small { + font-size: 10px + } + + .markdown-body sub, + .markdown-body sup { + font-size: 8px; + line-height: 0; + position: relative; + vertical-align: baseline; + } + + .markdown-body sub { + bottom: -4px; + } + + .markdown-body sup { + top: -8px; + } + + .markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; + + } + + .markdown-body code, + .markdown-body kbd, + .markdown-body pre, + .markdown-body samp { + font-family: monospace; + font-size: 14px; + } + + .markdown-body figure { + margin: 16px 40px; + } + + .markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: $mainThemeBorder; + height: 4px; + padding: 0; + margin: 24px 0; + background-color: $secondaryThemeColor; + border: 0; + } + + .markdown-body [type=button], + .markdown-body [type=reset], + .markdown-body [type=submit] { + -webkit-appearance: button; + } + + .markdown-body a:hover { + text-decoration: underline; + } + + .markdown-body hr::before { + display: table; + content: ''; + } + + .markdown-body hr::after { + display: table; + clear: both; + content: ''; + } + + .markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + } + + .markdown-body td, + .markdown-body th { + padding: 0; + } + + .markdown-body details summary { + cursor: pointer; + } + + .markdown-body details:not([open])>*:not(summary) { + display: none !important; + } + + .markdown-body h1, + .markdown-body h2, + .markdown-body h3, + .markdown-body h4, + .markdown-body h5, + .markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; + } + + .markdown-body h2 { + font-weight: 600; + padding-bottom: 5px; + font-size: 24px; + border-bottom: $mainThemeBorder; + } + + .markdown-body h3 { + font-weight: 600; + font-size: 20px; + } + + .markdown-body h4 { + font-weight: 600; + font-size: 16px; + } + + .markdown-body h5 { + font-weight: 600; + font-size: 14px; + } + + .markdown-body h6 { + font-weight: 600; + font-size: 14px; + color: $tertiaryThemeFontColor; + } + + .markdown-body p { + margin-top: 0; + margin-bottom: 10px; + } + + .markdown-body blockquote { + margin: 0; + padding: 0 16px; + color: $tertiaryThemeFontColor; + border-left: 4px solid $secondaryThemeColor; + } + + .markdown-body ul, + .markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 32px; + } + + .markdown-body ol ol, + .markdown-body ul ol { + list-style-type: lower-roman; + } + + .markdown-body ul ul ol, + .markdown-body ul ol ol, + .markdown-body ol ul ol, + .markdown-body ol ol ol { + list-style-type: lower-alpha; + } + + .markdown-body dd { + margin-left: 0; + } + + .markdown-body code, + .markdown-body samp { + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; + } + + .markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; + word-wrap: normal; + } + + .markdown-body::before { + display: table; + content: ''; + } + + .markdown-body::after { + display: table; + clear: both; + content: ''; + } + + .markdown-body>*:first-child { + margin-top: 0 !important; + } + + .markdown-body>*:last-child { + margin-bottom: 0 !important; + } + + .markdown-body p, + .markdown-body blockquote, + .markdown-body ul, + .markdown-body ol, + .markdown-body dl, + .markdown-body table, + .markdown-body pre, + .markdown-body details { + margin-top: 0; + margin-bottom: 16px; + } + + .markdown-body blockquote>:first-child { + margin-top: 0; + } + + .markdown-body blockquote>:last-child { + margin-bottom: 0; + } + + .markdown-body h1 code, + .markdown-body h2 code, + .markdown-body h3 code, + .markdown-body h4 code, + .markdown-body h5 code, + .markdown-body h6 code { + padding: 0 4px; + font-size: inherit; + } + + .markdown-body summary h1, + .markdown-body summary h2, + .markdown-body summary h3, + .markdown-body summary h4, + .markdown-body summary h5, + .markdown-body summary h6 { + display: inline-block; + } + + + .markdown-body summary h1, + .markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; + } + + + .markdown-body div>ol:not([type]) { + list-style-type: decimal; + } + + .markdown-body ul ul, + .markdown-body ul ol, + .markdown-body ol ol, + .markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; + } + + .markdown-body li>p { + margin-top: 16px; + } + + .markdown-body li+li { + margin-top: 4px; + } + + .markdown-body dl { + padding: 0; + } + + .markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 14px; + font-style: italic; + font-weight: 600; + } + + .markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; + } + + .markdown-body table th { + font-weight: 600; + } + + .markdown-body table th, + .markdown-body table td { + padding: 6px 13px; + border: 1px solid $secondaryThemeColor; + } + + .markdown-body table td>:last-child { + margin-bottom: 0; + } + + .markdown-body table tr { + border-top: $mainThemeBorder; + } + + .markdown-body table tr:nth-child(2n) { + background-color: $tertiaryThemeColor; + } + + .markdown-body table img { + background-color: transparent; + } + + .markdown-body img[align=right] { + padding-left: 20px; + } + + .markdown-body img[align=left] { + padding-right: 20px; + } + + .markdown-body code { + padding: 4px 6px; + margin: 0; + font-size: 12px; + white-space: break-spaces; + background-color: $codeblockBgColor; + border-radius: 4px; + } + + .markdown-body code br { + display: none; + } + + .markdown-body samp { + font-size: 12px; + } + + .markdown-body pre code { + font-size: 14px; + } + + .markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; + } + + .markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 12px; + line-height: 1.45; + color: $mainFontColor; + background-color: $tertiaryThemeColor; + border-radius: 4px; + } + + .markdown-body pre code{ + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: $codeblockBgColor; + border: 0; + } + """.trimIndent() + } + + private fun convertUsingGithubFlavoredMarkdown(markdown: String): String { + val bodyContents = MarkdownToHtmlConverter(GFMFlavourDescriptor()).convertMarkdownToHtml(markdown) + + return """ + + + + + + $bodyContents + + + """.trimIndent() + } + + private fun buildRootPanel(): JBScrollPane { + val description = convertUsingGithubFlavoredMarkdown(summary.content) + val editorPane = JEditorPane().apply { + contentType = "text/html" + putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) + border = BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(), + BorderFactory.createEmptyBorder(7, 11, 8, 11) + ) + isEditable = false + addHyperlinkListener { he -> + if (he.eventType == HyperlinkEvent.EventType.ACTIVATED) { + BrowserUtil.browse(he.url) + } + } + text = description + } + return JBScrollPane(editorPane) + } + + override fun dispose() {} + override fun getComponent() = rootPanel + override fun getPreferredFocusedComponent() = null + override fun getName() = "CodeModernizerSummary" + override fun getFile(): VirtualFile = virtualFile + override fun setState(state: FileEditorState) {} + override fun isModified() = false + override fun isValid() = true + override fun addPropertyChangeListener(listener: PropertyChangeListener) {} + override fun removePropertyChangeListener(listener: PropertyChangeListener) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/summary/CodeModernizerSummaryEditorProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/summary/CodeModernizerSummaryEditorProvider.kt new file mode 100644 index 0000000000..4d58c8be1e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/summary/CodeModernizerSummaryEditorProvider.kt @@ -0,0 +1,48 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.summary + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codemodernizer.TransformationSummary +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend + +class CodeModernizerSummaryEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile) = file is CodeModernizerSummaryVirtualFile + + override fun createEditor(project: Project, file: VirtualFile) = CodeModernizerSummaryEditor(project, file) + + override fun getEditorTypeId() = "CodeModernizerSummaryEditor" + + override fun getPolicy() = FileEditorPolicy.HIDE_DEFAULT_EDITOR + + companion object { + private val LOG = getLogger() + val MIGRATION_SUMMARY_KEY = Key.create("") + + fun openEditor(project: Project, summary: TransformationSummary) { + if (isRunningOnRemoteBackend()) return + val virtualFile = CodeModernizerSummaryVirtualFile() + virtualFile.putUserData(MIGRATION_SUMMARY_KEY, summary) + runInEdt { + try { + FileEditorManager + .getInstance(project) + .openFileEditor(OpenFileDescriptor(project, virtualFile), true) + } catch (e: Exception) { + LOG.debug(e) { "Showing transformation job summary page failed to open" } + } + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/summary/CodeModernizerSummaryVirtualFile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/summary/CodeModernizerSummaryVirtualFile.kt new file mode 100644 index 0000000000..2fa6f8c709 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/summary/CodeModernizerSummaryVirtualFile.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.summary + +import com.intellij.testFramework.LightVirtualFile +import software.aws.toolkits.resources.message + +class CodeModernizerSummaryVirtualFile : LightVirtualFile(message("codemodernizer.migration_summary.header.title")) { + override fun getPresentableName(): String = message("codemodernizer.migration_summary.header.title") + + override fun getPath(): String = "transformationSummary" + + override fun isWritable(): Boolean = false + + // This along with hashCode() is to make sure only one editor for this is opened at a time + override fun equals(other: Any?) = other is CodeModernizerSummaryVirtualFile && this.hashCode() == other.hashCode() + + override fun hashCode(): Int = presentableName.hashCode() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/toolwindow/CodeModernizerBottomToolWindowFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/toolwindow/CodeModernizerBottomToolWindowFactory.kt new file mode 100644 index 0000000000..a71d7050ca --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/toolwindow/CodeModernizerBottomToolWindowFactory.kt @@ -0,0 +1,43 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.toolwindow + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.components.JBScrollPane +import software.aws.toolkits.jetbrains.services.amazonq.isQSupportedInThisVersion +import software.aws.toolkits.jetbrains.services.codemodernizer.panels.managers.CodeModernizerBottomWindowPanelManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererExpired +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend +import software.aws.toolkits.resources.message + +class CodeModernizerBottomToolWindowFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val toolWindowContent = toolWindow.contentManager.factory.createContent( + JBScrollPane(CodeModernizerBottomWindowPanelManager.getInstance(project).setPreviousJobHistoryUI(false)), + null, + false + ) + toolWindowContent.isCloseable = false + toolWindow.contentManager.addContent(toolWindowContent) + + runInEdt { + toolWindow.installWatcher(toolWindow.contentManager) + } + } + + override fun init(toolWindow: ToolWindow) { + toolWindow.stripeTitle = message("codemodernizer.toolwindow.label_no_job") + } + override fun shouldBeAvailable(project: Project): Boolean = + isCodeWhispererEnabled(project) && !isCodeWhispererExpired(project) && !isRunningOnRemoteBackend() && isQSupportedInThisVersion() + + companion object { + const val id = "aws.codewhisperer.codetransform" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/BuildErrorDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/BuildErrorDialog.kt new file mode 100644 index 0000000000..7d97729671 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/BuildErrorDialog.kt @@ -0,0 +1,26 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.ui.components + +import com.intellij.openapi.ui.DialogBuilder +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.resources.message + +object BuildErrorDialog { + + /** + * Opens a dialog to user allowing them to select a migration path and details about their project / module. + */ + fun create(errorMessage: String) { + val builder = DialogBuilder() + builder.setTitle(message("codemodernizer.builderrordialog.description.title")) + builder.setCenterPanel( + panel { + row { text(errorMessage) } + } + ) + builder.addOkAction() + builder.showNotModal() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/PanelHeaderFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/PanelHeaderFactory.kt new file mode 100644 index 0000000000..938229c165 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/PanelHeaderFactory.kt @@ -0,0 +1,32 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.ui.components + +import com.intellij.ui.components.JBLabel +import software.aws.toolkits.jetbrains.services.codemodernizer.constants.CodeModernizerUIConstants +import java.awt.Font +import javax.swing.BorderFactory + +class PanelHeaderFactory { + fun createPanelHeader(headerText: String): JBLabel { + var headerElement = JBLabel(headerText).apply { + // Set padding + border = BorderFactory.createEmptyBorder( + CodeModernizerUIConstants.HEADER.PADDING_TOP, + CodeModernizerUIConstants.HEADER.PADDING_LEFT, + CodeModernizerUIConstants.HEADER.PADDING_BOTTOM, + CodeModernizerUIConstants.HEADER.PADDING_RIGHT + ) + // Set font size + val newFont = font.deriveFont(CodeModernizerUIConstants.HEADER.FONT_SIZE) + font = newFont + + // Make font bold + val boldFont = Font(font.fontName, Font.BOLD, font.size) + font = boldFont + } + + return headerElement + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/PreCodeTransformUserDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/PreCodeTransformUserDialog.kt new file mode 100644 index 0000000000..42a7c074b1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/PreCodeTransformUserDialog.kt @@ -0,0 +1,159 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.ui.components + +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.observable.util.whenItemSelected +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.DialogBuilder +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.COLUMNS_MEDIUM +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.toMutableProperty +import software.aws.toolkits.jetbrains.services.codemodernizer.getSupportedJavaMappingsForProject +import software.aws.toolkits.jetbrains.services.codemodernizer.model.CustomerSelection +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeTransformTelemetryState +import software.aws.toolkits.jetbrains.services.codemodernizer.tryGetJdk +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodetransformTelemetry +import kotlin.math.max + +class PreCodeTransformUserDialog( + val project: Project, + val supportedBuildFilesInProject: List, + val supportedJavaMappings: Map>, +) { + + internal data class Model( + var focusedBuildFileIndex: Int, + var focusedBuildFile: VirtualFile?, + var selectedMigrationPath: String?, + var supportedMigrationPaths: List, + var focusedBuildFileModule: Module?, + ) + + /** + * Opens a dialog to user allowing them to select a migration path and details about their project / module. + */ + fun create(): CustomerSelection? { + lateinit var dialogPanel: DialogPanel + lateinit var buildFileComboBox: ComboBox + + val buildfiles = supportedBuildFilesInProject + var focusedModuleIndex = 0 + var chosenBuildFile = buildfiles.firstOrNull() + val chosenFile = FileEditorManager.getInstance(project).selectedEditor?.file + + // Detect default selection for the build file + if (chosenFile != null) { + val focusedModule = ModuleUtil.findModuleForFile(chosenFile, project) + val matchingBuildFileForChosenModule = buildfiles.find { ModuleUtil.findModuleForFile(it, project) == focusedModule } + + if (focusedModule != null && matchingBuildFileForChosenModule != null) { + chosenBuildFile = matchingBuildFileForChosenModule + focusedModuleIndex = max(0, buildfiles.indexOfFirst { it == chosenBuildFile }) + } + } + + // Detect module for default selected file (if applicable) + var chosenModule: Module? = null + if (chosenBuildFile != null) { + chosenModule = ModuleUtil.findModuleForFile(chosenBuildFile, project) + } + + // Detect the supported migration path for the module, revert to project default if file not part of module. + fun supportedJdkForModuleOrProject(module: Module?): List { + val jdk = if (module != null) { + getSupportedJavaVersions(module) + } else { + project.getSupportedJavaMappingsForProject(supportedJavaMappings) + } + return jdk.map { it.replace("_", " ") } + } + + val supportedJavaVersions = supportedJdkForModuleOrProject(chosenModule) + + // Initialize model to hold form data + val model = Model( + focusedBuildFileIndex = focusedModuleIndex, + focusedBuildFile = chosenBuildFile, + focusedBuildFileModule = chosenModule, + selectedMigrationPath = supportedJavaVersions.firstOrNull(), + supportedMigrationPaths = supportedJavaVersions, + ) + + dialogPanel = panel { + row { text(message("codemodernizer.customerselectiondialog.description.main")) } + row { text(message("codemodernizer.customerselectiondialog.description.select")) } + row { + buildFileComboBox = comboBox(buildfiles.map { it.path }) + .bind({ it.selectedIndex }, { t, v -> t.selectedIndex = v }, model::focusedBuildFileIndex.toMutableProperty()) + .align(AlignX.FILL) + .columns(COLUMNS_MEDIUM) + .component + buildFileComboBox.whenItemSelected { + dialogPanel.apply() // apply user changes to model + model.focusedBuildFile = buildfiles[model.focusedBuildFileIndex] + model.focusedBuildFileModule = ModuleUtil.findModuleForFile(buildfiles[model.focusedBuildFileIndex], project) + model.supportedMigrationPaths = supportedJdkForModuleOrProject(model.focusedBuildFileModule) + dialogPanel.reset() // present model changes to user + } + buildFileComboBox.addActionListener { + CodetransformTelemetry.configurationFileSelectedChanged( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId() + ) + } + } + row { + this.topGap(TopGap.SMALL) + text(message("codemodernizer.customerselectiondialog.description.after_module")) + } + row { + text(message("codemodernizer.customerselectiondialog.description.after_module_part2")) + } + } + + val builder = DialogBuilder() + builder.setOkOperation { + CodetransformTelemetry.jobIsStartedFromUserPopupClick( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId() + ) + builder.dialogWrapper.close(DialogWrapper.OK_EXIT_CODE) + } + builder.addOkAction().setText(message("codemodernizer.customerselectiondialog.ok_button")) + builder.setCancelOperation { + CodetransformTelemetry.jobIsCanceledFromUserPopupClick( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId() + ) + builder.dialogWrapper.close(DialogWrapper.CANCEL_EXIT_CODE) + } + builder.addCancelAction() + builder.setCenterPanel(dialogPanel) + builder.setTitle(message("codemodernizer.customerselectiondialog.title")) + if (builder.showAndGet()) { + val selectedMigrationPath = model.selectedMigrationPath?.replace(" ", "_") ?: throw RuntimeException("Migration path is required") + val sourceJavaVersion = model.focusedBuildFileModule?.tryGetJdk(project) ?: project.tryGetJdk() + ?: throw RuntimeException("Unable to detect source language of selected ") + val targetJavaVersion = JavaSdkVersion.fromVersionString(selectedMigrationPath) ?: throw RuntimeException("Invalid migration path") + return CustomerSelection( + model.focusedBuildFile ?: throw RuntimeException("A build file must be selected"), + sourceJavaVersion, + targetJavaVersion, + ) + } + return null + } + + private fun getSupportedJavaVersions(module: Module?): List = + supportedJavaMappings.get(module?.tryGetJdk(project))?.map { it.name } ?: listOf("Unsupported module") +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/ValidationErrorDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/ValidationErrorDialog.kt new file mode 100644 index 0000000000..0dfd39f69b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codemodernizer/ui/components/ValidationErrorDialog.kt @@ -0,0 +1,27 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codemodernizer.ui.components + +import com.intellij.openapi.ui.DialogBuilder +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.resources.message + +object ValidationErrorDialog { + + /** + * Opens a dialog to user allowing them to select a migration path and details about their project / module. + */ + fun create(errorMessage: String) { + val builder = DialogBuilder() + builder.setTitle(message("codemodernizer.validationerrordialog.description.title")) + builder.setCenterPanel( + panel { + row { text(message("codemodernizer.validationerrordialog.description.main")) } + row { text(errorMessage) } + } + ) + builder.addOkAction() + builder.showNotModal() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererActionPromoter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererActionPromoter.kt new file mode 100644 index 0000000000..692d1ceb5e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererActionPromoter.kt @@ -0,0 +1,40 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.actions + +import com.intellij.openapi.actionSystem.ActionPromoter +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.editor.actionSystem.EditorAction +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererPopupLeftArrowHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererPopupRightArrowHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererPopupTabHandler + +class CodeWhispererActionPromoter : ActionPromoter { + override fun promote(actions: MutableList, context: DataContext): MutableList { + val results = actions.toMutableList() + results.sortWith { a, b -> + if (isCodeWhispererPopupAction(a)) { + return@sortWith -1 + } else if (isCodeWhispererPopupAction(b)) { + return@sortWith 1 + } else { + 0 + } + } + return results + } + + private fun isCodeWhispererAcceptAction(action: AnAction): Boolean = + action is EditorAction && action.handler is CodeWhispererPopupTabHandler + + private fun isCodeWhispererNavigateAction(action: AnAction): Boolean = + action is EditorAction && ( + action.handler is CodeWhispererPopupRightArrowHandler || + action.handler is CodeWhispererPopupLeftArrowHandler + ) + + private fun isCodeWhispererPopupAction(action: AnAction): Boolean = + isCodeWhispererAcceptAction(action) || isCodeWhispererNavigateAction(action) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererLearnMoreAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererLearnMoreAction.kt new file mode 100644 index 0000000000..9f7e858aab --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererLearnMoreAction.kt @@ -0,0 +1,40 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.actions + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.CODEWHISPERER_LEARN_MORE_URI +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.CODEWHISPERER_LOGIN_LEARN_MORE_URI +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.CODEWHISPERER_SSO_LEARN_MORE_URI +import software.aws.toolkits.resources.message +import java.net.URI + +class CodeWhispererLearnMoreAction : + AnAction( + message("codewhisperer.explorer.learn_more"), + null, + AllIcons.Actions.Help + ), + DumbAware { + + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(URI(CODEWHISPERER_LEARN_MORE_URI)) + } +} + +class CodeWhispererSsoLearnMoreAction : AnAction(message("aws.settings.learn_more")), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(URI(CODEWHISPERER_SSO_LEARN_MORE_URI)) + } +} + +class CodeWhispererLoginLearnMoreAction : AnAction(message("aws.settings.learn_more")), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(URI(CODEWHISPERER_LOGIN_LEARN_MORE_URI)) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererProvideFeedbackAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererProvideFeedbackAction.kt new file mode 100644 index 0000000000..c40a51955e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererProvideFeedbackAction.kt @@ -0,0 +1,24 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.project.DumbAware +import icons.AwsIcons +import software.aws.toolkits.jetbrains.ui.feedback.FeedbackDialog +import software.aws.toolkits.resources.message + +class CodeWhispererProvideFeedbackAction : + AnAction( + message("codewhisperer.feedback"), + null, + AwsIcons.Misc.SMILE_GREY + ), + DumbAware { + override fun actionPerformed(e: AnActionEvent) { + FeedbackDialog(e.getRequiredData(LangDataKeys.PROJECT), productName = "CodeWhisperer").showAndGet() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererRecommendationAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererRecommendationAction.kt new file mode 100644 index 0000000000..00938a1ef1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererRecommendationAction.kt @@ -0,0 +1,34 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.services.codewhisperer.model.LatencyContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.TriggerTypeInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutomatedTriggerType +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererService +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodewhispererTriggerType + +class CodeWhispererRecommendationAction : AnAction(message("codewhisperer.trigger.service")), DumbAware { + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.project != null && e.getData(CommonDataKeys.EDITOR) != null + } + + override fun actionPerformed(e: AnActionEvent) { + val latencyContext = LatencyContext() + latencyContext.codewhispererPreprocessingStart = System.nanoTime() + latencyContext.codewhispererEndToEndStart = System.nanoTime() + val editor = e.getRequiredData(CommonDataKeys.EDITOR) + if (!CodeWhispererService.getInstance().canDoInvocation(editor, CodewhispererTriggerType.OnDemand)) { + return + } + + val triggerType = TriggerTypeInfo(CodewhispererTriggerType.OnDemand, CodeWhispererAutomatedTriggerType.Unknown()) + CodeWhispererService.getInstance().showRecommendationsInPopup(editor, triggerType, latencyContext) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererShowSettingsAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererShowSettingsAction.kt new file mode 100644 index 0000000000..f5d6297f0a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererShowSettingsAction.kt @@ -0,0 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererConfigurable +import software.aws.toolkits.resources.message + +class CodeWhispererShowSettingsAction : + AnAction( + message("codewhisperer.settings.show.label"), + null, + AllIcons.General.GearPlain + ), + DumbAware { + override fun actionPerformed(e: AnActionEvent) { + ShowSettingsUtil.getInstance().showSettingsDialog(e.getRequiredData(LangDataKeys.PROJECT), CodeWhispererConfigurable::class.java) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererWhatIsAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererWhatIsAction.kt new file mode 100644 index 0000000000..ea43e903d4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/CodeWhispererWhatIsAction.kt @@ -0,0 +1,32 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.actions + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.resources.message +import java.net.URI + +class CodeWhispererWhatIsAction : + AnAction( + message("codewhisperer.explorer.what_is"), + null, + AllIcons.Actions.Help + ), + DumbAware { + override fun update(e: AnActionEvent) { + e.project?.let { + e.presentation.isEnabledAndVisible = isCodeWhispererEnabled(it) + } + } + + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(URI(CodeWhispererConstants.CODEWHISPERER_LEARN_MORE_URI)) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/ConnectWithAwsToContinueActionWarn.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/ConnectWithAwsToContinueActionWarn.kt new file mode 100644 index 0000000000..0c19cfcff9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/ConnectWithAwsToContinueActionWarn.kt @@ -0,0 +1,32 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsForCodeWhisperer +import software.aws.toolkits.resources.message + +/** + * Action prompting users to switch to SSO based credential, will nullify accountless credential (delete) + */ +class ConnectWithAwsToContinueActionWarn : DumbAwareAction(message("codewhisperer.notification.accountless.warn.action.connect")) { + override fun actionPerformed(e: AnActionEvent) { + e.project?.let { + runInEdt { + requestCredentialsForCodeWhisperer(it) + } + } + } +} +class ConnectWithAwsToContinueActionError : DumbAwareAction(message("codewhisperer.notification.accountless.error.action.connect")) { + override fun actionPerformed(e: AnActionEvent) { + e.project?.let { + runInEdt { + requestCredentialsForCodeWhisperer(it) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/DoNotShowAgainActionWarn.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/DoNotShowAgainActionWarn.kt new file mode 100644 index 0000000000..09119cf15c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/actions/DoNotShowAgainActionWarn.kt @@ -0,0 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.notifyInfoAccountless +import software.aws.toolkits.resources.message + +class DoNotShowAgainActionWarn : AnAction(message("codewhisperer.notification.accountless.warn.dont.show.again")), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + notifyInfoAccountless() + CodeWhispererExplorerActionManager.getInstance().setDoNotShowAgainWarn(true) + } +} + +class DoNotShowAgainActionError : AnAction(message("codewhisperer.notification.accountless.warn.dont.show.again")), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + notifyInfoAccountless() + CodeWhispererExplorerActionManager.getInstance().setDoNotShowAgainError(true) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanException.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanException.kt new file mode 100644 index 0000000000..ed778f5da2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanException.kt @@ -0,0 +1,26 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan + +import software.aws.toolkits.resources.message + +open class CodeWhispererCodeScanException(override val message: String?) : RuntimeException() + +internal fun noFileOpenError(): Nothing = + throw CodeWhispererCodeScanException(message("codewhisperer.codescan.no_file_open")) + +internal fun codeScanFailed(): Nothing = + throw CodeWhispererCodeScanException(message("codewhisperer.codescan.service_error")) + +internal fun cannotFindFile(file: String?): Nothing = + error(message("codewhisperer.codescan.file_not_found", file ?: "")) + +internal fun cannotFindBuildArtifacts(): Nothing = + throw CodeWhispererCodeScanException(message("codewhisperer.codescan.build_artifacts_not_found")) + +internal fun fileFormatNotSupported(format: String): Nothing = + throw CodeWhispererCodeScanException(message("codewhisperer.codescan.file_ext_not_supported", format)) + +internal fun fileTooLarge(presentableSize: String): Nothing = + throw CodeWhispererCodeScanException(message("codewhisperer.codescan.file_too_large", presentableSize)) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanHighlightingFilesPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanHighlightingFilesPanel.kt new file mode 100644 index 0000000000..1886f411b7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanHighlightingFilesPanel.kt @@ -0,0 +1,99 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.ColoredTreeCellRenderer +import com.intellij.ui.DoubleClickListener +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.TreeSpeedSearch +import com.intellij.ui.treeStructure.Tree +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import java.awt.event.MouseEvent +import javax.swing.BorderFactory +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JTree +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.DefaultTreeModel + +internal class CodeWhispererCodeScanHighlightingFilesPanel(private val project: Project, files: List) : JPanel(BorderLayout()) { + + init { + removeAll() + val scannedFilesTreeNodeRoot = DefaultMutableTreeNode("CodeWhisperer scanned files for security scan") + files.forEach { + scannedFilesTreeNodeRoot.add(DefaultMutableTreeNode(it)) + } + val scannedFilesTreeModel = DefaultTreeModel(scannedFilesTreeNodeRoot) + val scannedFilesTree: Tree = Tree().apply { + isRootVisible = false + object : DoubleClickListener() { + var status = true + override fun onDoubleClick(event: MouseEvent): Boolean { + val fileNode = (event.source as Tree).selectionPath?.lastPathComponent as? DefaultMutableTreeNode + val file = fileNode?.userObject as? VirtualFile ?: return false + runInEdt { + val editor = FileEditorManager.getInstance(project).openTextEditor( + OpenFileDescriptor(project, file), + true + ) + if (editor == null) { + LOG.error { "Cannot fetch editor for the file ${file.path}" } + status = false + return@runInEdt + } + } + return status + } + }.installOn(this) + model = scannedFilesTreeModel + cellRenderer = ScannedFilesTreeCellRenderer() + repaint() + } + TreeSpeedSearch(scannedFilesTree, TreeSpeedSearch.NODE_DESCRIPTOR_TOSTRING, true) + val scrollPane = ScrollPaneFactory.createScrollPane(scannedFilesTree, true) + val htmlText = message("codewhisperer.codescan.scanned_files_heading", files.size) + val label = JLabel(htmlText, JLabel.LEFT).apply { + border = BorderFactory.createEmptyBorder(7, 26, 8, 11) + } + + add(BorderLayout.NORTH, label) + add(BorderLayout.CENTER, scrollPane) + isVisible = true + revalidate() + } + + private class ScannedFilesTreeCellRenderer : ColoredTreeCellRenderer() { + override fun customizeCellRenderer( + tree: JTree, + value: Any, + selected: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ) { + value as DefaultMutableTreeNode + val file = value.userObject as? VirtualFile + val attributes = SimpleTextAttributes.LINK_ATTRIBUTES + icon = file?.fileType?.icon + font = UIUtil.getTreeFont() + file?.path?.let { append(it, attributes) } + } + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanManager.kt new file mode 100644 index 0000000000..9195c50f5a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanManager.kt @@ -0,0 +1,549 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan + +import com.intellij.analysis.problemsView.toolWindow.ProblemsView +import com.intellij.codeHighlighting.HighlightDisplayLevel +import com.intellij.codeInspection.util.InspectionMessage +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.ex.RangeHighlighterEx +import com.intellij.openapi.editor.impl.DocumentMarkupModel +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.MarkupModel +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.impl.FileDocumentManagerImpl +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.MessageDialogBuilder +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.refactoring.suggested.range +import com.intellij.ui.content.ContentManagerEvent +import com.intellij.ui.content.ContentManagerListener +import com.intellij.ui.treeStructure.Tree +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.time.withTimeout +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.codewhisperer.model.CodeWhispererException +import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException +import software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException +import software.aws.toolkits.core.utils.WaiterTimeoutException +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.core.utils.info +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.listeners.CodeWhispererCodeScanDocumentListener +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.listeners.CodeWhispererCodeScanEditorMouseMotionListener +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig.CodeScanSessionConfig +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.overlaps +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererUnknownLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CodeScanTelemetryEvent +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.INACTIVE_TEXT_COLOR +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.ISSUE_HIGHLIGHT_TEXT_ATTRIBUTES +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.promptReAuth +import software.aws.toolkits.jetbrains.services.codewhisperer.util.runIfIdcConnectionOrTelemetryEnabled +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.Icon +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.TreePath +import kotlin.coroutines.CoroutineContext + +class CodeWhispererCodeScanManager(val project: Project) { + private val codeScanResultsPanel by lazy { + CodeWhispererCodeScanResultsView(project) + } + private val codeScanIssuesContent by lazy { + val contentManager = getProblemsWindow().contentManager + contentManager.factory.createContent( + codeScanResultsPanel, + message("codewhisperer.codescan.scan_display"), + false + ).also { + Disposer.register(contentManager, it) + contentManager.addContentManagerListener(object : ContentManagerListener { + override fun contentRemoved(event: ContentManagerEvent) { + if (event.content == it) reset() + } + }) + } + } + + private val fileNodeLookup = mutableMapOf() + private val scanNodesLookup = mutableMapOf>() + + private val documentListener = CodeWhispererCodeScanDocumentListener(project) + private val editorMouseListener = CodeWhispererCodeScanEditorMouseMotionListener(project) + + private val isCodeScanInProgress = AtomicBoolean(false) + + private lateinit var codeScanJob: Job + + /** + * Returns true if the code scan is in progress. + * This function will return true for a cancelled code scan job which is in cancellation state. + */ + fun isCodeScanInProgress(): Boolean = isCodeScanInProgress.get() + + /** + * Code scan job is active when the [Job] is started and is in active state. + */ + fun isCodeScanJobActive(): Boolean = this::codeScanJob.isInitialized && codeScanJob.isActive + + fun getRunActionButtonIcon(): Icon = if (isCodeScanInProgress()) AllIcons.Process.Step_1 else AllIcons.Actions.Execute + + fun getActionButtonIconForExplorerNode(): Icon = if (isCodeScanInProgress()) AllIcons.Actions.Suspend else AllIcons.Actions.Execute + + fun getActionButtonText(): String = if (!isCodeScanInProgress()) message("codewhisperer.codescan.run_scan") else message("codewhisperer.codescan.stop_scan") + + /** + * Triggers a code scan and displays results in the new tab in problems view panel. + */ + fun runCodeScan() { + if (!isCodeWhispererEnabled(project)) return + + // Return if a scan is already in progress. + if (isCodeScanInProgress.getAndSet(true)) return + if (promptReAuth(project)) { + isCodeScanInProgress.set(false) + return + } + + // Prepare for a code scan + beforeCodeScan() + + // launch code scan coroutine + codeScanJob = launchCodeScanCoroutine() + } + + fun stopCodeScan() { + // Return if code scan job is not active. + if (!codeScanJob.isActive) return + if (isCodeScanInProgress() && confirmCancelCodeScan()) { + LOG.info { "Security scan stopped by user..." } + // Checking `codeScanJob.isActive` to ensure that the job is not already completed by the time user confirms. + if (codeScanJob.isActive) { + codeScanResultsPanel.setStoppingCodeScan() + codeScanJob.cancel(CancellationException("User requested cancellation")) + } + } + } + + private fun confirmCancelCodeScan(): Boolean = MessageDialogBuilder + .okCancel(message("codewhisperer.codescan.stop_scan"), message("codewhisperer.codescan.stop_scan_confirm_message")) + .yesText(message("codewhisperer.codescan.stop_scan_confirm_button")) + .ask(project) + + private fun launchCodeScanCoroutine() = projectCoroutineScope(project).launch { + var codeScanStatus: Result = Result.Failed + val startTime = Instant.now().toEpochMilli() + var codeScanResponseContext = defaultCodeScanResponseContext() + var getProjectSize: Deferred = async { null } + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance()) + var codeScanJobId: String? = null + var language: CodeWhispererProgrammingLanguage = CodeWhispererUnknownLanguage.INSTANCE + try { + val file = FileEditorManager.getInstance(project).selectedEditor?.file + ?: noFileOpenError() + val codeScanSessionConfig = CodeScanSessionConfig.create(file, project) + language = codeScanSessionConfig.getSelectedFile().programmingLanguage() + withTimeout(Duration.ofSeconds(codeScanSessionConfig.overallJobTimeoutInSeconds())) { + // 1. Generate truncation (zip files) based on the current editor. + LOG.debug { "Creating context truncation for file ${file.path}" } + val sessionContext = CodeScanSessionContext(project, codeScanSessionConfig) + val session = CodeWhispererCodeScanSession(sessionContext) + val codeScanResponse = session.run() + codeScanResponseContext = codeScanResponse.responseContext + codeScanJobId = codeScanResponseContext.codeScanJobId + when (codeScanResponse) { + is CodeScanResponse.Success -> { + val issues = codeScanResponse.issues + coroutineContext.ensureActive() + renderResponseOnUIThread( + issues, + codeScanResponse.responseContext.payloadContext.scannedFiles, + codeScanSessionConfig.isProjectTruncated() + ) + codeScanStatus = Result.Succeeded + } + + is CodeScanResponse.Failure -> { + if (codeScanResponse.failureReason !is TimeoutCancellationException && codeScanResponse.failureReason is CancellationException) { + codeScanStatus = Result.Cancelled + } + throw codeScanResponse.failureReason + } + } + LOG.info { "Security scan completed." } + } + getProjectSize = async { + codeScanSessionConfig.getTotalProjectSizeInBytes() + } + } catch (e: Exception) { + isCodeScanInProgress.set(false) + val errorMessage = handleException(coroutineContext, e) + codeScanResponseContext = codeScanResponseContext.copy(reason = errorMessage) + } finally { + // After code scan + afterCodeScan() + launch { + val duration = (Instant.now().toEpochMilli() - startTime).toDouble() + CodeWhispererTelemetryService.getInstance().sendSecurityScanEvent( + CodeScanTelemetryEvent(codeScanResponseContext, duration, codeScanStatus, getProjectSize.await()?.toDouble(), connection) + ) + sendCodeScanTelemetryToServiceAPI(project, language, codeScanJobId) + } + } + } + + fun handleException(coroutineContext: CoroutineContext, e: Exception): String { + val errorMessage = when (e) { + is CodeWhispererException -> e.awsErrorDetails().errorMessage() ?: message("codewhisperer.codescan.service_error") + is CodeWhispererCodeScanException -> e.message + is WaiterTimeoutException, is TimeoutCancellationException -> message("codewhisperer.codescan.scan_timed_out") + is CancellationException -> "Code scan job cancelled by user." + else -> null + } ?: message("codewhisperer.codescan.run_scan_error") + + val errorCode = (e as? CodeWhispererException)?.awsErrorDetails()?.errorCode() + val requestId = if (e is CodeWhispererException) e.requestId() else null + + if (!coroutineContext.isActive) { + codeScanResultsPanel.setDefaultUI() + } else { + codeScanResultsPanel.showError(errorMessage) + } + + if ( + e is ThrottlingException && + e.message == CodeWhispererConstants.THROTTLING_MESSAGE + ) { + CodeWhispererExplorerActionManager.getInstance().setSuspended(project) + CodeWhispererUtil.notifyErrorCodeWhispererUsageLimit(project, isCodeScan = true) + } + + LOG.error { + "Failed to run security scan and display results. Caused by: $errorMessage, status code: $errorCode, " + + "exception: ${e::class.simpleName}, request ID: $requestId " + + "Jetbrains IDE: ${ApplicationInfo.getInstance().fullApplicationName}, " + + "IDE version: ${ApplicationInfo.getInstance().apiVersion}, " + + "stacktrace: ${e.stackTrace.contentDeepToString()}" + } + return errorMessage + } + + /** + * The initial landing UI for the code scan results view panel. + * This method adds code content to the problems view if not already added. + * When [setSelected] is true, code scan panel is set to be in focus. + */ + fun addCodeScanUI(setSelected: Boolean = false) = runInEdt { + reset() + val problemsWindow = getProblemsWindow() + if (!problemsWindow.contentManager.contents.contains(codeScanIssuesContent)) { + problemsWindow.contentManager.addContent(codeScanIssuesContent) + } + codeScanIssuesContent.displayName = message("codewhisperer.codescan.scan_display") + if (setSelected) { + problemsWindow.contentManager.setSelectedContent(codeScanIssuesContent) + problemsWindow.show() + } + } + + fun removeCodeScanUI() = runInEdt { + val problemsWindow = getProblemsWindow() + if (problemsWindow.contentManager.contents.contains(codeScanIssuesContent)) { + problemsWindow.contentManager.removeContent(codeScanIssuesContent, false) + } + } + + fun getScanNodesInRange(file: VirtualFile, startOffset: Int): List = + getOverlappingScanNodes(file, TextRange.create(startOffset, startOffset + 1)) + + fun getOverlappingScanNodes(file: VirtualFile, range: TextRange): List = synchronized(scanNodesLookup) { + scanNodesLookup[file]?.mapNotNull { node -> + val issue = node.userObject as CodeWhispererCodeScanIssue + if (issue.textRange?.overlaps(range) == true) node else null + } ?: listOf() + } + + fun getScanTree(): Tree = codeScanResultsPanel.getCodeScanTree() + + /** + * Updates the scan nodes in a [file] with the new text range. + */ + fun updateScanNodes(file: VirtualFile) { + scanNodesLookup[file]?.forEach { node -> + val issue = node.userObject as CodeWhispererCodeScanIssue + val newRange = issue.rangeHighlighter?.range + val oldRange = issue.textRange + // Check if the location of the issue is changed and only update the valid nodes. + if (newRange != null && oldRange != newRange && !issue.isInvalid) { + val newIssue = issue.copyRange(newRange) + synchronized(node) { + getScanTree().model.valueForPathChanged(TreePath(node.path), newIssue) + node.userObject = newIssue + } + } + } + } + + private fun CodeWhispererCodeScanIssue.copyRange(newRange: TextRange): CodeWhispererCodeScanIssue { + val newStartLine = document.getLineNumber(newRange.startOffset) + val newStartCol = newRange.startOffset - document.getLineStartOffset(newStartLine) + val newEndLine = document.getLineNumber(newRange.endOffset) + val newEndCol = newRange.endOffset - document.getLineStartOffset(newEndLine) + return copy( + startLine = newStartLine + 1, + startCol = newStartCol + 1, + endLine = newEndLine + 1, + endCol = newEndCol + 1 + ) + } + + private fun getProblemsWindow() = ProblemsView.getToolWindow(project) + ?: error(message("codewhisperer.codescan.problems_window_not_found")) + + private fun reset() = runInEdt { + // Remove previous document listeners before starting a new scan. + removeListeners() + fileNodeLookup.clear() + // Erase all range highlighter before cleaning up. + scanNodesLookup.apply { + forEach { (_, nodes) -> + nodes.forEach { node -> + val issue = node.userObject as CodeWhispererCodeScanIssue + issue.rangeHighlighter?.dispose() + } + } + clear() + } + } + + private fun addListeners() { + fileNodeLookup.keys.forEach { file -> + runInEdt { + val document = FileDocumentManager.getInstance().getDocument(file) + if (document == null) { + LOG.error { message("codewhisperer.codescan.file_not_found", file.path) } + return@runInEdt + } + document.addDocumentListener(documentListener, codeScanIssuesContent) + } + } + EditorFactory.getInstance().eventMulticaster.addEditorMouseMotionListener( + editorMouseListener, + codeScanIssuesContent + ) + } + + private fun removeListeners() { + fileNodeLookup.keys.forEach { file -> + runInEdt { + val document = FileDocumentManager.getInstance().getDocument(file) + if (document == null) { + LOG.error { message("codewhisperer.codescan.file_not_found", file.path) } + return@runInEdt + } + document.removeDocumentListener(documentListener) + } + } + EditorFactory.getInstance().eventMulticaster.removeEditorMouseMotionListener(editorMouseListener) + } + + private fun beforeCodeScan() { + // Refresh CodeWhisperer Explorer tree node to reflect scan in progress. + project.refreshCwQTree() + addCodeScanUI(setSelected = true) + // Show in progress indicator + codeScanResultsPanel.showInProgressIndicator() + (FileDocumentManagerImpl.getInstance() as FileDocumentManagerImpl).saveAllDocuments(false) + LOG.info { "Starting security scan on package ${project.name}..." } + } + + private fun afterCodeScan() { + isCodeScanInProgress.set(false) + project.refreshCwQTree() + } + + private fun sendCodeScanTelemetryToServiceAPI( + project: Project, + programmingLanguage: CodeWhispererProgrammingLanguage, + codeScanJobId: String? + ) { + runIfIdcConnectionOrTelemetryEnabled(project) { + try { + val response = CodeWhispererClientAdaptor.getInstance(project) + .sendCodeScanTelemetry(programmingLanguage, codeScanJobId) + LOG.debug { "Successfully sent code scan telemetry. RequestId: ${response.responseMetadata().requestId()}" } + } catch (e: Exception) { + val requestId = if (e is CodeWhispererRuntimeException) e.requestId() else null + LOG.debug { + "Failed to send code scan telemetry. RequestId: $requestId, ErrorMessage: ${e.message}" + } + } + } + } + + /** + * Creates a CodeWhisperer code scan issues tree. + * For each scan node: + * 1. (Add file node if not already present and) add scan node to the file node. + * 2. Update the lookups - [fileNodeLookup] for efficiently adding scan nodes and + * [scanNodesLookup] for receiving the editor events and updating the corresponding scan nodes. + */ + private fun createCodeScanIssuesTree(codeScanIssues: List): DefaultMutableTreeNode { + LOG.debug { "Rendering response from the scan API" } + + val codeScanTreeNodeRoot = DefaultMutableTreeNode("CodeWhisperer Code scan results") + codeScanIssues.forEach { issue -> + val fileNode = synchronized(fileNodeLookup) { + fileNodeLookup.getOrPut(issue.file) { + val node = DefaultMutableTreeNode(issue.file) + synchronized(codeScanTreeNodeRoot) { + codeScanTreeNodeRoot.add(node) + } + node + } + } + + val scanNode = DefaultMutableTreeNode(issue) + fileNode.add(scanNode) + scanNodesLookup.getOrPut(issue.file) { + mutableListOf() + }.add(scanNode) + } + // Add document and editor listeners to the documents having scan issues. + addListeners() + return codeScanTreeNodeRoot + } + + suspend fun renderResponseOnUIThread(issues: List, scannedFiles: List, isProjectTruncated: Boolean) { + withContext(getCoroutineUiContext()) { + val root = createCodeScanIssuesTree(issues) + val codeScanTreeModel = CodeWhispererCodeScanTreeModel(root) + val totalIssuesCount = codeScanTreeModel.getTotalIssuesCount() + if (totalIssuesCount > 0) { + codeScanIssuesContent.displayName = + message("codewhisperer.codescan.scan_display_with_issues", totalIssuesCount, INACTIVE_TEXT_COLOR) + } + codeScanResultsPanel.updateAndDisplayScanResults(codeScanTreeModel, scannedFiles, isProjectTruncated) + } + } + + @TestOnly + suspend fun testRenderResponseOnUIThread(issues: List, scannedFiles: List, isProjectTruncated: Boolean) { + assert(ApplicationManager.getApplication().isUnitTestMode) + renderResponseOnUIThread(issues, scannedFiles, isProjectTruncated) + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): CodeWhispererCodeScanManager = project.service() + } +} + +/** + * Wrapper Data class representing a CodeWhisperer code scan issue. + * @param title is shown in the code scan tree in the `CodeWhisperer Security Issues` tab. + * @param description is shown in the tooltip of the scan node and also shown when the mouse + * is hovered over the highlighted text in the editor. + */ +data class CodeWhispererCodeScanIssue( + val project: Project, + val file: VirtualFile, + val startLine: Int, + val startCol: Int, + val endLine: Int, + val endCol: Int, + val title: @InspectionMessage String, + val description: Description, + val detectorId: String, + val detectorName: String, + val findingId: String, + val ruleId: String?, + val relatedVulnerabilities: List, + val severity: String, + val recommendation: Recommendation, + val suggestedFixes: List, + val issueSeverity: HighlightDisplayLevel = HighlightDisplayLevel.WARNING, + val isInvalid: Boolean = false, + var rangeHighlighter: RangeHighlighterEx? = null +) { + override fun toString(): String = title + + val document = runReadAction { + FileDocumentManager.getInstance().getDocument(file) + ?: cannotFindFile(file.path) + } + + /** + * Immutable value of the textRange at the time the issue was constructed. + */ + val textRange = toTextRange() + + fun displayTextRange() = "[$startLine:$startCol-$endLine:$endCol]" + + /** + * Adds a range highlighter for the corresponding code scan issue with the given markup model. + * Note that the default markup model which is fetched from [DocumentMarkupModel] can be null. + * Must be run in [runInEdt]. + */ + fun addRangeHighlighter( + markupModel: MarkupModel? = + DocumentMarkupModel.forDocument(document, project, false) + ): RangeHighlighterEx? { + if (!ApplicationManager.getApplication().isDispatchThread) return null + return markupModel?.let { + textRange ?: return null + it.addRangeHighlighter( + textRange.startOffset, + textRange.endOffset, + HighlighterLayer.LAST + 1, + ISSUE_HIGHLIGHT_TEXT_ATTRIBUTES, + HighlighterTargetArea.EXACT_RANGE + ) as RangeHighlighterEx + } + } + + private fun toTextRange(): TextRange? { + if (startLine < 1 || endLine > document.lineCount) return null + val startOffset = document.getLineStartOffset(startLine - 1) + startCol - 1 + val endOffset = document.getLineStartOffset(endLine - 1) + endCol - 1 + if (startOffset < 0 || endOffset > document.textLength || startOffset > endOffset) return null + return TextRange.create(startOffset, endOffset) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanResultsView.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanResultsView.kt new file mode 100644 index 0000000000..605368ed3c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanResultsView.kt @@ -0,0 +1,304 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.OnePixelSplitter +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.border.CustomLineBorder +import com.intellij.ui.components.ActionLink +import com.intellij.ui.treeStructure.Tree +import com.intellij.util.ui.JBUI +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.listeners.CodeWhispererCodeScanTreeMouseListener +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.addHorizontalGlue +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.INACTIVE_TEXT_COLOR +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import java.awt.Component +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.net.URI +import java.time.Instant +import java.time.format.DateTimeFormatter +import javax.swing.BorderFactory +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JTree +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.TreeCellRenderer + +/** + * Create a Code Scan results view that displays the code scan results. + */ +internal class CodeWhispererCodeScanResultsView(private val project: Project) : JPanel(BorderLayout()) { + + private val codeScanTree: Tree = Tree().apply { + isRootVisible = false + CodeWhispererCodeScanTreeMouseListener(project).installOn(this) + cellRenderer = ColoredTreeCellRenderer() + } + + private val scrollPane = ScrollPaneFactory.createScrollPane(codeScanTree, true) + private val splitter = OnePixelSplitter(CODE_SCAN_SPLITTER_PROPORTION_KEY, 1.0f).apply { + firstComponent = scrollPane + } + + private val toolbar = createToolbar().apply { + setTargetComponent(this@CodeWhispererCodeScanResultsView) + component.border = BorderFactory.createCompoundBorder( + CustomLineBorder(JBUI.insetsRight(1)), + component.border + ) + } + + private val infoLabelInitialText = message("codewhisperer.codescan.run_scan_info") + private val infoLabelPrefix = JLabel(infoLabelInitialText, JLabel.LEFT).apply { + icon = AllIcons.General.BalloonInformation + } + private val scannedFilesLabelLink = ActionLink().apply { + border = BorderFactory.createEmptyBorder(0, 7, 0, 0) + } + private val learnMoreLabelLink = ActionLink().apply { + border = BorderFactory.createEmptyBorder(0, 7, 0, 0) + } + + private val completeInfoLabel = JPanel(GridBagLayout()).apply { + layout = GridBagLayout() + border = BorderFactory.createCompoundBorder( + CustomLineBorder(JBUI.insetsBottom(1)), + BorderFactory.createEmptyBorder(7, 11, 8, 11) + ) + add(infoLabelPrefix, CodeWhispererLayoutConfig.inlineLabelConstraints) + add(scannedFilesLabelLink, CodeWhispererLayoutConfig.inlineLabelConstraints) + add(learnMoreLabelLink, CodeWhispererLayoutConfig.inlineLabelConstraints) + addHorizontalGlue() + } + + private val progressIndicatorLabel = JLabel(message("codewhisperer.codescan.scan_in_progress"), AnimatedIcon.Default(), JLabel.CENTER).apply { + border = BorderFactory.createEmptyBorder(7, 7, 7, 7) + } + private val stopCodeScanButton = JButton(message("codewhisperer.codescan.stop_scan")).apply { + addActionListener { + CodeWhispererCodeScanManager.getInstance(project).stopCodeScan() + } + } + + private val progressIndicator = JPanel(GridBagLayout()).apply { + add(progressIndicatorLabel, GridBagConstraints()) + add(stopCodeScanButton, GridBagConstraints().apply { gridy = 1 }) + } + + // Results panel containing info label and progressIndicator/scrollPane + private val resultsPanel = JPanel(BorderLayout()).apply { + add(BorderLayout.NORTH, completeInfoLabel) + } + + init { + add(BorderLayout.WEST, toolbar.component) + add(BorderLayout.CENTER, resultsPanel) + } + + fun getCodeScanTree() = codeScanTree + + /** + * Updates the [codeScanTree] with the new tree model root and displays the same on the UI. + */ + fun updateAndDisplayScanResults(scanTreeModel: CodeWhispererCodeScanTreeModel, scannedFiles: List, isProjectTruncated: Boolean) { + codeScanTree.apply { + model = scanTreeModel + repaint() + } + + scannedFilesLabelLink.addActionListener { + showScannedFiles(scannedFiles) + } + + if (isProjectTruncated) { + learnMoreLabelLink.addActionListener { + // TODO: Change this URL to point to updated security scan documentation + BrowserUtil.browse(URI("https://docs.aws.amazon.com/codewhisperer/latest/userguide/security-scans.html")) + } + } + + resultsPanel.apply { + if (components.contains(progressIndicator)) remove(progressIndicator) + add(BorderLayout.CENTER, splitter) + splitter.proportion = 1.0f + splitter.secondComponent = null + revalidate() + repaint() + } + + changeInfoLabelToDisplayScanCompleted(scannedFiles.size, isProjectTruncated) + } + + fun setStoppingCodeScan() { + completeInfoLabel.isVisible = false + stopCodeScanButton.isVisible = false + resultsPanel.apply { + if (components.contains(splitter)) remove(splitter) + progressIndicatorLabel.apply { + text = message("codewhisperer.codescan.stopping_scan") + revalidate() + repaint() + } + add(BorderLayout.CENTER, progressIndicator) + revalidate() + repaint() + } + } + + fun setDefaultUI() { + completeInfoLabel.isVisible = true + infoLabelPrefix.apply { + text = infoLabelInitialText + icon = AllIcons.General.BalloonInformation + isVisible = true + } + scannedFilesLabelLink.apply { + text = "" + isVisible = false + } + learnMoreLabelLink.apply { + text = "" + isVisible = false + } + resultsPanel.apply { + removeAll() + add(BorderLayout.NORTH, completeInfoLabel) + revalidate() + repaint() + } + } + + /** + * Shows in progress indicator indicating that the scan is in progress. + */ + fun showInProgressIndicator() { + completeInfoLabel.isVisible = false + + stopCodeScanButton.isVisible = true + progressIndicatorLabel.text = message("codewhisperer.codescan.scan_in_progress") + resultsPanel.apply { + if (components.contains(splitter)) remove(splitter) + add(BorderLayout.CENTER, progressIndicator) + revalidate() + repaint() + } + } + + /** + * Sets info label to show error in case a runtime exception is encountered while running a code scan. + */ + fun showError(errorMsg: String) { + completeInfoLabel.isVisible = true + infoLabelPrefix.apply { + text = errorMsg + icon = AllIcons.General.Error + isVisible = true + repaint() + } + scannedFilesLabelLink.text = "" + learnMoreLabelLink.text = "" + resultsPanel.apply { + if (components.contains(splitter)) remove(splitter) + if (components.contains(progressIndicator)) remove(progressIndicator) + revalidate() + repaint() + } + } + private fun showScannedFiles(files: List) { + val scannedFilesViewPanel = CodeWhispererCodeScanHighlightingFilesPanel(project, files) + scannedFilesViewPanel.apply { + isVisible = true + revalidate() + } + splitter.apply { + secondComponent = scannedFilesViewPanel + proportion = 0.5f + revalidate() + repaint() + } + } + + private fun changeInfoLabelToDisplayScanCompleted(numScannedFiles: Int, isProjectTruncated: Boolean) { + completeInfoLabel.isVisible = true + infoLabelPrefix.icon = AllIcons.Actions.Commit + infoLabelPrefix.text = message( + "codewhisperer.codescan.run_scan_complete", + numScannedFiles, + (codeScanTree.model as CodeWhispererCodeScanTreeModel).getTotalIssuesCount(), + project.name, + if (isProjectTruncated) 1 else 0, + INACTIVE_TEXT_COLOR, + DateTimeFormatter.ISO_INSTANT.format(Instant.now()) + ) + infoLabelPrefix.repaint() + infoLabelPrefix.isVisible = true + scannedFilesLabelLink.text = message("codewhisperer.codescan.view_scanned_files", numScannedFiles) + scannedFilesLabelLink.isVisible = true + if (isProjectTruncated) { + learnMoreLabelLink.apply { + text = message("aws.settings.learn_more") + isVisible = true + } + } + } + + private fun createToolbar(): ActionToolbar { + val actionManager = ActionManager.getInstance() + val group = actionManager.getAction("aws.toolkit.codewhisperer.toolbar.security") as ActionGroup + return actionManager.createActionToolbar(ACTION_PLACE, group, false) + } + + private class ColoredTreeCellRenderer : TreeCellRenderer { + override fun getTreeCellRendererComponent( + tree: JTree?, + value: Any?, + selected: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ): Component { + value as DefaultMutableTreeNode + val cell = JLabel() + synchronized(value) { + when (val obj = value.userObject) { + is VirtualFile -> { + cell.text = message("codewhisperer.codescan.file_name_issues_count", obj.name, obj.path, value.childCount, INACTIVE_TEXT_COLOR) + cell.icon = obj.fileType.icon + } + is CodeWhispererCodeScanIssue -> { + val cellText = "${obj.title}: ${obj.description.text}" + if (obj.isInvalid) { + cell.text = message("codewhisperer.codescan.scan_recommendation_invalid", obj.title, obj.displayTextRange(), INACTIVE_TEXT_COLOR) + cell.toolTipText = message("codewhisperer.codescan.scan_recommendation_invalid.tooltip_text") + cell.icon = AllIcons.General.Information + } else { + cell.text = message("codewhisperer.codescan.scan_recommendation", cellText, obj.displayTextRange(), INACTIVE_TEXT_COLOR) + cell.toolTipText = cellText + cell.icon = obj.issueSeverity.icon + } + } + } + } + return cell + } + } + + private companion object { + const val ACTION_PLACE = "CodeScanResultsPanel" + const val CODE_SCAN_SPLITTER_PROPORTION_KEY = "CODE_SCAN_SPLITTER_PROPORTION" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanSession.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanSession.kt new file mode 100644 index 0000000000..6654e9e305 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanSession.kt @@ -0,0 +1,408 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.util.TimeoutUtil.sleep +import com.intellij.util.io.HttpRequests +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.time.withTimeout +import org.apache.commons.codec.digest.DigestUtils +import software.amazon.awssdk.services.codewhisperer.model.ArtifactType +import software.amazon.awssdk.services.codewhisperer.model.CodeScanFindingsSchema +import software.amazon.awssdk.services.codewhisperer.model.CodeScanStatus +import software.amazon.awssdk.services.codewhisperer.model.CodeWhispererException +import software.amazon.awssdk.services.codewhisperer.model.CreateCodeScanRequest +import software.amazon.awssdk.services.codewhisperer.model.CreateCodeScanResponse +import software.amazon.awssdk.services.codewhisperer.model.GetCodeScanRequest +import software.amazon.awssdk.services.codewhisperer.model.GetCodeScanResponse +import software.amazon.awssdk.services.codewhisperer.model.ListCodeScanFindingsRequest +import software.amazon.awssdk.services.codewhisperer.model.ListCodeScanFindingsResponse +import software.amazon.awssdk.services.codewhispererruntime.model.CreateUploadUrlRequest +import software.amazon.awssdk.services.codewhispererruntime.model.CreateUploadUrlResponse +import software.amazon.awssdk.utils.IoUtils +import software.aws.toolkits.core.utils.Waiters.waitUntil +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.AwsClientManager +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig.CodeScanSessionConfig +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig.PayloadContext +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CodeScanResponseContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CodeScanServiceInvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.CODE_SCAN_POLLING_INTERVAL_IN_SECONDS +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.TOTAL_BYTES_IN_KB +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.TOTAL_MILLIS_IN_SECOND +import software.aws.toolkits.jetbrains.utils.assertIsNonDispatchThread +import software.aws.toolkits.telemetry.CodewhispererLanguage +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.nio.file.Path +import java.time.Duration +import java.time.Instant +import java.util.Base64 +import java.util.UUID +import kotlin.coroutines.coroutineContext + +class CodeWhispererCodeScanSession(val sessionContext: CodeScanSessionContext) { + private val clientToken: UUID = UUID.randomUUID() + private val urlResponse = mutableMapOf() + + private val clientAdaptor = CodeWhispererClientAdaptor.getInstance(sessionContext.project) + + private fun now() = Instant.now().toEpochMilli() + + /** + * Note that this function makes network calls and needs to be run from a background thread. + * Runs a code scan session which comprises the following steps: + * 1. Generate truncation (zip files) based on the truncation in the session context. + * 2. CreateUploadURL to upload the context. + * 3. Upload the zip files using the URL + * 4. Call createCodeScan to start a code scan + * 5. Keep polling the API GetCodeScan to wait for results for a given timeout period. + * 6. Return the results from the ListCodeScan API. + */ + suspend fun run(): CodeScanResponse { + var issues: List = listOf() + var codeScanResponseContext = defaultCodeScanResponseContext() + val currentCoroutineContext = coroutineContext + try { + assertIsNonDispatchThread() + currentCoroutineContext.ensureActive() + val startTime = now() + val (payloadContext, sourceZip) = withTimeout(Duration.ofSeconds(sessionContext.sessionConfig.createPayloadTimeoutInSeconds())) { + runReadAction { sessionContext.sessionConfig.createPayload() } + } + + LOG.debug { + "Total size of source payload in KB: ${payloadContext.srcPayloadSize * 1.0 / TOTAL_BYTES_IN_KB} \n" + + "Total size of build payload in KB: ${(payloadContext.buildPayloadSize ?: 0) * 1.0 / TOTAL_BYTES_IN_KB} \n" + + "Total size of source zip file in KB: ${payloadContext.srcZipFileSize * 1.0 / TOTAL_BYTES_IN_KB} \n" + + "Total number of lines scanned: ${payloadContext.totalLines} \n" + + "Total number of files included in payload: ${payloadContext.totalFiles} \n" + + "Total time taken for creating payload: ${payloadContext.totalTimeInMilliseconds * 1.0 / TOTAL_MILLIS_IN_SECOND} seconds\n" + + "Payload context language: ${payloadContext.language}" + } + codeScanResponseContext = codeScanResponseContext.copy(payloadContext = payloadContext) + + // 2 & 3. CreateUploadURL and upload the context. + currentCoroutineContext.ensureActive() + LOG.debug { "Uploading source zip located at ${sourceZip.path} to s3" } + val artifactsUploadStartTime = now() + val sourceZipUploadResponse = createUploadUrlAndUpload(sourceZip, "SourceCode") + LOG.debug { + "Successfully uploaded source zip to s3: " + + "Upload id: ${sourceZipUploadResponse.uploadId()} " + + "Request id: ${sourceZipUploadResponse.responseMetadata().requestId()}" + } + urlResponse[ArtifactType.SOURCE_CODE] = sourceZipUploadResponse + currentCoroutineContext.ensureActive() + val artifactsUploadDuration = now() - artifactsUploadStartTime + codeScanResponseContext = codeScanResponseContext.copy( + serviceInvocationContext = codeScanResponseContext.serviceInvocationContext.copy(artifactsUploadDuration = artifactsUploadDuration) + ) + + // 4. Call createCodeScan to start a code scan + currentCoroutineContext.ensureActive() + LOG.debug { "Requesting security scan for the uploaded artifacts, language: ${payloadContext.language}" } + val serviceInvocationStartTime = now() + val createCodeScanResponse = createCodeScan(payloadContext.language.toString()) + LOG.debug { + "Successfully created security scan with " + + "status: ${createCodeScanResponse.status()} " + + "for request id: ${createCodeScanResponse.responseMetadata().requestId()}" + } + var codeScanStatus = createCodeScanResponse.status() + if (codeScanStatus == CodeScanStatus.FAILED) { + LOG.debug { + "CodeWhisperer service error occurred. Something went wrong when creating a security scan: $createCodeScanResponse " + + "Status: ${createCodeScanResponse.status()} for request id: ${createCodeScanResponse.responseMetadata().requestId()}" + } + codeScanFailed() + } + val jobId = createCodeScanResponse.jobId() + codeScanResponseContext = codeScanResponseContext.copy(codeScanJobId = jobId) + + // 5. Keep polling the API GetCodeScan to wait for results for a given timeout period. + waitUntil( + succeedOn = { codeScanStatus == CodeScanStatus.COMPLETED }, + maxDuration = Duration.ofSeconds(sessionContext.sessionConfig.overallJobTimeoutInSeconds()) + ) { + currentCoroutineContext.ensureActive() + val elapsedTime = (now() - startTime) * 1.0 / TOTAL_MILLIS_IN_SECOND + LOG.debug { "Waiting for security scan to complete. Elapsed time: $elapsedTime sec." } + val getCodeScanResponse = getCodeScan(jobId) + codeScanStatus = getCodeScanResponse.status() + LOG.debug { + "Get security scan status: ${getCodeScanResponse.status()}, " + + "request id: ${getCodeScanResponse.responseMetadata().requestId()}" + } + sleepThread() + if (codeScanStatus == CodeScanStatus.FAILED) { + LOG.debug { + "CodeWhisperer service error occurred. Something went wrong fetching results for security scan: $getCodeScanResponse " + + "Status: ${getCodeScanResponse.status()} for request id: ${getCodeScanResponse.responseMetadata().requestId()}" + } + codeScanFailed() + } + } + + LOG.debug { "Security scan completed successfully by CodeWhisperer." } + + // 6. Return the results from the ListCodeScan API. + currentCoroutineContext.ensureActive() + LOG.debug { "Fetching results for the completed security scan" } + var listCodeScanFindingsResponse = listCodeScanFindings(jobId) + LOG.debug { + "Successfully fetched results for security scan with " + + "request id: ${listCodeScanFindingsResponse.responseMetadata().requestId()}" + } + val serviceInvocationDuration = now() - serviceInvocationStartTime + codeScanResponseContext = codeScanResponseContext.copy( + serviceInvocationContext = codeScanResponseContext.serviceInvocationContext.copy(serviceInvocationDuration = serviceInvocationDuration) + ) + + val documents = mutableListOf() + documents.add(listCodeScanFindingsResponse.codeScanFindings()) + while (listCodeScanFindingsResponse.nextToken() != null) { + documents.add(listCodeScanFindingsResponse.codeScanFindings()) + listCodeScanFindingsResponse = listCodeScanFindings(jobId) + } + LOG.debug { "Successfully fetched results for the security scan." } + LOG.debug { "Code scan findings: ${listCodeScanFindingsResponse.codeScanFindings()}" } + LOG.debug { "Rendering response to display security scan results." } + currentCoroutineContext.ensureActive() + issues = mapToCodeScanIssues(documents) + codeScanResponseContext = codeScanResponseContext.copy(codeScanTotalIssues = issues.count()) + codeScanResponseContext = codeScanResponseContext.copy(codeScanIssuesWithFixes = issues.count { it.suggestedFixes.isNotEmpty() }) + codeScanResponseContext = codeScanResponseContext.copy(reason = "Succeeded") + return CodeScanResponse.Success(issues, codeScanResponseContext) + } catch (e: Exception) { + val errorCode = (e as? CodeWhispererException)?.awsErrorDetails()?.errorCode() + val requestId = if (e is CodeWhispererException) e.requestId() else null + LOG.error { + "Failed to run security scan and display results. Caused by: ${e.message}, status code: $errorCode, " + + "exception: ${e::class.simpleName}, request ID: $requestId " + + "Jetbrains IDE: ${ApplicationInfo.getInstance().fullApplicationName}, " + + "IDE version: ${ApplicationInfo.getInstance().apiVersion}, " + + "stacktrace: ${e.stackTrace.contentDeepToString()}" + } + return CodeScanResponse.Failure(issues, codeScanResponseContext, e) + } + } + + /** + * Creates an upload URL and uplaods the zip file to the presigned URL + */ + fun createUploadUrlAndUpload(zipFile: File, artifactType: String): CreateUploadUrlResponse = try { + val fileMd5: String = Base64.getEncoder().encodeToString(DigestUtils.md5(FileInputStream(zipFile))) + LOG.debug { "Fetching presigned URL for uploading $artifactType." } + val createUploadUrlResponse = createUploadUrl(fileMd5, artifactType) + LOG.debug { "Successfully fetched presigned URL for uploading $artifactType." } + val url = createUploadUrlResponse.uploadUrl() + LOG.debug { "Uploading $artifactType using the presigned URL." } + uploadArtifactToS3(url, createUploadUrlResponse.uploadId(), zipFile, fileMd5, createUploadUrlResponse.kmsKeyArn()) + createUploadUrlResponse + } catch (e: Exception) { + LOG.error { "Security scan failed. Something went wrong uploading artifacts: ${e.message}" } + throw e + } + + fun createUploadUrl(md5Content: String, artifactType: String): CreateUploadUrlResponse = clientAdaptor.createUploadUrl( + CreateUploadUrlRequest.builder() + .contentMd5(md5Content) + .artifactType(artifactType) + .build() + ) + + @Throws(IOException::class) + fun uploadArtifactToS3(url: String, uploadId: String, fileToUpload: File, md5: String, kmsArn: String?) { + val uploadIdJson = """{"uploadId":"$uploadId"}""" + HttpRequests.put(url, "application/zip").userAgent(AwsClientManager.userAgent).tuner { + it.setRequestProperty(CONTENT_MD5, md5) + it.setRequestProperty(SERVER_SIDE_ENCRYPTION, AWS_KMS) + it.setRequestProperty(CONTENT_TYPE, APPLICATION_ZIP) + if (kmsArn?.isNotEmpty() == true) { + it.setRequestProperty(SERVER_SIDE_ENCRYPTION_AWS_KMS_KEY_ID, kmsArn) + } + it.setRequestProperty(SERVER_SIDE_ENCRYPTION_CONTEXT, Base64.getEncoder().encodeToString(uploadIdJson.toByteArray())) + }.connect { + val connection = it.connection as HttpURLConnection + connection.setFixedLengthStreamingMode(fileToUpload.length()) + IoUtils.copy(fileToUpload.inputStream(), connection.outputStream) + } + } + + fun createCodeScan(language: String): CreateCodeScanResponse { + val artifactsMap = mapOf( + ArtifactType.SOURCE_CODE to urlResponse[ArtifactType.SOURCE_CODE]?.uploadId(), + ArtifactType.BUILT_JARS to urlResponse[ArtifactType.BUILT_JARS]?.uploadId() + ).filter { (_, v) -> v != null } + + try { + return clientAdaptor.createCodeScan( + CreateCodeScanRequest.builder() + .clientToken(clientToken.toString()) + .programmingLanguage { it.languageName(language) } + .artifacts(artifactsMap) + .build() + ) + } catch (e: Exception) { + LOG.debug { "Creating security scan failed: ${e.message}" } + throw e + } + } + + fun getCodeScan(jobId: String): GetCodeScanResponse = try { + clientAdaptor.getCodeScan( + GetCodeScanRequest.builder() + .jobId(jobId) + .build() + ) + } catch (e: Exception) { + LOG.debug { "Getting security scan failed: ${e.message}" } + throw e + } + + fun listCodeScanFindings(jobId: String): ListCodeScanFindingsResponse = try { + clientAdaptor.listCodeScanFindings( + ListCodeScanFindingsRequest.builder() + .jobId(jobId) + .codeScanFindingsSchema(CodeScanFindingsSchema.CODESCAN_FINDINGS_1_0) + .build() + ) + } catch (e: Exception) { + LOG.debug { "Listing security scan failed: ${e.message}" } + throw e + } + + fun mapToCodeScanIssues(recommendations: List): List { + val scanRecommendations: List = recommendations.map { + val value: List = MAPPER.readValue(it) + value + }.flatten() + LOG.debug { "Total code scan issues returned from service: ${scanRecommendations.size}" } + return scanRecommendations.mapNotNull { + val file = try { + LocalFileSystem.getInstance().findFileByIoFile( + Path.of(sessionContext.sessionConfig.projectRoot.path, it.filePath).toFile() + ) + } catch (e: Exception) { + LOG.debug { "Cannot find file at location ${it.filePath}" } + null + } + when (file?.isDirectory) { + false -> { + runReadAction { + FileDocumentManager.getInstance().getDocument(file) + }?.let { document -> + val endCol = document.getLineEndOffset(it.endLine - 1) - document.getLineStartOffset(it.endLine - 1) + 1 + CodeWhispererCodeScanIssue( + startLine = it.startLine, + startCol = 1, + endLine = it.endLine, + endCol = endCol, + file = file, + project = sessionContext.project, + title = it.title, + description = it.description, + detectorId = it.detectorId, + detectorName = it.detectorName, + findingId = it.findingId, + ruleId = it.ruleId, + relatedVulnerabilities = it.relatedVulnerabilities, + severity = it.severity, + recommendation = it.remediation.recommendation, + suggestedFixes = it.remediation.suggestedFixes + ) + } + } + else -> null + } + }.onEach { issue -> + // Add range highlighters for all the issues found. + runInEdt { + issue.rangeHighlighter = issue.addRangeHighlighter() + } + } + } + + fun sleepThread() { + sleep(CODE_SCAN_POLLING_INTERVAL_IN_SECONDS * TOTAL_MILLIS_IN_SECOND) + } + + companion object { + private val LOG = getLogger() + private val MAPPER = jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + const val AWS_KMS = "aws:kms" + const val APPLICATION_ZIP = "application/zip" + const val CONTENT_MD5 = "Content-MD5" + const val CONTENT_TYPE = "Content-Type" + const val SERVER_SIDE_ENCRYPTION = "x-amz-server-side-encryption" + const val SERVER_SIDE_ENCRYPTION_AWS_KMS_KEY_ID = "x-amz-server-side-encryption-aws-kms-key-id" + const val SERVER_SIDE_ENCRYPTION_CONTEXT = "x-amz-server-side-encryption-context" + } +} + +sealed class CodeScanResponse { + abstract val issues: List + abstract val responseContext: CodeScanResponseContext + + data class Success( + override val issues: List, + override val responseContext: CodeScanResponseContext + ) : CodeScanResponse() + + data class Failure( + override val issues: List, + override val responseContext: CodeScanResponseContext, + val failureReason: Throwable + ) : CodeScanResponse() +} + +internal data class CodeScanRecommendation( + val filePath: String, + val startLine: Int, + val endLine: Int, + val title: String, + val description: Description, + val detectorId: String, + val detectorName: String, + val findingId: String, + val ruleId: String?, + val relatedVulnerabilities: List, + val severity: String, + val remediation: Remediation +) + +data class Description(val text: String, val markdown: String) + +data class Remediation(val recommendation: Recommendation, val suggestedFixes: List) + +data class Recommendation(val text: String, val url: String) + +data class SuggestedFix(val description: String, val code: String) + +data class CodeScanSessionContext( + val project: Project, + val sessionConfig: CodeScanSessionConfig +) + +internal fun defaultPayloadContext() = PayloadContext(CodewhispererLanguage.Unknown, 0, 0, 0, listOf(), 0, 0) + +internal fun defaultServiceInvocationContext() = CodeScanServiceInvocationContext(0, 0) + +internal fun defaultCodeScanResponseContext() = CodeScanResponseContext(defaultPayloadContext(), defaultServiceInvocationContext()) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanTreeModel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanTreeModel.kt new file mode 100644 index 0000000000..8bf936537c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/CodeWhispererCodeScanTreeModel.kt @@ -0,0 +1,39 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan + +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.DefaultTreeModel + +internal class CodeWhispererCodeScanTreeModel( + private val codeScanTreeNodeRoot: DefaultMutableTreeNode = DefaultMutableTreeNode("CodeWhisperer security scan results") +) : DefaultTreeModel(codeScanTreeNodeRoot) { + + override fun getRoot(): Any = codeScanTreeNodeRoot + + override fun getChild(parent: Any?, index: Int): Any { + parent as DefaultMutableTreeNode + return synchronized(parent) { parent.getChildAt(index) } + } + + override fun getChildCount(parent: Any?): Int { + parent as DefaultMutableTreeNode + return synchronized(parent) { parent.childCount } + } + + override fun isLeaf(node: Any?): Boolean { + node as DefaultMutableTreeNode + return synchronized(node) { node.isLeaf } + } + + override fun getIndexOfChild(parent: Any?, child: Any?): Int { + parent as DefaultMutableTreeNode + child as DefaultMutableTreeNode + return synchronized(parent) { parent.getIndex(child) } + } + + fun getTotalIssuesCount(): Int = synchronized(codeScanTreeNodeRoot) { + codeScanTreeNodeRoot.children().asSequence().sumBy { it.childCount } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/actions/CodeWhispererCodeScanRunAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/actions/CodeWhispererCodeScanRunAction.kt new file mode 100644 index 0000000000..9951559e5a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/actions/CodeWhispererCodeScanRunAction.kt @@ -0,0 +1,29 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.resources.message + +class CodeWhispererCodeScanRunAction : DumbAwareAction( + message("codewhisperer.codescan.run_scan"), + null, + AllIcons.Actions.Execute +) { + override fun update(event: AnActionEvent) { + val project = event.project ?: return + event.presentation.isEnabledAndVisible = isCodeWhispererEnabled(project) + val scanManager = CodeWhispererCodeScanManager.getInstance(project) + event.presentation.icon = scanManager.getRunActionButtonIcon() + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + CodeWhispererCodeScanManager.getInstance(project).runCodeScan() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/actions/CodeWhispererStopCodeScanAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/actions/CodeWhispererStopCodeScanAction.kt new file mode 100644 index 0000000000..bdaed87c4c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/actions/CodeWhispererStopCodeScanAction.kt @@ -0,0 +1,27 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager +import software.aws.toolkits.resources.message + +class CodeWhispererStopCodeScanAction : DumbAwareAction( + message("codewhisperer.codescan.stop_scan"), + null, + AllIcons.Actions.Suspend +) { + override fun update(event: AnActionEvent) { + val project = event.project ?: return + val scanManager = CodeWhispererCodeScanManager.getInstance(project) + event.presentation.isEnabled = scanManager.isCodeScanJobActive() + } + + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + CodeWhispererCodeScanManager.getInstance(project).stopCodeScan() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanDocumentListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanDocumentListener.kt new file mode 100644 index 0000000000..490c617e60 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanDocumentListener.kt @@ -0,0 +1,43 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.listeners + +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanIssue +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager +import javax.swing.tree.TreePath + +internal class CodeWhispererCodeScanDocumentListener(val project: Project) : DocumentListener { + + override fun documentChanged(event: DocumentEvent) { + val scanManager = CodeWhispererCodeScanManager.getInstance(project) + val treeModel = scanManager.getScanTree().model + val file = FileDocumentManager.getInstance().getFile(event.document) + if (file == null) { + LOG.error { "Cannot find file for document ${event.document}" } + return + } + val editedTextRange = TextRange.create(event.offset, event.offset + event.oldLength) + val nodes = scanManager.getOverlappingScanNodes(file, editedTextRange) + nodes.forEach { + val issue = it.userObject as CodeWhispererCodeScanIssue + synchronized(it) { + treeModel.valueForPathChanged(TreePath(it.path), issue.copy(isInvalid = true)) + } + issue.rangeHighlighter?.dispose() + issue.rangeHighlighter?.textAttributes = null + } + scanManager.updateScanNodes(file) + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanEditorMouseMotionListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanEditorMouseMotionListener.kt new file mode 100644 index 0000000000..8ec4466456 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanEditorMouseMotionListener.kt @@ -0,0 +1,308 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.listeners + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.event.EditorMouseEvent +import com.intellij.openapi.editor.event.EditorMouseEventArea +import com.intellij.openapi.editor.event.EditorMouseMotionListener +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.psi.PsiDocumentManager +import com.intellij.ui.JBColor +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBScrollPane +import icons.AwsIcons +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.services.codewhisperer.codescan.CodeWhispererCodeScanIssue +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.getHexString +import software.aws.toolkits.jetbrains.utils.applyPatch +import software.aws.toolkits.jetbrains.utils.convertMarkdownToHTML +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import java.awt.Dimension +import javax.swing.BorderFactory +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.Icon +import javax.swing.JButton +import javax.swing.JEditorPane +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.HTMLEditorKit + +class CodeWhispererCodeScanEditorMouseMotionListener(private val project: Project) : EditorMouseMotionListener { + /** + * Current context for popup is still being shown. + */ + private var currentPopupContext: ScanIssuePopupContext? = null + + private val codeBlockBackgroundColor = JBColor.namedColor("Editor.background", JBColor(0xf7f8fa, 0x2b2d30)) + private val codeBlockForegroundColor = JBColor.namedColor("Editor.foreground", JBColor(0x808080, 0xdfe1e5)) + private val codeBlockBorderColor = JBColor.namedColor("borderColor", JBColor(0xebecf0, 0x1e1f22)) + private val deletionBackgroundColor = JBColor.namedColor("FileColor.Rose", JBColor(0xf5c2c2, 0x511e1e)) + private val deletionForegroundColor = JBColor.namedColor("Label.errorForeground", JBColor(0xb63e3e, 0xfc6479)) + private val additionBackgroundColor = JBColor.namedColor("FileColor.Green", JBColor(0xdde9c1, 0x394323)) + private val additionForegroundColor = JBColor.namedColor("Label.successForeground", JBColor(0x42a174, 0xacc49e)) + private val metaBackgroundColor = JBColor.namedColor("FileColor.Blue", JBColor(0xeaf6ff, 0x4f556b)) + private val metaForegroundColor = JBColor.namedColor("Label.infoForeground", JBColor(0x808080, 0x8C8C8C)) + + private fun hidePopup() { + currentPopupContext?.popup?.cancel() + currentPopupContext = null + } + + private fun getHtml(issue: CodeWhispererCodeScanIssue): String { + val isFixAvailable = issue.suggestedFixes.isNotEmpty() + + val cweLinks = if (issue.relatedVulnerabilities.isNotEmpty()) { + issue.relatedVulnerabilities.joinToString(", ") { cwe -> + "$cwe" + } + } else { + "-" + } + + val detectorLibraryLink = "${issue.detectorName}" + + val detectorSection = """ +
+
+ + + + + + + + + + + + + + + +
${message("codewhisperer.codescan.cwe_label")}${message("codewhisperer.codescan.fix_available_label")}${message("codewhisperer.codescan.detector_library_label")}
$cweLinks${if (isFixAvailable) "Yes" else "No" }$detectorLibraryLink
+ """.trimIndent() + + val suggestedFixSection = if (isFixAvailable) { + val isFixDescriptionAvailable = issue.suggestedFixes[0].description.isNotBlank() && + issue.suggestedFixes[0].description.trim() != "Suggested remediation:" + """ + |
+ |
+ | + |## ${message("codewhisperer.codescan.suggested_fix_label")} + | + |```diff + |${issue.suggestedFixes[0].code} + |``` + | + |${if (isFixDescriptionAvailable) "|### ${message("codewhisperer.codescan.suggested_fix_description")}\n${issue.suggestedFixes[0].description}" else ""} + """.trimMargin() + } else { + "" + } + + return convertMarkdownToHTML( + """ + |${issue.recommendation.text} + | + |$detectorSection + | + |$suggestedFixSection + """.trimMargin() + ) + } + + private fun getSeverityIcon(issue: CodeWhispererCodeScanIssue): Icon? = when (issue.severity) { + "Info" -> AwsIcons.Resources.CodeWhisperer.SEVERITY_INFO + "Low" -> AwsIcons.Resources.CodeWhisperer.SEVERITY_LOW + "Medium" -> AwsIcons.Resources.CodeWhisperer.SEVERITY_MEDIUM + "High" -> AwsIcons.Resources.CodeWhisperer.SEVERITY_HIGH + "Critical" -> AwsIcons.Resources.CodeWhisperer.SEVERITY_CRITICAL + else -> null + } + + private fun showPopup(issues: List, e: EditorMouseEvent, issueIndex: Int = 0) { + if (issues == null || issues.isEmpty()) { + LOG.debug { + "Unable to show popup issue at ${e.logicalPosition} as the issue was null" + } + return + } + + val issue = issues[issueIndex] + val content = getHtml(issue) + val kit = HTMLEditorKit() + kit.styleSheet.apply { + addRule("h1, h3 { margin-bottom: 0 }") + addRule("th { text-align: left; }") + addRule(".code-block { background-color: ${codeBlockBackgroundColor.getHexString()}; border: 1px solid ${codeBlockBorderColor.getHexString()}; }") + addRule(".code-block pre { margin: 0; }") + addRule(".code-block div { color: ${codeBlockForegroundColor.getHexString()}; }") + addRule( + ".code-block div.deletion { background-color: ${deletionBackgroundColor.getHexString()}; color: ${deletionForegroundColor.getHexString()}; }" + ) + addRule( + ".code-block div.addition { background-color: ${additionBackgroundColor.getHexString()}; color: ${additionForegroundColor.getHexString()}; }" + ) + addRule(".code-block div.meta { background-color: ${metaBackgroundColor.getHexString()}; color: ${metaForegroundColor.getHexString()}; }") + } + val doc = kit.createDefaultDocument() + val editorPane = JEditorPane().apply { + contentType = "text/html" + putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) + border = BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(), + BorderFactory.createEmptyBorder(7, 11, 8, 11) + ) + isEditable = false + addHyperlinkListener { he -> + if (he.eventType == HyperlinkEvent.EventType.ACTIVATED) { + BrowserUtil.browse(he.url) + } + } + editorKit = kit + document = doc + text = content + caretPosition = 0 + } + val scrollPane = JBScrollPane(editorPane).apply { + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + } + val label = JLabel(issue.title).apply { + icon = getSeverityIcon(issue) + horizontalTextPosition = JLabel.LEFT + } + val button = JButton(message("codewhisperer.codescan.apply_fix_button_label")).apply { + toolTipText = message("codewhisperer.codescan.apply_fix_button_tooltip") + addActionListener { + handleApplyFix(issue) + } + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + } + val nextButton = JButton(AllIcons.Actions.ArrowExpand).apply { + preferredSize = Dimension(30, this.height) + addActionListener { + hidePopup() + showPopup(issues, e, (issueIndex + 1) % issues.size) + } + } + val prevButton = JButton(AllIcons.Actions.ArrowCollapse).apply { + preferredSize = Dimension(30, this.height) + addActionListener { + hidePopup() + showPopup(issues, e, (issues.size - (issueIndex + 1)) % issues.size) + } + } + + val titlePane = JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + preferredSize = Dimension(this.width, 30) + add(Box.createHorizontalGlue()) + add(label) + add(Box.createHorizontalGlue()) + if (issues.size > 1) { + add(prevButton) + add(JLabel("${issueIndex + 1} of ${issues.size}")) + add(nextButton) + } + + if (issue.suggestedFixes.isNotEmpty()) { + add(button) + } + } + + val containerPane = JPanel().apply { + layout = BoxLayout(this, BoxLayout.PAGE_AXIS) + add(titlePane) + add(scrollPane) + preferredSize = Dimension(650, 350) + } + + val popup = JBPopupFactory.getInstance().createComponentPopupBuilder(containerPane, null).setFocusable(true).setResizable(true) + .createPopup() + // Set the currently shown issue popup context as this issue + currentPopupContext = ScanIssuePopupContext(issue, popup) + + popup.show(RelativePoint(e.mouseEvent)) + + CodeWhispererTelemetryService.getInstance().sendCodeScanIssueHoverEvent(issue) + } + + override fun mouseMoved(e: EditorMouseEvent) { + val scanManager = CodeWhispererCodeScanManager.getInstance(project) + if (e.area != EditorMouseEventArea.EDITING_AREA || !e.isOverText) { + hidePopup() + return + } + val offset = e.offset + val file = FileDocumentManager.getInstance().getFile(e.editor.document) + if (file == null) { + LOG.error { "Cannot find file for the document ${e.editor.document}" } + return + } + val issuesInRange = scanManager.getScanNodesInRange(file, offset).map { + it.userObject as CodeWhispererCodeScanIssue + } + if (issuesInRange.isEmpty()) { + hidePopup() + return + } + if (issuesInRange.contains(currentPopupContext?.issue)) return + + // No popups should be visible at this point. + hidePopup() + // Show popup for only the first issue found. + // Only add popup if the issue is still valid. If the issue has gone stale or invalid because + // the user has made some edits, we don't need to show the popup for the stale or invalid issues. + if (!issuesInRange.first().isInvalid) showPopup(issuesInRange, e) + } + + private data class ScanIssuePopupContext(val issue: CodeWhispererCodeScanIssue, val popup: JBPopup) + + companion object { + private val LOG = getLogger() + } + + private fun handleApplyFix(issue: CodeWhispererCodeScanIssue) { + try { + WriteCommandAction.runWriteCommandAction(issue.project) { + val document = FileDocumentManager.getInstance().getDocument(issue.file) ?: return@runWriteCommandAction + + val documentContent = document.text + val updatedContent = applyPatch(issue.suggestedFixes[0].code, documentContent, issue.file.name) ?: return@runWriteCommandAction + document.replaceString(document.getLineStartOffset(0), document.getLineEndOffset(document.lineCount - 1), updatedContent) + PsiDocumentManager.getInstance(issue.project).commitDocument(document) + notifyInfo( + message("codewhisperer.codescan.fix_applied_success"), + ) + CodeWhispererTelemetryService.getInstance().sendCodeScanIssueApplyFixEvent(issue, Result.Succeeded) + hidePopup() + } + } catch (err: Error) { + notifyError(message("codewhisperer.codescan.fix_applied_fail", err)) + LOG.error { "Apply fix command failed. $err" } + CodeWhispererTelemetryService.getInstance().sendCodeScanIssueApplyFixEvent(issue, Result.Failed, err.message) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanTreeMouseListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanTreeMouseListener.kt new file mode 100644 index 0000000000..5e6fba3468 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/listeners/CodeWhispererCodeScanTreeMouseListener.kt @@ -0,0 +1,53 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.listeners + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.ui.DoubleClickListener +import com.intellij.ui.treeStructure.Tree +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanIssue +import java.awt.event.MouseEvent +import javax.swing.tree.DefaultMutableTreeNode + +class CodeWhispererCodeScanTreeMouseListener(private val project: Project) : DoubleClickListener() { + override fun onDoubleClick(e: MouseEvent): Boolean { + val codeScanNode = (e.source as Tree).selectionPath?.lastPathComponent as? DefaultMutableTreeNode ?: return false + var status = true + synchronized(codeScanNode) { + if (codeScanNode.userObject !is CodeWhispererCodeScanIssue) return false + val codeScanIssue = codeScanNode.userObject as CodeWhispererCodeScanIssue + val textRange = codeScanIssue.textRange ?: return false + val startOffset = textRange.startOffset + + if (codeScanIssue.isInvalid) return false + + runInEdt { + val editor = FileEditorManager.getInstance(project).openTextEditor( + OpenFileDescriptor(project, codeScanIssue.file, startOffset), + true + ) + if (editor == null) { + LOG.error { "Cannot fetch editor for the file ${codeScanIssue.file.path}" } + status = false + return@runInEdt + } + // If the codeScanIssue is still valid and rangehighlighter was not added previously for some reason, + // try adding again. + if (!codeScanIssue.isInvalid && codeScanIssue.rangeHighlighter == null) { + codeScanIssue.rangeHighlighter = codeScanIssue.addRangeHighlighter(editor.markupModel) + } + } + } + return status + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CloudFormationJsonCodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CloudFormationJsonCodeScanSessionConfig.kt new file mode 100644 index 0000000000..eeda4d68e2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CloudFormationJsonCodeScanSessionConfig.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants + +internal class CloudFormationJsonCodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project +) : CodeScanSessionConfig(selectedFile, project) { + + override val sourceExt: List = listOf(".json") + + override fun overallJobTimeoutInSeconds(): Long = CodeWhispererConstants.CLOUDFORMATION_CODE_SCAN_TIMEOUT_IN_SECONDS + + override fun getPayloadLimitInBytes(): Int = CodeWhispererConstants.CLOUDFORMATION_PAYLOAD_LIMIT_IN_BYTES +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CloudFormationYamlCodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CloudFormationYamlCodeScanSessionConfig.kt new file mode 100644 index 0000000000..186473daea --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CloudFormationYamlCodeScanSessionConfig.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants + +internal class CloudFormationYamlCodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project +) : CodeScanSessionConfig(selectedFile, project) { + + override val sourceExt: List = listOf(".yaml", ".yml") + + override fun overallJobTimeoutInSeconds(): Long = CodeWhispererConstants.CLOUDFORMATION_CODE_SCAN_TIMEOUT_IN_SECONDS + + override fun getPayloadLimitInBytes(): Int = CodeWhispererConstants.CLOUDFORMATION_PAYLOAD_LIMIT_IN_BYTES +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CodeScanSessionConfig.kt new file mode 100644 index 0000000000..4f13f33c63 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CodeScanSessionConfig.kt @@ -0,0 +1,254 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VFileProperty +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.time.withTimeout +import software.aws.toolkits.core.utils.createTemporaryZipFile +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.putNextEntry +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.fileFormatNotSupported +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.fileTooLarge +import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.CODE_SCAN_CREATE_PAYLOAD_TIMEOUT_IN_SECONDS +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.TOTAL_BYTES_IN_KB +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.TOTAL_BYTES_IN_MB +import software.aws.toolkits.telemetry.CodewhispererLanguage +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.time.Instant +import kotlin.io.path.relativeTo + +sealed class CodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project +) { + var projectRoot = project.guessProjectDir() ?: error("Cannot guess base directory for project ${project.name}") + private set + + abstract val sourceExt: List + + private var isProjectTruncated = false + + /** + * Timeout for the overall job - "Run Security Scan". + */ + abstract fun overallJobTimeoutInSeconds(): Long + + abstract fun getPayloadLimitInBytes(): Int + + protected fun willExceedPayloadLimit(currentTotalFileSize: Long, currentFileSize: Long): Boolean { + val exceedsLimit = currentTotalFileSize > getPayloadLimitInBytes() - currentFileSize + isProjectTruncated = isProjectTruncated || exceedsLimit + return exceedsLimit + } + + open fun getImportedFiles(file: VirtualFile, includedSourceFiles: Set): List = listOf() + + open fun getSelectedFile(): VirtualFile = selectedFile + + open fun createPayload(): Payload { + // Fail fast if the selected file size is greater than the payload limit. + if (selectedFile.length > getPayloadLimitInBytes()) { + fileTooLarge(getPresentablePayloadLimit()) + } + + val start = Instant.now().toEpochMilli() + + LOG.debug { "Creating payload. File selected as root for the context truncation: ${selectedFile.path}" } + + val payloadMetadata = when (selectedFile.path.startsWith(projectRoot.path)) { + true -> includeDependencies() + false -> { + // Set project root as the parent of the selected file. + projectRoot = selectedFile.parent + includeFileOutsideProjectRoot() + } + } + + // Copy all the included source files to the source zip + val srcZip = zipFiles(payloadMetadata.sourceFiles.map { Path.of(it) }) + val payloadContext = PayloadContext( + selectedFile.programmingLanguage().toTelemetryType(), + payloadMetadata.linesScanned, + payloadMetadata.sourceFiles.size, + Instant.now().toEpochMilli() - start, + payloadMetadata.sourceFiles.mapNotNull { Path.of(it).toFile().toVirtualFile() }, + payloadMetadata.payloadSize, + srcZip.length() + ) + + return Payload(payloadContext, srcZip) + } + + open fun includeFileOutsideProjectRoot(): PayloadMetadata = + // Handle the case where the selected file is outside the project root. + PayloadMetadata( + setOf(selectedFile.path), + selectedFile.length, + Files.lines(selectedFile.toNioPath()).count().toLong() + ) + + open fun includeDependencies(): PayloadMetadata { + val includedSourceFiles = mutableSetOf() + var currentTotalFileSize = 0L + var currentTotalLines = 0L + val files = getSourceFilesUnderProjectRoot(selectedFile) + val queue = ArrayDeque() + + files.forEach { pivotFile -> + val filePath = pivotFile.path + queue.addLast(filePath) + + // BFS + while (queue.isNotEmpty()) { + if (currentTotalFileSize.equals(getPayloadLimitInBytes())) { + return PayloadMetadata(includedSourceFiles, currentTotalFileSize, currentTotalLines) + } + + val currentFilePath = queue.removeFirst() + val currentFile = File(currentFilePath).toVirtualFile() + if (includedSourceFiles.contains(currentFilePath) || + currentFile == null || + willExceedPayloadLimit(currentTotalFileSize, currentFile.length) + ) { + continue + } + + val currentFileSize = currentFile.length + + currentTotalFileSize += currentFileSize + currentTotalLines += Files.lines(currentFile.toNioPath()).count() + includedSourceFiles.add(currentFilePath) + getImportedFiles(currentFile, includedSourceFiles).forEach { + if (!includedSourceFiles.contains(it)) queue.addLast(it) + } + } + } + + return PayloadMetadata(includedSourceFiles, currentTotalFileSize, currentTotalLines) + } + + /** + * Timeout for creating the payload [createPayload] + */ + open fun createPayloadTimeoutInSeconds(): Long = CODE_SCAN_CREATE_PAYLOAD_TIMEOUT_IN_SECONDS + + open fun getPresentablePayloadLimit(): String = when (getPayloadLimitInBytes() >= TOTAL_BYTES_IN_MB) { + true -> "${getPayloadLimitInBytes() / TOTAL_BYTES_IN_MB}MB" + false -> "${getPayloadLimitInBytes() / TOTAL_BYTES_IN_KB}KB" + } + + open suspend fun getTotalProjectSizeInBytes(): Long { + var totalSize = 0L + try { + withTimeout(Duration.ofSeconds(TELEMETRY_TIMEOUT_IN_SECONDS)) { + VfsUtil.collectChildrenRecursively(projectRoot).filter { + !it.isDirectory && !it.`is`((VFileProperty.SYMLINK)) && ( + it.path.endsWith(sourceExt[0]) || ( + sourceExt.getOrNull(1) != null && it.path.endsWith( + sourceExt[1] + ) + ) + ) + }.fold(0L) { acc, next -> + totalSize = acc + next.length + totalSize + } + } + } catch (e: TimeoutCancellationException) { + // Do nothing + } + return totalSize + } + + protected fun zipFiles(files: List): File = createTemporaryZipFile { + files.forEach { file -> + val relativePath = file.relativeTo(projectRoot.toNioPath()) + LOG.debug { "Selected file for truncation: $file" } + it.putNextEntry(relativePath.toString(), file) + } + }.toFile() + + /** + * Returns all the source files for a given payload type. + */ + open fun getSourceFilesUnderProjectRoot(selectedFile: VirtualFile): List { + // Include the current selected file + val files = mutableListOf(selectedFile) + // Include other files only if the current file is in the project. + if (selectedFile.path.startsWith(projectRoot.path)) { + files.addAll( + VfsUtil.collectChildrenRecursively(projectRoot).filter { + (it.path.endsWith(sourceExt[0]) || (sourceExt.getOrNull(1) != null && it.path.endsWith(sourceExt[1]))) && it != selectedFile + } + ) + } + return files + } + + open fun isProjectTruncated() = isProjectTruncated + + protected fun getPath(root: String, relativePath: String = ""): Path? = try { + Path.of(root, relativePath).normalize() + } catch (e: Exception) { + LOG.debug { "Cannot find file at path $relativePath relative to the root $root" } + null + } + + protected fun File.toVirtualFile() = LocalFileSystem.getInstance().findFileByIoFile(this) + + companion object { + private val LOG = getLogger() + private const val TELEMETRY_TIMEOUT_IN_SECONDS: Long = 10 + const val FILE_SEPARATOR = '/' + fun create(file: VirtualFile, project: Project): CodeScanSessionConfig = + when (file.programmingLanguage().toTelemetryType()) { + CodewhispererLanguage.Java -> JavaCodeScanSessionConfig(file, project) + CodewhispererLanguage.Python -> PythonCodeScanSessionConfig(file, project) + CodewhispererLanguage.Javascript -> JavaScriptCodeScanSessionConfig(file, project, CodewhispererLanguage.Javascript) + CodewhispererLanguage.Typescript -> JavaScriptCodeScanSessionConfig(file, project, CodewhispererLanguage.Typescript) + CodewhispererLanguage.Csharp -> CsharpCodeScanSessionConfig(file, project) + CodewhispererLanguage.Yaml -> CloudFormationYamlCodeScanSessionConfig(file, project) + CodewhispererLanguage.Json -> CloudFormationJsonCodeScanSessionConfig(file, project) + CodewhispererLanguage.Tf, + CodewhispererLanguage.Hcl -> TerraformCodeScanSessionConfig(file, project) + CodewhispererLanguage.Go -> GoCodeScanSessionConfig(file, project) + CodewhispererLanguage.Ruby -> RubyCodeScanSessionConfig(file, project) + else -> fileFormatNotSupported(file.extension ?: "") + } + } +} + +data class Payload( + val context: PayloadContext, + val srcZip: File +) + +data class PayloadContext( + val language: CodewhispererLanguage, + val totalLines: Long, + val totalFiles: Int, + val totalTimeInMilliseconds: Long, + val scannedFiles: List, + val srcPayloadSize: Long, + val srcZipFileSize: Long, + val buildPayloadSize: Long? = null +) + +data class PayloadMetadata( + val sourceFiles: Set, + val payloadSize: Long, + val linesScanned: Long, + val buildPaths: Set = setOf() +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CsharpCodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CsharpCodeScanSessionConfig.kt new file mode 100644 index 0000000000..997f89a6fc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/CsharpCodeScanSessionConfig.kt @@ -0,0 +1,111 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.containers.addIfNotNull +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.resources.message +import java.io.IOException + +internal class CsharpCodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project +) : CodeScanSessionConfig(selectedFile, project) { + + private val importRegex = Regex("^(global\\s)?using\\s(static\\s)?((\\b[A-Z][A-Za-z]+(\\.\\b[A-Z][A-Za-z]+)*)|\\w+\\s*=\\s*([\\w.]+));$") + + private val projectContentRoots = ProjectRootManager.getInstance(project).contentRoots + override val sourceExt: List = listOf(".cs") + + override fun overallJobTimeoutInSeconds(): Long = CodeWhispererConstants.CSHARP_CODE_SCAN_TIMEOUT_IN_SECONDS + + // Payload Size for C#: 1MB + override fun getPayloadLimitInBytes(): Int = CodeWhispererConstants.CSHARP_PAYLOAD_LIMIT_IN_BYTES + + // Generate the combinations for module paths + private fun generateModulePaths(inputPath: String): MutableSet { + val inputPaths = inputPath.split('.') + val outputPaths = mutableSetOf() + for (i in inputPaths.indices) { + val outputPath = inputPaths.subList(0, i + 1).joinToString(FILE_SEPARATOR.toString()) + outputPaths.add(outputPath) + } + return outputPaths + } + + private fun getModulePath(modulePathString: String): MutableSet { + val index = modulePathString.indexOf("=") + val modulePathStrings = if (index != -1) { + modulePathString.substring(index + 1) + } else { + modulePathString + } + return generateModulePaths(modulePathStrings.trim()) + } + + private fun extractModulePaths(modulePathLine: String): Set { + val modulePaths = mutableSetOf() + // Check if Import statement starts with either "using" or "global using" + if (modulePathLine.startsWith(CodeWhispererConstants.USING) || modulePathLine.startsWith(CodeWhispererConstants.GLOBAL_USING)) { + // Check for "static" keyword in the Import statement + val indexStatic = modulePathLine.indexOf(CodeWhispererConstants.STATIC) + if (indexStatic != -1) { + val modulePathString = modulePathLine.substring(indexStatic + CodeWhispererConstants.STATIC.length).trim() + modulePaths.addAll(getModulePath(modulePathString.replace(" ", ""))) + } else { + // Check for "using" keyword in the Import statement + val indexOfUsing = modulePathLine.indexOf(CodeWhispererConstants.USING) + if (indexOfUsing != -1) { + val modulePathString = modulePathLine.substring(indexOfUsing + CodeWhispererConstants.USING.length).trim() + modulePaths.addAll(getModulePath(modulePathString.replace(" ", ""))) + } + } + } + return modulePaths.toSet() + } + + fun parseImports(file: VirtualFile): List { + val imports = mutableSetOf() + try { + file.inputStream.use { + it.bufferedReader().lines().forEach { line -> + val importMatcher = importRegex.toPattern().matcher(line) + if (importMatcher.find()) { + val modulePathLine = line.replace(";", "") + val goalImports = extractModulePaths(modulePathLine) + imports.addAll(goalImports) + } + } + } + } catch (e: IOException) { + error(message("codewhisperer.codescan.cannot_read_file", file.path)) + } + return imports.toList() + } + + override fun getImportedFiles(file: VirtualFile, includedSourceFiles: Set): List { + val importedFiles = mutableListOf() + val imports = parseImports(file) + val importedFilePaths = mutableListOf() + + projectContentRoots.forEach { root -> + imports.forEach { importPath -> + val importedFilePath = getPath(root.path, importPath + sourceExt[0]) + if (importedFilePath?.exists() == true) { + importedFilePaths.addIfNotNull(importedFilePath.toFile().toVirtualFile()?.path) + } + } + } + + val validSourceFiles = importedFilePaths.filter { !includedSourceFiles.contains(it) } + validSourceFiles.forEach { validFile -> + importedFiles.add(validFile) + } + return importedFiles + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/GoCodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/GoCodeScanSessionConfig.kt new file mode 100644 index 0000000000..30cbd256ca --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/GoCodeScanSessionConfig.kt @@ -0,0 +1,114 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.containers.addIfNotNull +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.resources.message +import java.io.IOException +import java.nio.file.Path +import java.util.stream.Collectors +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries + +internal class GoCodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project +) : CodeScanSessionConfig(selectedFile, project) { + private val importRegex = Regex("^\\s*import\\s+([^(]+?\$|\\([^)]+\\))", RegexOption.MULTILINE) + private val moduleRegex = Regex("\"[^\"\\r\\n]+\"", RegexOption.MULTILINE) + + private val projectContentRoots = ProjectRootManager.getInstance(project).contentRoots + + override val sourceExt: List = listOf(".go") + + override fun overallJobTimeoutInSeconds(): Long = CodeWhispererConstants.GO_CODE_SCAN_TIMEOUT_IN_SECONDS + + override fun getPayloadLimitInBytes(): Int = CodeWhispererConstants.GO_PAYLOAD_LIMIT_IN_BYTES + + private fun extractModulePaths(importGroup: String): Set { + val modulePaths = mutableSetOf() + val moduleMatcher = moduleRegex.toPattern().matcher(importGroup) + while (moduleMatcher.find()) { + val match = moduleMatcher.group() + modulePaths.add(match.substring(1, match.length - 1)) + } + return modulePaths.toSet() + } + + fun parseImports(file: VirtualFile): List { + val imports = mutableSetOf() + try { + file.inputStream.use { + val lines = it.bufferedReader().lines().collect(Collectors.joining("\n")) + val importMatcher = importRegex.toPattern().matcher(lines) + while (importMatcher.find()) { + val goalImports = extractModulePaths(importMatcher.group()) + imports.addAll(goalImports) + } + } + } catch (e: IOException) { + error(message("codewhisperer.codescan.cannot_read_file", file.path)) + } + return imports.toList() + } + + private fun generateSourceFilePath(modulePath: String, dirPath: String): Path? { + if (modulePath.isEmpty()) { + return null + } + val packageDir = getPath(dirPath, modulePath) + val slashPos = modulePath.indexOf("/") + val newModulePath = if (slashPos != -1) modulePath.substring(slashPos + 1) else "" + return if (packageDir?.exists() == true) packageDir else generateSourceFilePath(newModulePath, dirPath) + } + + private fun getImportedPackages(file: VirtualFile): List { + val importedPackages = mutableListOf() + val imports = parseImports(file) + projectContentRoots.forEach { root -> + imports.forEach { importPath -> + val importedFilePath = generateSourceFilePath(importPath, root.path) + importedPackages.addIfNotNull(importedFilePath) + } + } + return importedPackages + } + + private fun getSiblingFiles(file: VirtualFile): List = listGoFilesInDir(file.parent.toNioPath()).filter { + it.fileName.toString() != file.name + } + + private fun listGoFilesInDir(path: Path): List = path.listDirectoryEntries().filter { + !it.isDirectory() && it.fileName.toString().endsWith(sourceExt[0]) + } + + override fun getImportedFiles(file: VirtualFile, includedSourceFiles: Set): List { + val importedFiles = mutableListOf() + val importedFilePaths = mutableListOf() + + val siblingFiles = getSiblingFiles(file) + siblingFiles.forEach { sibling -> + importedFilePaths.addIfNotNull(sibling.toFile().toVirtualFile()?.path) + } + + val importedPackages = getImportedPackages(file) + importedPackages.forEach { pkg -> + val files = listGoFilesInDir(pkg) + .mapNotNull { it.toFile().toVirtualFile()?.path } + importedFilePaths.addAll(files) + } + + val validSourceFiles = importedFilePaths.filter { !includedSourceFiles.contains(it) } + validSourceFiles.forEach { validFile -> + importedFiles.add(validFile) + } + + return importedFiles + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/JavaCodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/JavaCodeScanSessionConfig.kt new file mode 100644 index 0000000000..45733ba3ce --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/JavaCodeScanSessionConfig.kt @@ -0,0 +1,241 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.compiler.CompilerPaths +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.rootManager +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.jps.model.java.JavaSourceRootType +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.fileTooLarge +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.JAVA_CODE_SCAN_TIMEOUT_IN_SECONDS +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.JAVA_PAYLOAD_LIMIT_IN_BYTES +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodewhispererLanguage +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant + +internal class JavaCodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project +) : CodeScanSessionConfig(selectedFile, project) { + + private val packageRegex = Regex("package\\s+([\\w.]+)\\s*;") + private val importRegex = Regex("import\\s+([\\w.]+[*]?)\\s*;") + private val buildExt = ".class" + override val sourceExt: List = listOf(".java") + + data class JavaImportsInfo(val imports: List, val packagePath: String) + + override fun overallJobTimeoutInSeconds(): Long = JAVA_CODE_SCAN_TIMEOUT_IN_SECONDS + + override fun getPayloadLimitInBytes(): Int = JAVA_PAYLOAD_LIMIT_IN_BYTES + + override fun createPayload(): Payload { + // Fail fast if the selected file size is greater than the payload limit. + if (selectedFile.length > getPayloadLimitInBytes()) { + fileTooLarge(getPresentablePayloadLimit()) + } + + val start = Instant.now().toEpochMilli() + + LOG.debug { "Creating payload. File selected as root for the context truncation: ${selectedFile.path}" } + + // Include all the dependencies using BFS + val (sourceFiles, srcPayloadSize, totalLines, buildPaths) = includeDependencies() + + val outputPaths = CompilerPaths.getOutputPaths(ModuleManager.getInstance(project).modules) + var totalBuildPayloadSize = 0L + val buildFiles = buildPaths.mapNotNull { relativePath -> + val classFile = findClassFile(relativePath, outputPaths) + if (classFile == null) { + LOG.debug { "Cannot find class file for $relativePath" } + } else { + totalBuildPayloadSize += classFile.toFile().length() + } + classFile + } + LOG.debug { "Total build files sent in payload: ${buildFiles.size}" } + + // Copy all the included source and build files to the source zip + val srcZip = zipFiles(sourceFiles.mapNotNull { getPath(it) } + buildFiles) + + val payloadContext = PayloadContext( + CodewhispererLanguage.Java, + totalLines, + sourceFiles.size, + Instant.now().toEpochMilli() - start, + sourceFiles.mapNotNull { Path.of(it).toFile().toVirtualFile() }, + srcPayloadSize, + srcZip.length(), + totalBuildPayloadSize + ) + return Payload(payloadContext, srcZip) + } + + private fun findClassFile(relativePath: String, outputPaths: Array): Path? { + outputPaths.forEach { outputPath -> + val classFile = getPath(outputPath, relativePath) + if (classFile?.exists() == true) return classFile + } + return null + } + + fun parseImports(file: VirtualFile): JavaImportsInfo { + val imports = mutableSetOf() + val inputStream = file.inputStream + var packagePath = "" + try { + inputStream.use { + it.bufferedReader().lines().forEach { line -> + val importMatcher = importRegex.toPattern().matcher(line) + val packageMatcher = packageRegex.toPattern().matcher(line) + if (importMatcher.find()) { + val import = importMatcher.group(1).replace('.', FILE_SEPARATOR) + imports.add(import) + } + if (packageMatcher.find()) { + packagePath = packageMatcher.group(1).replace('.', FILE_SEPARATOR) + } + } + } + } catch (e: IOException) { + LOG.error { message("codewhisperer.codescan.cannot_read_file", file.path) } + } + return JavaImportsInfo(imports.toList(), packagePath) + } + + /** + * Gets a relative build path for file and package + */ + private fun getRelativeBuildPath(file: VirtualFile, packagePath: String): String? { + val sourceFilePath = file.path + return if (packagePath.isEmpty()) { + file.nameWithoutExtension + } else { + val start = sourceFilePath.lastIndexOf(packagePath) + val end = sourceFilePath.lastIndexOf('.') + try { + sourceFilePath.substring(start, end) + } catch (e: IndexOutOfBoundsException) { + return null + } + } + buildExt + } + + override fun getSourceFilesUnderProjectRoot(selectedFile: VirtualFile): List { + val files = mutableListOf() + val sourceRoots = ProjectRootManager.getInstance(project).getModuleSourceRoots(setOf(JavaSourceRootType.SOURCE)) + files.add(selectedFile) + sourceRoots.forEach { vFile -> + files.addAll( + VfsUtil.collectChildrenRecursively(vFile).filter { + it.path.endsWith(sourceExt[0]) && it != selectedFile + } + ) + } + return files + } + + override fun includeDependencies(): PayloadMetadata { + val sourceFiles = mutableSetOf() + val buildPaths = mutableSetOf() + var currentTotalFileSize = 0L + var currentTotalLines = 0L + val files = getSourceFilesUnderProjectRoot(selectedFile) + val queue = ArrayDeque() + + files.forEach { file -> + queue.add(file) + + // BFS + while (queue.isNotEmpty()) { + if (currentTotalFileSize.equals(getPayloadLimitInBytes())) { + return PayloadMetadata(sourceFiles.map { it.path }.toSet(), currentTotalFileSize, currentTotalLines, buildPaths.filterNotNull().toSet()) + } + + val currentFile = queue.removeFirst() + if (!currentFile.path.startsWith(projectRoot.path) || + sourceFiles.contains(currentFile) || + willExceedPayloadLimit(currentTotalFileSize, currentFile.length) + ) { + if (!currentFile.path.startsWith(projectRoot.path)) { + LOG.error { "Invalid workspace: Current file ${currentFile.path} is not under the project root ${projectRoot.path}" } + } + continue + } + + val currentFileSize = currentFile.length + + currentTotalFileSize += currentFileSize + currentTotalLines += Files.lines(currentFile.toNioPath()).count() + sourceFiles.add(currentFile) + + // Get all imports from the file + val importsInfo = parseImports(currentFile) + importsInfo.imports.forEach { importPath -> + val importedFiles = getSourceFilesForImport(currentFile, importPath) + importedFiles.forEach { importedFile -> + if (!sourceFiles.contains(importedFile)) queue.addLast(importedFile) + } + } + buildPaths.add(getRelativeBuildPath(currentFile, importsInfo.packagePath)) + } + } + + return PayloadMetadata(sourceFiles.map { it.path }.toSet(), currentTotalFileSize, currentTotalLines, buildPaths.filterNotNull().toSet()) + } + + private fun getImportedFile(currentFile: VirtualFile, importPath: String): VirtualFile? { + // Handle '*' imports + val resolvedImportPath = if (importPath.contains('*')) { + importPath.substring(0, importPath.indexOfFirst { it == '*' } - 1) + } else { + importPath + sourceExt[0] + } + + // First try searching the module containing the current file + ModuleUtil.findModuleForFile(currentFile, project)?.rootManager?.getSourceRoots(JavaSourceRootType.SOURCE)?.forEach { srcRoot -> + val path = getPath(srcRoot.path, resolvedImportPath) + path?.toFile()?.toVirtualFile()?.let { + return it + } + } + + // Fallback to all other java source roots. + val projectSourceRoots = ProjectRootManager.getInstance(project).contentSourceRoots + projectSourceRoots.forEach { srcRoot -> + val path = getPath(srcRoot.path, resolvedImportPath) + path?.toFile()?.toVirtualFile()?.let { + return it + } + } + return null + } + + /** + * Get source files for import statement. If the import is a star import, include all the files in the package directory. + */ + fun getSourceFilesForImport(currentFile: VirtualFile, importPath: String): List { + val importedFile = getImportedFile(currentFile, importPath) ?: return listOf() + if (!importedFile.isDirectory) { + return listOf(importedFile) + } + return VfsUtil.collectChildrenRecursively(importedFile).filter { it.name.endsWith(sourceExt[0]) } + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/JavaScriptCodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/JavaScriptCodeScanSessionConfig.kt new file mode 100644 index 0000000000..a56c43e0a2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/JavaScriptCodeScanSessionConfig.kt @@ -0,0 +1,90 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.containers.addIfNotNull +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.JS_CODE_SCAN_TIMEOUT_IN_SECONDS +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.JS_PAYLOAD_LIMIT_IN_BYTES +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodewhispererLanguage +import java.io.IOException +import java.nio.file.Path + +internal class JavaScriptCodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project, + private val language: CodewhispererLanguage +) : CodeScanSessionConfig(selectedFile, project) { + + private val importRegex = Regex("^import.*(?:[\"'](.+)[\"']);?\$") + private val requireRegex = Regex("^.+require\\(['\"](.+)['\"]\\)[ \\t]*;?") + override val sourceExt by lazy { + if (language === CodewhispererLanguage.Javascript) { + listOf(".js") + } else { + listOf(".ts") + } + } + + override fun overallJobTimeoutInSeconds(): Long = JS_CODE_SCAN_TIMEOUT_IN_SECONDS + + override fun getPayloadLimitInBytes(): Int = JS_PAYLOAD_LIMIT_IN_BYTES + + fun parseImports(file: VirtualFile): List { + val imports = mutableSetOf() + try { + file.inputStream.use { + it.bufferedReader().lines().forEach { line -> + val importMatcher = importRegex.toPattern().matcher(line) + val moduleName = when (importMatcher.find()) { + true -> importMatcher.group(1) + false -> { + val requireMatcher = requireRegex.toPattern().matcher(line) + if (requireMatcher.find()) { + requireMatcher.group(1) + } else { + "" + } + } + }.trim() + if (moduleName.isNotEmpty()) { + when (moduleName.endsWith(sourceExt[0])) { + true -> imports.add(moduleName) + false -> { + imports.add(moduleName + sourceExt[0]) + } + } + } + } + } + } catch (e: IOException) { + error(message("codewhisperer.codescan.cannot_read_file", file.path)) + } + return imports.toList() + } + + override fun getImportedFiles(file: VirtualFile, includedSourceFiles: Set): List { + val importedFiles = mutableListOf() + val imports = parseImports(file) + val importedFilePaths = mutableListOf() + imports.forEach { importPath -> + if (getPath(importPath)?.exists() == true) { + importedFilePaths.add(Path.of(importPath).normalize().toString()) + } else { + val importedFilePath = getPath(file.parent.path, importPath) + if (importedFilePath?.exists() == true) { + importedFilePaths.addIfNotNull(importedFilePath.toFile().toVirtualFile()?.path) + } + } + } + val validSourceFiles = importedFilePaths.filter { !includedSourceFiles.contains(it) } + validSourceFiles.forEach { validFile -> + importedFiles.add(validFile) + } + return importedFiles + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/PythonCodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/PythonCodeScanSessionConfig.kt new file mode 100644 index 0000000000..f1b9db9cfd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/PythonCodeScanSessionConfig.kt @@ -0,0 +1,74 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.containers.addIfNotNull +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.PYTHON_CODE_SCAN_TIMEOUT_IN_SECONDS +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.PYTHON_PAYLOAD_LIMIT_IN_BYTES +import software.aws.toolkits.resources.message +import java.io.IOException + +internal class PythonCodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project +) : CodeScanSessionConfig(selectedFile, project) { + + private val importRegex = Regex("^(?:from\\s+(\\S+)\\s+)?(?:import\\s+((?:\\S+(?:\\s+as\\s+\\S+)?\\s*[,]?\\s*)+))\$") + private val projectContentRoots = ProjectRootManager.getInstance(project).contentRoots + override val sourceExt: List = listOf(".py") + + override fun overallJobTimeoutInSeconds(): Long = PYTHON_CODE_SCAN_TIMEOUT_IN_SECONDS + + override fun getPayloadLimitInBytes(): Int = PYTHON_PAYLOAD_LIMIT_IN_BYTES + + fun parseImports(file: VirtualFile): List { + val imports = mutableSetOf() + try { + file.inputStream.use { + it.bufferedReader().lines().forEach { line -> + val importMatcher = importRegex.toPattern().matcher(line) + if (importMatcher.find()) { + // Group(1) is the 'from' module in the import statement. + // For E.g. in "from import xyz", import module is Module1 + val fromModule = importMatcher.group(1)?.plus(FILE_SEPARATOR) ?: "" + // Group(2) is the " as , as , ,..." statement + val importStatements = importMatcher.group(2) + importStatements.split(",").forEach { statement -> + // Just get the first word in [as ] statement + val importModule = statement.trim().split(" ").first() + val importPath = fromModule + importModule.replace(".", FILE_SEPARATOR.toString()) + sourceExt[0] + imports.add(importPath) + } + } + } + } + } catch (e: IOException) { + error(message("codewhisperer.codescan.cannot_read_file", file.path)) + } + return imports.toList() + } + + override fun getImportedFiles(file: VirtualFile, includedSourceFiles: Set): List { + val importedFiles = mutableListOf() + val imports = parseImports(file) + val importedFilePaths = mutableListOf() + projectContentRoots.forEach { root -> + imports.forEach { importPath -> + val importedFilePath = getPath(root.path, importPath) + if (importedFilePath?.exists() == true) { + importedFilePaths.addIfNotNull(importedFilePath.toFile().toVirtualFile()?.path) + } + } + } + val validSourceFiles = importedFilePaths.filter { !includedSourceFiles.contains(it) } + validSourceFiles.forEach { validFile -> + importedFiles.add(validFile) + } + return importedFiles + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/RubyCodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/RubyCodeScanSessionConfig.kt new file mode 100644 index 0000000000..f500d29b09 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/RubyCodeScanSessionConfig.kt @@ -0,0 +1,132 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.containers.addIfNotNull +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.resources.message +import java.io.IOException + +internal class RubyCodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project +) : CodeScanSessionConfig(selectedFile, project) { + + private val importRegex = Regex("^(require|require_relative|load|include|extend)\\s+('[^']+'|\"[^\"]+\"|\\w+)(\\s+as\\s+(\\w+))?") + private val projectContentRoots = ProjectRootManager.getInstance(project).contentRoots + override val sourceExt: List = listOf(".rb") + + override fun overallJobTimeoutInSeconds(): Long = CodeWhispererConstants.RUBY_CODE_SCAN_TIMEOUT_IN_SECONDS + override fun getPayloadLimitInBytes(): Int = CodeWhispererConstants.RUBY_PAYLOAD_LIMIT_IN_BYTES + + private fun generateModulePaths(inputPath: String): MutableSet { + val positionOfExt = inputPath.indexOf(sourceExt[0]) + val inputPathString = if (positionOfExt != -1) { + inputPath.substring(0, positionOfExt).trim() + } else { + inputPath + } + val inputPaths = inputPathString.split('/') + val outputPaths = mutableSetOf() + for (i in inputPaths.indices) { + val outputPath = inputPaths.subList(0, i + 1).joinToString(FILE_SEPARATOR.toString()) + outputPaths.add(outputPath) + } + return outputPaths + } + + private fun getModulePath(modulePathStr: String): MutableSet { + val pos = modulePathStr.indexOf(" ${CodeWhispererConstants.AS} ") + val modifiedModulePathStr = if (pos != -1) { + modulePathStr.substring(0, pos) + } else { + modulePathStr + } + + return generateModulePaths(modifiedModulePathStr.replace(Regex("[\",'\\s()]"), "").trim()) + } + + private fun extractModulePaths(importStr: String): Set { + val modulePaths = mutableSetOf() + val requireKeyword = CodeWhispererConstants.REQUIRE + val requireRelativeKeyword = CodeWhispererConstants.REQUIRE_RELATIVE + val includeKeyword = CodeWhispererConstants.INCLUDE + val extendKeyword = CodeWhispererConstants.EXTEND + val loadKeyword = CodeWhispererConstants.LOAD + + var keyword: String? = null + + when { + importStr.startsWith(requireRelativeKeyword) -> { + keyword = requireRelativeKeyword + } + importStr.startsWith(requireKeyword) -> { + keyword = requireKeyword + } + importStr.startsWith(includeKeyword) -> { + keyword = includeKeyword + } + importStr.startsWith(extendKeyword) -> { + keyword = extendKeyword + } + importStr.startsWith(loadKeyword) -> { + keyword = loadKeyword + } + } + + if (keyword != null) { + val modulePathStr = importStr + .substring(keyword.length) + .trim() + .replace(Regex("\\s+"), "") + modulePaths.addAll(getModulePath(modulePathStr)) + } + + return modulePaths + } + + fun parseImports(file: VirtualFile): List { + val imports = mutableSetOf() + try { + file.inputStream.use { + it.bufferedReader().lines().forEach { line -> + val importMatcher = importRegex.toPattern().matcher(line) + if (importMatcher.find()) { + val modulePathLine = line.replace(";", "") + val goalImports = extractModulePaths(modulePathLine) + imports.addAll(goalImports) + } + } + } + } catch (e: IOException) { + error(message("codewhisperer.codescan.cannot_read_file", file.path)) + } + return imports.toList() + } + + override fun getImportedFiles(file: VirtualFile, includedSourceFiles: Set): List { + val importedFiles = mutableListOf() + val imports = parseImports(file) + val importedFilePaths = mutableListOf() + + projectContentRoots.forEach { root -> + imports.forEach { importPath -> + val importedFilePath = getPath(root.path, importPath + sourceExt[0]) + if (importedFilePath?.exists() == true) { + importedFilePaths.addIfNotNull(importedFilePath.toFile().toVirtualFile()?.path) + } + } + } + + val validSourceFiles = importedFilePaths.filter { it !in includedSourceFiles } + validSourceFiles.forEach { validFile -> + importedFiles.add(validFile) + } + return importedFiles + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/TerraformCodeScanSessionConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/TerraformCodeScanSessionConfig.kt new file mode 100644 index 0000000000..adac6f3842 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/codescan/sessionconfig/TerraformCodeScanSessionConfig.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants + +internal class TerraformCodeScanSessionConfig( + private val selectedFile: VirtualFile, + private val project: Project +) : CodeScanSessionConfig(selectedFile, project) { + + override val sourceExt: List = listOf(".tf", ".hcl") + + override fun overallJobTimeoutInSeconds(): Long = CodeWhispererConstants.TERRAFORM_CODE_SCAN_TIMEOUT_IN_SECONDS + + override fun getPayloadLimitInBytes(): Int = CodeWhispererConstants.TERRAFORM_PAYLOAD_LIMIT_IN_BYTES +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt new file mode 100644 index 0000000000..040c3bbd2b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererClientAdaptor.kt @@ -0,0 +1,393 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.credentials + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider +import software.amazon.awssdk.services.codewhisperer.CodeWhispererClient +import software.amazon.awssdk.services.codewhisperer.model.CreateCodeScanRequest +import software.amazon.awssdk.services.codewhisperer.model.CreateCodeScanResponse +import software.amazon.awssdk.services.codewhisperer.model.GetCodeScanRequest +import software.amazon.awssdk.services.codewhisperer.model.GetCodeScanResponse +import software.amazon.awssdk.services.codewhisperer.model.ListCodeScanFindingsRequest +import software.amazon.awssdk.services.codewhisperer.model.ListCodeScanFindingsResponse +import software.amazon.awssdk.services.codewhispererruntime.CodeWhispererRuntimeClient +import software.amazon.awssdk.services.codewhispererruntime.model.CompletionType +import software.amazon.awssdk.services.codewhispererruntime.model.CreateUploadUrlRequest +import software.amazon.awssdk.services.codewhispererruntime.model.CreateUploadUrlResponse +import software.amazon.awssdk.services.codewhispererruntime.model.Dimension +import software.amazon.awssdk.services.codewhispererruntime.model.GenerateCompletionsRequest +import software.amazon.awssdk.services.codewhispererruntime.model.GenerateCompletionsResponse +import software.amazon.awssdk.services.codewhispererruntime.model.ListAvailableCustomizationsRequest +import software.amazon.awssdk.services.codewhispererruntime.model.ListFeatureEvaluationsResponse +import software.amazon.awssdk.services.codewhispererruntime.model.SendTelemetryEventResponse +import software.amazon.awssdk.services.codewhispererruntime.model.SuggestionState +import software.aws.toolkits.core.utils.debug +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.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.service.RequestContext +import software.aws.toolkits.jetbrains.services.codewhisperer.service.ResponseContext +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getTelemetryOptOutPreference +import software.aws.toolkits.jetbrains.services.codewhisperer.util.transform +import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata +import software.aws.toolkits.telemetry.CodewhispererCompletionType +import software.aws.toolkits.telemetry.CodewhispererSuggestionState +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlin.reflect.KProperty0 +import kotlin.reflect.jvm.isAccessible + +// TODO: move this file to package "/client" +// As the connection is project-level, we need to make this project-level too +@Deprecated("Methods can throw a NullPointerException if callee does not check if connection is valid") +interface CodeWhispererClientAdaptor : Disposable { + val project: Project + + fun generateCompletionsPaginator( + firstRequest: GenerateCompletionsRequest, + ): Sequence + + fun createUploadUrl( + request: CreateUploadUrlRequest + ): CreateUploadUrlResponse + + fun createCodeScan( + request: CreateCodeScanRequest, + isSigv4: Boolean = shouldUseSigv4Client(project) + ): CreateCodeScanResponse + + fun getCodeScan( + request: GetCodeScanRequest, + isSigv4: Boolean = shouldUseSigv4Client(project) + ): GetCodeScanResponse + + fun listCodeScanFindings( + request: ListCodeScanFindingsRequest, + isSigv4: Boolean = shouldUseSigv4Client(project) + ): ListCodeScanFindingsResponse + + fun listAvailableCustomizations(): List + + fun sendUserTriggerDecisionTelemetry( + requestContext: RequestContext, + responseContext: ResponseContext, + completionType: CodewhispererCompletionType, + suggestionState: CodewhispererSuggestionState, + suggestionReferenceCount: Int, + lineCount: Int, + numberOfRecommendations: Int + ): SendTelemetryEventResponse + + fun sendCodePercentageTelemetry( + language: CodeWhispererProgrammingLanguage, + customizationArn: String?, + acceptedTokenCount: Int, + totalTokenCount: Int + ): SendTelemetryEventResponse + + fun sendUserModificationTelemetry( + sessionId: String, + requestId: String, + language: CodeWhispererProgrammingLanguage, + customizationArn: String, + modificationPercentage: Double + ): SendTelemetryEventResponse + + fun sendCodeScanTelemetry( + language: CodeWhispererProgrammingLanguage, + codeScanJobId: String? + ): SendTelemetryEventResponse + + fun listFeatureEvaluations(): ListFeatureEvaluationsResponse + + fun sendMetricDataTelemetry(eventName: String, metadata: Map): SendTelemetryEventResponse + + companion object { + fun getInstance(project: Project): CodeWhispererClientAdaptor = project.service() + + private fun shouldUseSigv4Client(project: Project) = + CodeWhispererExplorerActionManager.getInstance().checkActiveCodeWhispererConnectionType(project) == CodeWhispererLoginType.Accountless + + const val INVALID_CODESCANJOBID = "Invalid_CodeScanJobID" + } +} + +open class CodeWhispererClientAdaptorImpl(override val project: Project) : CodeWhispererClientAdaptor { + + private val mySigv4Client by lazy { createUnmanagedSigv4Client() } + + @Volatile + private var myBearerClient: CodeWhispererRuntimeClient? = null + + private val KProperty0<*>.isLazyInitialized: Boolean + get() { + isAccessible = true + return (getDelegate() as Lazy<*>).isInitialized() + } + + init { + initClientUpdateListener() + } + + private fun initClientUpdateListener() { + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + if (newConnection is AwsBearerTokenConnection) { + myBearerClient = getBearerClient(newConnection.getConnectionSettings().providerId) + } + } + } + ) + } + + private fun bearerClient(): CodeWhispererRuntimeClient { + if (myBearerClient != null) return myBearerClient as CodeWhispererRuntimeClient + myBearerClient = getBearerClient() + return myBearerClient as CodeWhispererRuntimeClient + } + + override fun generateCompletionsPaginator(firstRequest: GenerateCompletionsRequest) = sequence { + var nextToken: String? = firstRequest.nextToken() + do { + val response = bearerClient().generateCompletions(firstRequest.copy { it.nextToken(nextToken) }) + nextToken = response.nextToken() + yield(response) + } while (!nextToken.isNullOrEmpty()) + } + + override fun createUploadUrl(request: CreateUploadUrlRequest): CreateUploadUrlResponse = + bearerClient().createUploadUrl(request) + + override fun createCodeScan(request: CreateCodeScanRequest, isSigv4: Boolean): CreateCodeScanResponse = + if (isSigv4) { + mySigv4Client.createCodeScan(request) + } else { + bearerClient().startCodeAnalysis(request.transform()).transform() + } + + override fun getCodeScan(request: GetCodeScanRequest, isSigv4: Boolean): GetCodeScanResponse = + if (isSigv4) { + mySigv4Client.getCodeScan(request) + } else { + bearerClient().getCodeAnalysis(request.transform()).transform() + } + + override fun listCodeScanFindings(request: ListCodeScanFindingsRequest, isSigv4: Boolean): ListCodeScanFindingsResponse = + if (isSigv4) { + mySigv4Client.listCodeScanFindings(request) + } else { + bearerClient().listCodeAnalysisFindings(request.transform()).transform() + } + + // DO NOT directly use this method to fetch customizations, use wrapper [CodeWhispererModelConfigurator.listCustomization()] instead + override fun listAvailableCustomizations(): List = + bearerClient().listAvailableCustomizationsPaginator(ListAvailableCustomizationsRequest.builder().build()) + .stream() + .toList() + .flatMap { resp -> + LOG.debug { + "listAvailableCustomizations: requestId: ${resp.responseMetadata().requestId()}, customizations: ${ + resp.customizations().map { it.name() } + }" + } + resp.customizations().map { + CodeWhispererCustomization( + arn = it.arn(), + name = it.name(), + description = it.description() + ) + } + } + + override fun sendUserTriggerDecisionTelemetry( + requestContext: RequestContext, + responseContext: ResponseContext, + completionType: CodewhispererCompletionType, + suggestionState: CodewhispererSuggestionState, + suggestionReferenceCount: Int, + lineCount: Int, + numberOfRecommendations: Int + ): SendTelemetryEventResponse { + val fileContext = requestContext.fileContextInfo + val programmingLanguage = fileContext.programmingLanguage + var e2eLatency = requestContext.latencyContext.getCodeWhispererEndToEndLatency() + + // When we send a userTriggerDecision of Empty or Discard, we set the time users see the first + // suggestion to be now. + if (e2eLatency < 0) { + e2eLatency = TimeUnit.NANOSECONDS.toMillis( + System.nanoTime() - requestContext.latencyContext.codewhispererEndToEndStart + ).toDouble() + } + return bearerClient().sendTelemetryEvent { requestBuilder -> + requestBuilder.telemetryEvent { telemetryEventBuilder -> + telemetryEventBuilder.userTriggerDecisionEvent { + it.requestId(requestContext.latencyContext.firstRequestId) + it.completionType(completionType.toCodeWhispererSdkType()) + it.programmingLanguage { builder -> builder.languageName(programmingLanguage.toCodeWhispererRuntimeLanguage().languageId) } + it.sessionId(responseContext.sessionId) + it.recommendationLatencyMilliseconds(e2eLatency) + it.triggerToResponseLatencyMilliseconds(requestContext.latencyContext.paginationFirstCompletionTime) + it.suggestionState(suggestionState.toCodeWhispererSdkType()) + it.timestamp(Instant.now()) + it.suggestionReferenceCount(suggestionReferenceCount) + it.generatedLine(lineCount) + it.customizationArn(requestContext.customizationArn) + it.numberOfRecommendations(numberOfRecommendations) + } + } + requestBuilder.optOutPreference(getTelemetryOptOutPreference()) + requestBuilder.userContext(ClientMetadata.DEFAULT_METADATA.codeWhispererUserContext) + } + } + + override fun sendCodePercentageTelemetry( + language: CodeWhispererProgrammingLanguage, + customizationArn: String?, + acceptedTokenCount: Int, + totalTokenCount: Int + ): SendTelemetryEventResponse = bearerClient().sendTelemetryEvent { requestBuilder -> + requestBuilder.telemetryEvent { telemetryEventBuilder -> + telemetryEventBuilder.codeCoverageEvent { + it.programmingLanguage { languageBuilder -> languageBuilder.languageName(language.toCodeWhispererRuntimeLanguage().languageId) } + it.customizationArn(customizationArn) + it.acceptedCharacterCount(acceptedTokenCount) + it.totalCharacterCount(totalTokenCount) + it.timestamp(Instant.now()) + } + } + requestBuilder.optOutPreference(getTelemetryOptOutPreference()) + requestBuilder.userContext(ClientMetadata.DEFAULT_METADATA.codeWhispererUserContext) + } + + override fun sendUserModificationTelemetry( + sessionId: String, + requestId: String, + language: CodeWhispererProgrammingLanguage, + customizationArn: String, + modificationPercentage: Double + ): SendTelemetryEventResponse = bearerClient().sendTelemetryEvent { requestBuilder -> + requestBuilder.telemetryEvent { telemetryEventBuilder -> + telemetryEventBuilder.userModificationEvent { + it.sessionId(sessionId) + it.requestId(requestId) + it.programmingLanguage { languageBuilder -> + languageBuilder.languageName(language.toCodeWhispererRuntimeLanguage().languageId) + } + it.customizationArn(customizationArn) + it.modificationPercentage(modificationPercentage) + it.timestamp(Instant.now()) + } + } + requestBuilder.optOutPreference(getTelemetryOptOutPreference()) + requestBuilder.userContext(ClientMetadata.DEFAULT_METADATA.codeWhispererUserContext) + } + + override fun sendCodeScanTelemetry( + language: CodeWhispererProgrammingLanguage, + codeScanJobId: String? + ): SendTelemetryEventResponse = bearerClient().sendTelemetryEvent { requestBuilder -> + requestBuilder.telemetryEvent { telemetryEventBuilder -> + telemetryEventBuilder.codeScanEvent { + it.programmingLanguage { languageBuilder -> + languageBuilder.languageName(language.toCodeWhispererRuntimeLanguage().languageId) + } + it.codeScanJobId(if (codeScanJobId.isNullOrEmpty()) CodeWhispererClientAdaptor.INVALID_CODESCANJOBID else codeScanJobId) + it.timestamp(Instant.now()) + } + } + requestBuilder.optOutPreference(getTelemetryOptOutPreference()) + requestBuilder.userContext(ClientMetadata.DEFAULT_METADATA.codeWhispererUserContext) + } + + override fun listFeatureEvaluations(): ListFeatureEvaluationsResponse = bearerClient().listFeatureEvaluations { + it.userContext(ClientMetadata.DEFAULT_METADATA.codeWhispererUserContext) + } + + override fun sendMetricDataTelemetry(eventName: String, metadata: Map): SendTelemetryEventResponse = + bearerClient().sendTelemetryEvent { requestBuilder -> + requestBuilder.telemetryEvent { telemetryEventBuilder -> + telemetryEventBuilder.metricData { metricBuilder -> + metricBuilder.metricName(eventName) + metricBuilder.metricValue(1.0) + metricBuilder.timestamp(Instant.now()) + metricBuilder.dimensions(metadata.filter { it.value != null }.map { Dimension.builder().name(it.key).value(it.value.toString()).build() }) + } + } + requestBuilder.optOutPreference(getTelemetryOptOutPreference()) + requestBuilder.userContext(ClientMetadata.DEFAULT_METADATA.codeWhispererUserContext) + } + + override fun dispose() { + if (this::mySigv4Client.isLazyInitialized) { + mySigv4Client.close() + } + myBearerClient?.close() + } + + /** + * Every different SSO/AWS Builder ID connection requires a new client which has its corresponding bearer token provider, + * thus we have to create them dynamically. + * Invalidate and recycle the old client first, and create a new client with the new connection. + * This makes sure when we invoke CW, we always use the up-to-date connection. + * In case this fails to close the client, myBearerClient is already set to null thus next time when we invoke CW, + * it will go through this again which should get the current up-to-date connection. This stale client would be + * unused and stay in memory for a while until eventually closed by ToolkitClientManager. + */ + open fun getBearerClient(oldProviderIdToRemove: String = ""): CodeWhispererRuntimeClient? { + myBearerClient = null + + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance()) + connection as? AwsBearerTokenConnection ?: run { + LOG.warn { "$connection is not a bearer token connection" } + return null + } + + return AwsClientManager.getInstance().getClient(connection.getConnectionSettings()) + } + + companion object { + private val LOG = getLogger() + private fun createUnmanagedSigv4Client(): CodeWhispererClient = AwsClientManager.getInstance().createUnmanagedClient( + AnonymousCredentialsProvider.create(), + CodeWhispererConstants.Config.Sigv4ClientRegion, + CodeWhispererConstants.Config.CODEWHISPERER_ENDPOINT + ) + } +} + +class MockCodeWhispererClientAdaptor(override val project: Project) : CodeWhispererClientAdaptorImpl(project) { + override fun getBearerClient(oldProviderIdToRemove: String): CodeWhispererRuntimeClient = project.awsClient() + override fun dispose() {} +} + +private fun CodewhispererSuggestionState.toCodeWhispererSdkType() = when { + this == CodewhispererSuggestionState.Accept -> SuggestionState.ACCEPT + this == CodewhispererSuggestionState.Reject -> SuggestionState.REJECT + this == CodewhispererSuggestionState.Empty -> SuggestionState.EMPTY + this == CodewhispererSuggestionState.Discard -> SuggestionState.DISCARD + else -> SuggestionState.UNKNOWN_TO_SDK_VERSION +} + +private fun CodewhispererCompletionType.toCodeWhispererSdkType() = when { + this == CodewhispererCompletionType.Line -> CompletionType.LINE + this == CodewhispererCompletionType.Block -> CompletionType.BLOCK + else -> CompletionType.UNKNOWN_TO_SDK_VERSION +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererLoginType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererLoginType.kt new file mode 100644 index 0000000000..1f384ad0be --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/credentials/CodeWhispererLoginType.kt @@ -0,0 +1,12 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.credentials + +enum class CodeWhispererLoginType(val displayName: String) { + SSO("IAM Identity Center"), + Sono("Builder ID"), + Accountless("Access Code"), + Logout("Logout"), + Expired("Expired") +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomization.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomization.kt new file mode 100644 index 0000000000..92335fec55 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomization.kt @@ -0,0 +1,15 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.customization + +data class CodeWhispererCustomization( + @JvmField + var arn: String = "", + + @JvmField + var name: String = "", + + @JvmField + var description: String? = null, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomizationDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomizationDialog.kt new file mode 100644 index 0000000000..b50976397f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomizationDialog.kt @@ -0,0 +1,278 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.customization + +import com.intellij.notification.NotificationAction +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBRadioButton +import com.intellij.ui.dsl.builder.Cell +import com.intellij.ui.dsl.builder.Row +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.actionListener +import com.intellij.ui.dsl.builder.bind +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.selected +import com.intellij.ui.dsl.builder.toNullableProperty +import com.intellij.ui.dsl.gridLayout.HorizontalAlign +import software.amazon.awssdk.arns.Arn +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.CODEWHISPERER_CUSTOM_LEARN_MORE_URI +import software.aws.toolkits.jetbrains.ui.AsyncComboBox +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import javax.swing.JComponent +import javax.swing.JList + +private val NoDataToDisplay = CustomizationUiItem( + CodeWhispererCustomization("", message("codewhisperer.custom.dialog.option.no_data"), ""), + false, + false +) + +private fun notifyCustomizationIsSelected(project: Project, customizationUiItem: CustomizationUiItem?) { + val content = customizationUiItem?.let { + "CodeWhisperer suggestions are now coming from the ${it.customization.name} customization" + } ?: "CodeWhisperer suggestions are now coming from the ${message("codewhisperer.custom.dialog.option.default")}" + + notifyInfo( + title = message("codewhisperer.custom.dialog.title"), + content = content, + project = project, + notificationActions = listOf( + NotificationAction.create( + message("codewhisperer.notification.custom.simple.button.got_it") + ) { _, notification -> notification.expire() } + ) + ) +} + +/** + * Please use CodeWhispererModelConfigurator.showConfigDialog() instead of init a dialog object directly, the reason is that we need to manage "New" + * customizations compared to the previous snapshot in the CodeWhipsererModelConfigurator service and render in the UI. Initialize a dialog directly and show + * will not have this metadata. + * + */ +class CodeWhispererCustomizationDialog( + private val project: Project, + private val myCustomizations: List? = null +) : DialogWrapper(project), Disposable { + private data class Modal( + var selectedOption: RadioButtonOption, + var selectedCustomization: CustomizationUiItem?, + ) + + enum class RadioButtonOption { + Default, + Customization + } + + private var modal: Modal + private val panel: DialogPanel by lazy { drawPanel() } + + init { + title = message("codewhisperer.custom.dialog.title") + setOKButtonText(message("codewhisperer.custom.dialog.ok_button.text")) + + val selectedOption = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.let { + RadioButtonOption.Customization + } ?: RadioButtonOption.Default + + modal = Modal(selectedOption, null) + isOKActionEnabled = false + + init() + } + + override fun doOKAction() { + this.panel.apply() + + when (modal.selectedOption) { + RadioButtonOption.Default -> run { + CodeWhispererModelConfigurator.getInstance().switchCustomization(project, null) + notifyCustomizationIsSelected(project, null) + } + + RadioButtonOption.Customization -> run { + CodeWhispererModelConfigurator.getInstance().switchCustomization(project, modal.selectedCustomization?.customization) + notifyCustomizationIsSelected(project, modal.selectedCustomization) + } + } + + close(OK_EXIT_CODE) + } + + override fun doCancelAction() { + super.doCancelAction() + + // TODO: not using project.refreshDevToolTree is weird + // but the purpose is to update devTool trees of all IDE instances with CodeWhisperer IdC + CodeWhispererCustomizationListener.notifyCustomUiUpdate() + close(CANCEL_EXIT_CODE) + } + + override fun dispose() { + super.dispose() + } + + override fun createCenterPanel(): JComponent = panel + + // TODO: check if we can render a multi-line combo box + private fun drawPanel() = panel { + row { + label(message("codewhisperer.custom.dialog.panel.title")).bold() + } + + lateinit var customizationButton: Cell + lateinit var defaultButton: Cell + lateinit var customizationComboBox: ComboBox + + buttonsGroup { + row { + defaultButton = radioButton(message("codewhisperer.custom.dialog.option.default"), RadioButtonOption.Default) + .comment(message("codewhisperer.custom.dialog.model.default.comment")) + .actionListener { _, component -> + if (component.isSelected) { + isOKActionEnabled = CodeWhispererModelConfigurator.getInstance().activeCustomization(project) != null + } + } + }.topGap(TopGap.MEDIUM) + + row { + customizationButton = radioButton(message("codewhisperer.custom.dialog.option.customization"), RadioButtonOption.Customization) + .actionListener { _, component -> + if (component.isSelected) { + isOKActionEnabled = + customizationComboBox.item != null && + CodeWhispererModelConfigurator.getInstance().activeCustomization(project) != customizationComboBox.item.customization && + modal.selectedCustomization?.customization != NoDataToDisplay.customization + } + } + }.topGap(TopGap.MEDIUM) + + lateinit var noCustomizationComment: Row + lateinit var customizationComment: Row + indent { + noCustomizationComment = row("") { + rowComment(message("codewhisperer.custom.dialog.option.customization.description.no_customization", CODEWHISPERER_CUSTOM_LEARN_MORE_URI)) + }.visible(false) + + customizationComment = row("") { + rowComment(message("codewhisperer.custom.dialog.option.customization.description")) + }.visible(false) + } + + indent { + row { + cell(AsyncComboBox(customRenderer = CustomizationRenderer)).applyToComponent { + customizationComboBox = this + preferredSize.width = maxOf(preferredSize.width, 600) + + proposeModelUpdate { model -> + val activeCustomization = CodeWhispererModelConfigurator.getInstance().activeCustomization(project) + val unsorted = myCustomizations ?: CodeWhispererModelConfigurator.getInstance().listCustomizations(project).orEmpty() + + val sorted = activeCustomization?.let { + unsorted.putPickedUpFront(setOf(it)) + } ?: run { + unsorted.sortedBy { it.customization.name } + } + + if ( + sorted.isNotEmpty() && + sorted.first().customization != activeCustomization && + modal.selectedOption == RadioButtonOption.Customization + ) { + isOKActionEnabled = true + } + + if (sorted.isEmpty()) { + model.addElement(NoDataToDisplay) + noCustomizationComment.visible(true) + modal.selectedOption = RadioButtonOption.Default + defaultButton.component.isSelected = true + customizationButton.enabled(false) + getLogger().debug { "Empty customization was found" } + } else { + customizationComment.visible(true) + sorted.forEach { + model.addElement(it) + } + } + + modal.selectedCustomization = model.selectedItem as CustomizationUiItem + + addItemListener { + isOKActionEnabled = item.customization != CodeWhispererModelConfigurator.getInstance().activeCustomization(project) && + item.customization != NoDataToDisplay.customization + } + } + } + .bindItem(prop = modal::selectedCustomization.toNullableProperty()) + .enabledIf(customizationButton.selected) + .horizontalAlign(HorizontalAlign.FILL) + } + } + }.bind(modal::selectedOption) + + separator().topGap(TopGap.MEDIUM) + } +} + +private fun List.putPickedUpFront(picked: Set) = sortedWith { o1, o2 -> + val has1 = picked.contains(o1.customization) + val has2 = picked.contains(o2.customization) + + if (has1 && has2) { + 0 + } else if (has1) { + -1 + } else if (has2) { + 1 + } else { + naturalOrder().compare(o1.customization.name, o2.customization.name) + } +} + +private object CustomizationRenderer : ColoredListCellRenderer() { + override fun customizeCellRenderer( + list: JList, + value: CustomizationUiItem?, + index: Int, + selected: Boolean, + hasFocus: Boolean + ) { + value?.let { + append(it.customization.name, SimpleTextAttributes.REGULAR_ATTRIBUTES) + + if (it.shouldPrefixAccountId) { + tryOrNull { Arn.fromString(it.customization.arn).accountId().get() }?.let { accountId -> + append(" ($accountId)", SimpleTextAttributes.REGULAR_ATTRIBUTES) + } + } + + if (it.isNew) { + append(" New", SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES) + } + + if (it != NoDataToDisplay) { + val description = if (it.customization.description.isNullOrBlank()) { + message("codewhisperer.custom.dialog.customization.no_description") + } else { + it.customization.description + } + + append(" $description", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomizationListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomizationListener.kt new file mode 100644 index 0000000000..531a687400 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererCustomizationListener.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.customization + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.util.messages.Topic + +interface CodeWhispererCustomizationListener { + fun refreshUi() {} + + companion object { + @Topic.AppLevel + val TOPIC = Topic.create("customization listener", CodeWhispererCustomizationListener::class.java) + + fun notifyCustomUiUpdate() { + ApplicationManager.getApplication().messageBus.syncPublisher(TOPIC).refreshUi() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererModelConfigurator.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererModelConfigurator.kt new file mode 100644 index 0000000000..0629f1c322 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/customization/CodeWhispererModelConfigurator.kt @@ -0,0 +1,307 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.customization + +import com.intellij.notification.NotificationAction +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.intellij.util.xmlb.annotations.MapAnnotation +import com.intellij.util.xmlb.annotations.Property +import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.calculateIfIamIdentityCenterConnection +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import java.util.Collections +import java.util.concurrent.atomic.AtomicBoolean + +private fun notifyInvalidSelectedCustomization(project: Project) { + notifyInfo( + title = message("codewhisperer.custom.dialog.title"), + content = message("codewhisperer.notification.custom.not_available"), + project = project, + notificationActions = listOf( + NotificationAction.create( + message("codewhisperer.notification.custom.simple.button.got_it") + ) { _, notification -> notification.expire() } + ) + ) +} + +private fun notifyNewCustomization(project: Project) { + notifyInfo( + title = message("codewhisperer.custom.dialog.title"), + content = message("codewhisperer.notification.custom.new_customization"), + project = project, + notificationActions = listOf( + NotificationAction.create(message("codewhisperer.notification.custom.simple.button.select_customization")) { _, notification -> + CodeWhispererModelConfigurator.getInstance().showConfigDialog(project) + notification.expire() + } + ) + ) +} + +// A component responsible managing client's codewhisperer model configuration (currently customization feature only support enterprise tier users) +interface CodeWhispererModelConfigurator { + fun showConfigDialog(project: Project) + + fun listCustomizations(project: Project, passive: Boolean = false): List? + + fun activeCustomization(project: Project): CodeWhispererCustomization? + + fun switchCustomization(project: Project, newCustomization: CodeWhispererCustomization?) + + /** + * This method is only used for invalidate a stale customization which was previously active but was removed, it will remove all usage of this customization + * but not limited to the specific connection. + */ + fun invalidateCustomization(arn: String) + + /** + * This method will be invoked on IDE instantiation, it will check if there is customization associated with given connection and + * indicate if user is allowlisted or not + */ + fun shouldDisplayCustomNode(project: Project, forceUpdate: Boolean = false): Boolean + + /** + * Query if there is customization for given connection + */ + fun getNewUpdate(connectionId: String): Collection? + + companion object { + fun getInstance(): CodeWhispererModelConfigurator = service() + } +} + +@Service(Service.Level.APP) +@State(name = "codewhispererCustomizationStates", storages = [Storage("aws.xml")]) +class DefaultCodeWhispererModelConfigurator : CodeWhispererModelConfigurator, PersistentStateComponent, Disposable { + // TODO: refactor and clean these states, probably not need all the follwing and it's hard to maintain + // Map to store connectionId to its active customization + private val connectionIdToActiveCustomizationArn = Collections.synchronizedMap(mutableMapOf()) + + // Map to store connectionId to its listAvailableCustomizations result last time + private val connectionToCustomizationsShownLastTime = mutableMapOf>() + + private val connectionIdToIsAllowlisted = Collections.synchronizedMap(mutableMapOf()) + + private val connectionToCustomizationUiItems: MutableMap?> = Collections.synchronizedMap(mutableMapOf()) + + private val hasShownNewCustomizationNotification = AtomicBoolean(false) + + override fun showConfigDialog(project: Project) { + runInEdt { + calculateIfIamIdentityCenterConnection(project) { + CodeWhispererCustomizationDialog(project, connectionToCustomizationUiItems[it.id]).show() + connectionToCustomizationUiItems[it.id] = null + } + } + } + + @RequiresBackgroundThread + override fun listCustomizations(project: Project, passive: Boolean): List? = + calculateIfIamIdentityCenterConnection(project) { + // 1. invoke API and get result + val listAvailableCustomizationsResult = try { + CodeWhispererClientAdaptor.getInstance(project).listAvailableCustomizations() + } catch (e: Exception) { + val requestId = (e as? CodeWhispererRuntimeException)?.requestId() + val logMessage = if (CodeWhispererConstants.Customization.noAccessToCustomizationExceptionPredicate(e)) { + // TODO: not required for non GP users + "ListAvailableCustomizations: connection ${it.id} is not allowlisted, requestId: ${requestId.orEmpty()}" + } else { + "ListAvailableCustomizations: failed due to unknown error ${e.message}, requestId: ${requestId.orEmpty()}" + } + + LOG.debug { logMessage } + null + } + + // 2. get diff + val previousCustomizationsShapshot = connectionToCustomizationsShownLastTime.getOrElse(it.id) { emptyList() } + val diff = listAvailableCustomizationsResult?.filterNot { customization -> previousCustomizationsShapshot.contains(customization.arn) }?.toSet() + + // 3 if passive, + // (1) update allowlisting + // (2) prompt "You have New Customizations" toast notification (only show once) + // + // if not passive, + // (1) update the customization list snapshot (seen by users last time) if it will be displayed + if (passive) { + connectionIdToIsAllowlisted[it.id] = listAvailableCustomizationsResult != null + if (diff?.isNotEmpty() == true && !hasShownNewCustomizationNotification.getAndSet(true)) { + notifyNewCustomization(project) + } + } else { + listAvailableCustomizationsResult?.let { customizations -> + connectionToCustomizationsShownLastTime[it.id] = customizations.map { customization -> customization.arn }.toMutableList() + } + } + + // 4. invalidate selected customization if + // (1) the API call failed + // (2) the selected customization is not in the resultset of API call + activeCustomization(project)?.let { activeCustom -> + if (listAvailableCustomizationsResult == null) { + invalidateSelectedAndNotify(project) + } else if (!listAvailableCustomizationsResult.any { latestCustom -> latestCustom.arn == activeCustom.arn }) { + invalidateSelectedAndNotify(project) + } + } + + // 5. transform result to UI items and return + val customizationUiItems = if (diff != null) { + listAvailableCustomizationsResult.let { customizations -> + val nameToCount = customizations.groupingBy { customization -> customization.name }.eachCount() + + customizations.map { customization -> + CustomizationUiItem( + customization, + isNew = diff.contains(customization), + shouldPrefixAccountId = (nameToCount[customization.name] ?: 0) > 1 + ) + } + } + } else { + null + } + connectionToCustomizationUiItems[it.id] = customizationUiItems + + return@calculateIfIamIdentityCenterConnection customizationUiItems + } + + override fun activeCustomization(project: Project): CodeWhispererCustomization? = + calculateIfIamIdentityCenterConnection(project) { connectionIdToActiveCustomizationArn[it.id] } + + override fun switchCustomization(project: Project, newCustomization: CodeWhispererCustomization?) { + calculateIfIamIdentityCenterConnection(project) { + val oldCus = connectionIdToActiveCustomizationArn[it.id] + if (oldCus != newCustomization) { + newCustomization?.let { newCus -> + connectionIdToActiveCustomizationArn[it.id] = newCus + } ?: run { + connectionIdToActiveCustomizationArn.remove(it.id) + } + + LOG.debug { "Switch from customization $oldCus to $newCustomization" } + + CodeWhispererCustomizationListener.notifyCustomUiUpdate() + } + } + } + + override fun invalidateCustomization(arn: String) { + LOG.debug { "Invalidate customization arn: $arn" } + connectionIdToActiveCustomizationArn.entries.removeIf { (_, v) -> v.arn == arn } + CodeWhispererCustomizationListener.notifyCustomUiUpdate() + } + + /** + * @return boolean flag indicates if the CodeWhisperer connection associated with this project is allowlisted to Customization feat or not + * This method will return the result in memory first and fallback to false if there is no value exist in the memory, + * then will try fetch the latest from the server in the background thread and update the UI correspondingly + */ + override fun shouldDisplayCustomNode(project: Project, forceUpdate: Boolean): Boolean = if (ApplicationManager.getApplication().isUnitTestMode) { + false + } else { + calculateIfIamIdentityCenterConnection(project) { + val cachedValue = connectionIdToIsAllowlisted[it.id] + when (cachedValue) { + true -> true + + null -> run { + ApplicationManager.getApplication().executeOnPooledThread { + // will update devTool tree + listCustomizations(project, passive = true) + project.refreshCwQTree() + } + + false + } + + false -> run { + if (forceUpdate) { + ApplicationManager.getApplication().executeOnPooledThread { + // will update devTool tree + val updatedValue = listCustomizations(project, passive = true) != null + if (updatedValue != cachedValue) { + project.refreshCwQTree() + } + } + } + + cachedValue + } + } + } ?: false + } + + override fun getNewUpdate(connectionId: String) = connectionToCustomizationUiItems[connectionId] + + override fun getState(): CodeWhispererCustomizationState { + val state = CodeWhispererCustomizationState() + state.connectionIdToActiveCustomizationArn.putAll(this.connectionIdToActiveCustomizationArn) + state.previousAvailableCustomizations.putAll(this.connectionToCustomizationsShownLastTime) + + return state + } + + override fun loadState(state: CodeWhispererCustomizationState) { + connectionIdToActiveCustomizationArn.clear() + connectionIdToActiveCustomizationArn.putAll(state.connectionIdToActiveCustomizationArn) + + connectionToCustomizationsShownLastTime.clear() + connectionToCustomizationsShownLastTime.putAll(state.previousAvailableCustomizations) + } + + override fun dispose() {} + + private fun invalidateSelectedAndNotify(project: Project) { + activeCustomization(project)?.let { selectedCustom -> + val arn = selectedCustom.arn + switchCustomization(project, null) + invalidateCustomization(arn) + runInEdt(ModalityState.any()) { + notifyInvalidSelectedCustomization(project) + } + + CodeWhispererCustomizationListener.notifyCustomUiUpdate() + } + } + + companion object { + private val LOG = getLogger() + } +} + +class CodeWhispererCustomizationState : BaseState() { + @get:Property + @get:MapAnnotation + val connectionIdToActiveCustomizationArn by map() + + @get:Property + @get:MapAnnotation + val previousAvailableCustomizations by map>() +} + +data class CustomizationUiItem( + val customization: CodeWhispererCustomization, + val isNew: Boolean, + val shouldPrefixAccountId: Boolean +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEditorListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEditorListener.kt new file mode 100644 index 0000000000..09fc73026c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEditorListener.kt @@ -0,0 +1,43 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.editor + +import com.intellij.openapi.editor.event.BulkAwareDocumentListener +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.EditorFactoryEvent +import com.intellij.openapi.editor.event.EditorFactoryListener +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.psi.PsiDocumentManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStatus +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererCodeCoverageTracker + +class CodeWhispererEditorListener : EditorFactoryListener { + override fun editorCreated(event: EditorFactoryEvent) { + val editor = (event.editor as? EditorImpl) ?: return + editor.project?.let { project -> + PsiDocumentManager.getInstance(project).getPsiFile(editor.document)?.programmingLanguage() ?. let { language -> + // If language is not supported by CodeWhisperer, no action needed + if (!language.isCodeCompletionSupported()) return + // If language is supported, install document listener for CodeWhisperer service + editor.document.addDocumentListener( + object : BulkAwareDocumentListener { + // TODO: Track only deletion changes within the current 5-min interval which will give + // the most accurate code percentage data. + override fun documentChanged(event: DocumentEvent) { + if (!isCodeWhispererEnabled(project)) return + CodeWhispererInvocationStatus.getInstance().documentChanged() + CodeWhispererCodeCoverageTracker.getInstance(project, language).apply { + activateTrackerIfNotActive() + documentChanged(event) + } + } + }, + editor.disposable + ) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEditorManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEditorManager.kt new file mode 100644 index 0000000000..1ec6293714 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEditorManager.kt @@ -0,0 +1,271 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.editor + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiDocumentManager +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CaretMovement +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.PAIRED_BRACKETS +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.PAIRED_QUOTES +import java.time.Instant +import java.util.Stack + +class CodeWhispererEditorManager { + fun updateEditorWithRecommendation(states: InvocationContext, sessionContext: SessionContext) { + val (requestContext, responseContext, recommendationContext) = states + val (project, editor) = requestContext + val document = editor.document + val primaryCaret = editor.caretModel.primaryCaret + val selectedIndex = sessionContext.selectedIndex + val typeahead = sessionContext.typeahead + val detail = recommendationContext.details[selectedIndex] + val reformatted = CodeWhispererPopupManager.getInstance().getReformattedRecommendation( + detail, + recommendationContext.userInputSinceInvocation + ) + val remainingRecommendation = reformatted.substring(typeahead.length) + val originalOffset = primaryCaret.offset - typeahead.length + + val endOffset = primaryCaret.offset + remainingRecommendation.length + + val insertEndOffset = sessionContext.insertEndOffset + val endOffsetToReplace = if (insertEndOffset != -1) insertEndOffset else primaryCaret.offset + + WriteCommandAction.runWriteCommandAction(project) { + document.replaceString(originalOffset, endOffsetToReplace, reformatted) + PsiDocumentManager.getInstance(project).commitDocument(document) + primaryCaret.moveToOffset(endOffset + detail.rightOverlap.length) + } + + ApplicationManager.getApplication().invokeLater { + WriteCommandAction.runWriteCommandAction(project) { + val rangeMarker = document.createRangeMarker(originalOffset, endOffset, true) + + CodeWhispererTelemetryService.getInstance().enqueueAcceptedSuggestionEntry( + detail.requestId, + requestContext, + responseContext, + Instant.now(), + PsiDocumentManager.getInstance(project).getPsiFile(document)?.virtualFile, + rangeMarker, + remainingRecommendation, + selectedIndex, + detail.completionType + ) + + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED, + ).afterAccept(states, sessionContext, rangeMarker) + } + } + } + + private fun isMatchingSymbol(symbol: Char): Boolean = + PAIRED_BRACKETS.containsKey(symbol) || PAIRED_BRACKETS.containsValue(symbol) || PAIRED_QUOTES.contains(symbol) || + symbol.isWhitespace() + + fun getUserInputSinceInvocation(editor: Editor, invocationOffset: Int): String { + val currentOffset = editor.caretModel.primaryCaret.offset + return editor.document.getText(TextRange(invocationOffset, currentOffset)) + } + + fun getCaretMovement(editor: Editor, caretPosition: CaretPosition): CaretMovement { + val oldOffset = caretPosition.offset + val newOffset = editor.caretModel.primaryCaret.offset + return when { + oldOffset < newOffset -> CaretMovement.MOVE_FORWARD + oldOffset > newOffset -> CaretMovement.MOVE_BACKWARD + else -> CaretMovement.NO_CHANGE + } + } + + fun getMatchingSymbolsFromRecommendation( + editor: Editor, + recommendation: String, + isTruncatedOnRight: Boolean, + sessionContext: SessionContext + ): List> { + val result = mutableListOf>() + val bracketsStack = Stack() + val quotesStack = Stack>>() + val caretOffset = editor.caretModel.primaryCaret.offset + val document = editor.document + val lineEndOffset = document.getLineEndOffset(document.getLineNumber(caretOffset)) + val lineText = document.charsSequence.subSequence(caretOffset, lineEndOffset) + + var totalDocLengthChecked = 0 + var current = 0 + + result.add(0 to caretOffset) + result.add(recommendation.length + 1 to lineEndOffset) + + if (isTruncatedOnRight) return result + + while (current < recommendation.length && + totalDocLengthChecked < lineText.length && + totalDocLengthChecked < recommendation.length + ) { + val currentDocChar = lineText[totalDocLengthChecked] + if (!isMatchingSymbol(currentDocChar)) { + // currentDocChar is not a matching symbol, so we try to compare the remaining strings as a last step to match + val recommendationRemaining = recommendation.substring(current) + val rightContextRemaining = lineText.subSequence(totalDocLengthChecked, lineText.length).toString() + if (recommendationRemaining == rightContextRemaining) { + for (i in 1..recommendation.length - current) { + result.add(current + i to caretOffset + totalDocLengthChecked + i) + } + result.sortBy { it.first } + } + break + } + totalDocLengthChecked++ + + // find symbol in the recommendation that will match this + while (current < recommendation.length) { + val char = recommendation[current] + current++ + + // if char isn't a paired symbol, or it is, but it's not the matching currentDocChar or + // the opening version of it, then we're done + if (!isMatchingSymbol(char) || (char != currentDocChar && PAIRED_BRACKETS[char] != currentDocChar)) { + continue + } + + // if char is an opening bracket, push it to the stack + if (PAIRED_BRACKETS[char] == currentDocChar) { + bracketsStack.push(char) + continue + } + + // char is currentDocChar, it's one of a bracket, a quote, or a whitespace character. + // If it's a whitespace character, directly add it to the result, + // if it's a bracket or a quote, check if this char is already having a matching opening symbol + // on the stack + if (char.isWhitespace()) { + result.add(current to caretOffset + totalDocLengthChecked) + break + } else if (bracketsStack.isNotEmpty() && PAIRED_BRACKETS[bracketsStack.peek()] == char) { + bracketsStack.pop() + } else if (quotesStack.isNotEmpty() && quotesStack.peek().first == char) { + result.add(quotesStack.pop().second) + result.add(current to caretOffset + totalDocLengthChecked) + break + } else { + // char does not have a matching opening symbol in the stack, if it's a (opening) bracket, + // immediately add it to the result; if it's a quote, push it to the stack + if (PAIRED_QUOTES.contains(char)) { + quotesStack.push(char to (current to caretOffset + totalDocLengthChecked)) + } else { + result.add(current to caretOffset + totalDocLengthChecked) + } + break + } + } + } + + // if there are any symbols left in the stack, add them to the result + quotesStack.forEach { result.add(it.second) } + result.sortBy { it.first } + + sessionContext.insertEndOffset = result[result.size - 2].second + + return result + } + + // example: recommendation: document + // line1 + // line2 + // line3 line3 + // line4 + // ... + // number of lines overlapping would be one, and it will be line 3 + fun findOverLappingLines( + editor: Editor, + recommendationLines: List, + isTruncatedOnRight: Boolean, + sessionContext: SessionContext + ): Int { + val caretOffset = editor.caretModel.offset + if (isTruncatedOnRight) { + // insertEndOffset value only makes sense when there are matching closing brackets, if there's right context + // resolution applied, set this value to the current caret offset + sessionContext.insertEndOffset = caretOffset + return 0 + } + + val text = editor.document.charsSequence + val document = editor.document + val textLines = mutableListOf>() + val caretLine = document.getLineNumber(caretOffset) + var currentLineNum = caretLine + 1 + val recommendationLinesNotBlank = recommendationLines.filter { it.isNotBlank() } + while (currentLineNum < document.lineCount && textLines.size < recommendationLinesNotBlank.size) { + val currentLine = text.subSequence( + document.getLineStartOffset(currentLineNum), + document.getLineEndOffset(currentLineNum) + ) + if (currentLine.isNotBlank()) { + textLines.add(currentLine.toString() to document.getLineEndOffset(currentLineNum)) + } + currentLineNum++ + } + + val numOfNonEmptyLinesMatching = countNonEmptyLinesMatching(recommendationLinesNotBlank, textLines) + val numOfLinesMatching = countLinesMatching(recommendationLines, numOfNonEmptyLinesMatching) + if (numOfNonEmptyLinesMatching > 0) { + sessionContext.insertEndOffset = textLines[numOfNonEmptyLinesMatching - 1].second + } else if (recommendationLines.isNotEmpty()) { + sessionContext.insertEndOffset = document.getLineEndOffset(caretLine) + } + + return numOfLinesMatching + } + + private fun countLinesMatching(lines: List, targetNonEmptyLines: Int): Int { + var count = 0 + var nonEmptyCount = 0 + + for (line in lines.asReversed()) { + if (nonEmptyCount == targetNonEmptyLines) { + break + } + if (line.isNotBlank()) { + nonEmptyCount++ + } + count++ + } + return count + } + + private fun countNonEmptyLinesMatching(recommendationLines: List, textLines: List>): Int { + // i lines we want to match + for (i in textLines.size downTo 1) { + val recommendationStart = recommendationLines.size - i + var matching = true + for (j in 0 until i) { + if (recommendationLines[recommendationStart + j].trimEnd() != textLines[j].first.trimEnd()) { + matching = false + break + } + } + if (matching) { + return i + } + } + return 0 + } + + companion object { + fun getInstance(): CodeWhispererEditorManager = service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEditorUtil.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEditorUtil.kt new file mode 100644 index 0000000000..307926defe --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEditorUtil.kt @@ -0,0 +1,129 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.editor + +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.psi.PsiFile +import com.intellij.ui.popup.AbstractPopup +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJson +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererYaml +import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition +import software.aws.toolkits.jetbrains.services.codewhisperer.model.FileContextInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.LEFT_CONTEXT_ON_CURRENT_LINE +import java.awt.Point +import java.util.Locale +import kotlin.math.max +import kotlin.math.min + +object CodeWhispererEditorUtil { + fun getFileContextInfo(editor: Editor, psiFile: PsiFile): FileContextInfo { + val caretContext = extractCaretContext(editor) + val fileName = getFileName(psiFile) + val programmingLanguage = psiFile.programmingLanguage() + return FileContextInfo(caretContext, fileName, programmingLanguage) + } + + fun extractCaretContext(editor: Editor): CaretContext { + val document = editor.document + val caretOffset = editor.caretModel.primaryCaret.offset + val totalCharLength = editor.document.textLength + + val caretLeftFileContext = document.getText( + TextRange( + CodeWhispererConstants.BEGINNING_OF_FILE.coerceAtLeast(caretOffset - CodeWhispererConstants.CHARACTERS_LIMIT), + caretOffset + ) + ) + + val caretRightFileContext = document.getText( + TextRange( + caretOffset, + totalCharLength.coerceAtMost(CodeWhispererConstants.CHARACTERS_LIMIT + caretOffset) + ) + ) + + val lineNumber = document.getLineNumber(caretOffset) + val startOffset = document.getLineStartOffset(lineNumber) + var leftContextOnCurrentLine = document.getText(TextRange(startOffset, caretOffset)) + leftContextOnCurrentLine = leftContextOnCurrentLine.substring( + leftContextOnCurrentLine.length - leftContextOnCurrentLine.length.coerceAtMost(LEFT_CONTEXT_ON_CURRENT_LINE) + ) + + return CaretContext(caretLeftFileContext, caretRightFileContext, leftContextOnCurrentLine) + } + + fun getCaretPosition(editor: Editor): CaretPosition { + val offset = editor.caretModel.primaryCaret.offset + val line = editor.caretModel.primaryCaret.visualPosition.line + return CaretPosition(offset, line) + } + + private fun getFileName(psiFile: PsiFile): String = + psiFile.name.substring(0, psiFile.name.length.coerceAtMost(CodeWhispererConstants.FILENAME_CHARS_LIMIT)) + + fun getRelativePathToContentRoot(editor: Editor): String? = + editor.project?.let { project -> + FileDocumentManager.getInstance().getFile(editor.document)?.let { vFile -> + val fileIndex = ProjectFileIndex.getInstance(project) + val contentRoot = runReadAction { fileIndex.getContentRootForFile(vFile) } + contentRoot?.let { + VfsUtilCore.getRelativePath(vFile, it) + } + } + } + + fun getPopupPositionAboveText(editor: Editor, popup: JBPopup, offset: Int): Point { + val textAbsolutePosition = editor.offsetToXY(offset) + val editorLocation = editor.component.locationOnScreen + val editorContentLocation = editor.contentComponent.locationOnScreen + return Point( + editorContentLocation.x + textAbsolutePosition.x, + editorLocation.y + textAbsolutePosition.y - editor.scrollingModel.verticalScrollOffset - + (popup as AbstractPopup).preferredContentSize.height + ) + } + + fun shouldSkipInvokingBasedOnRightContext(editor: Editor): Boolean { + val caretContext = runReadAction { CodeWhispererEditorUtil.extractCaretContext(editor) } + val rightContextLines = caretContext.rightFileContext.split(Regex("\r?\n")) + val rightContextCurrentLine = if (rightContextLines.isEmpty()) "" else rightContextLines[0] + + return rightContextCurrentLine.isNotEmpty() && + !rightContextCurrentLine.startsWith(" ") && + rightContextCurrentLine.trim() != ("}") && + rightContextCurrentLine.trim() != (")") + } + + /** + * Checks if the language is json or yaml and checks if left context contains keywords + */ + fun checkLeftContextKeywordsForJsonAndYaml(leftContext: String, language: String): Boolean = ( + (language == CodeWhispererJson.INSTANCE.languageId) || + (language == CodeWhispererYaml.INSTANCE.languageId) + ) && + ( + (!CodeWhispererConstants.AWSTemplateKeyWordsRegex.containsMatchIn(leftContext)) && + (!CodeWhispererConstants.AWSTemplateCaseInsensitiveKeyWordsRegex.containsMatchIn(leftContext.lowercase(Locale.getDefault()))) + ) + + /** + * Checks if the [otherRange] overlaps this TextRange. Note that the comparison is `<` because the endOffset of TextRange is exclusive. + */ + fun TextRange.overlaps(otherRange: TextRange): Boolean = + if (otherRange.isEmpty) { + // Handle case when otherRange is empty and within the range + otherRange.startOffset in startOffset until endOffset + } else { + max(startOffset, otherRange.startOffset) < min(endOffset, otherRange.endOffset) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEnterHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEnterHandler.kt new file mode 100644 index 0000000000..db1fe23541 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererEnterHandler.kt @@ -0,0 +1,28 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.editor + +import com.intellij.codeInsight.editorActions.EnterHandler +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.actionSystem.EditorActionHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.shouldSkipInvokingBasedOnRightContext +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutoTriggerService +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutomatedTriggerType + +class CodeWhispererEnterHandler(private val originalHandler: EditorActionHandler) : EnterHandler(originalHandler) { + override fun executeWriteAction(editor: Editor, caret: Caret?, dataContext: DataContext?) { + originalHandler.execute(editor, caret, dataContext) + + if (shouldSkipInvokingBasedOnRightContext(editor)) { + return + } + + ApplicationManager.getApplication().executeOnPooledThread { + CodeWhispererAutoTriggerService.getInstance().tryInvokeAutoTrigger(editor, CodeWhispererAutomatedTriggerType.Enter()) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererTypedHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererTypedHandler.kt new file mode 100644 index 0000000000..1884569409 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/editor/CodeWhispererTypedHandler.kt @@ -0,0 +1,36 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.editor + +import com.intellij.codeInsight.editorActions.TypedHandlerDelegate +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile +import kotlinx.coroutines.Job +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.shouldSkipInvokingBasedOnRightContext +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutoTriggerService +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutomatedTriggerType +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants + +class CodeWhispererTypedHandler : TypedHandlerDelegate() { + private var triggerOnIdle: Job? = null + override fun charTyped(c: Char, project: Project, editor: Editor, psiFiles: PsiFile): Result { + triggerOnIdle?.cancel() + + if (shouldSkipInvokingBasedOnRightContext(editor) + ) { + return Result.CONTINUE + } + + // Special Char + if (CodeWhispererConstants.SPECIAL_CHARACTERS_LIST.contains(c.toString())) { + CodeWhispererAutoTriggerService.getInstance().tryInvokeAutoTrigger(editor, CodeWhispererAutomatedTriggerType.SpecialChar(c)) + return Result.CONTINUE + } + + CodeWhispererAutoTriggerService.getInstance().tryInvokeAutoTrigger(editor, CodeWhispererAutomatedTriggerType.Classifier()) + + return Result.CONTINUE + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerActionManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerActionManager.kt new file mode 100644 index 0000000000..d79563c3af --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerActionManager.kt @@ -0,0 +1,214 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer + +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.util.xmlb.annotations.Property +import org.jetbrains.annotations.ApiStatus.ScheduledForRemoval +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.isSono +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.core.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getConnectionStartUrl +import software.aws.toolkits.telemetry.AwsTelemetry +import java.time.LocalDateTime + +// TODO: refactor this class, now it's managing action and state +@State(name = "codewhispererStates", storages = [Storage("aws.xml")]) +class CodeWhispererExplorerActionManager : PersistentStateComponent { + private val actionState = CodeWhispererExploreActionState() + private val suspendedConnections = mutableSetOf() + + fun isSuspended(project: Project): Boolean { + val startUrl = getCodeWhispererConnectionStartUrl(project) + return suspendedConnections.contains(startUrl) + } + + fun setSuspended(project: Project) { + val startUrl = getCodeWhispererConnectionStartUrl(project) + if (!suspendedConnections.add(startUrl)) { + return + } + project.refreshCwQTree() + } + + private fun getCodeWhispererConnectionStartUrl(project: Project): String { + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance()) + return getConnectionStartUrl(connection) ?: CodeWhispererConstants.ACCOUNTLESS_START_URL + } + + fun isAutoEnabled(): Boolean = actionState.value.getOrDefault(CodeWhispererExploreStateType.IsAutoEnabled, true) + + fun setAutoEnabled(isAutoEnabled: Boolean) { + actionState.value[CodeWhispererExploreStateType.IsAutoEnabled] = isAutoEnabled + } + + fun setHasShownNewOnboardingPage(hasShownNewOnboardingPage: Boolean) { + actionState.value[CodeWhispererExploreStateType.HasShownNewOnboardingPage] = hasShownNewOnboardingPage + } + + fun setAccountlessNotificationWarnTimestamp() { + actionState.accountlessWarnTimestamp = LocalDateTime.now().format(CodeWhispererConstants.TIMESTAMP_FORMATTER) + } + + fun setAccountlessNotificationErrorTimestamp() { + actionState.accountlessErrorTimestamp = LocalDateTime.now().format(CodeWhispererConstants.TIMESTAMP_FORMATTER) + } + + fun getAccountlessWarnNotificationTimestamp(): String? = actionState.accountlessWarnTimestamp + + fun getAccountlessErrorNotificationTimestamp(): String? = actionState.accountlessErrorTimestamp + + fun getDoNotShowAgainWarn(): Boolean = actionState.value.getOrDefault(CodeWhispererExploreStateType.DoNotShowAgainWarn, false) + + fun setDoNotShowAgainWarn(doNotShowAgain: Boolean) { + actionState.value[CodeWhispererExploreStateType.DoNotShowAgainWarn] = doNotShowAgain + } + + fun getDoNotShowAgainError(): Boolean = actionState.value.getOrDefault(CodeWhispererExploreStateType.DoNotShowAgainError, false) + + fun setDoNotShowAgainError(doNotShowAgain: Boolean) { + actionState.value[CodeWhispererExploreStateType.DoNotShowAgainError] = doNotShowAgain + } + + fun getConnectionExpiredDoNotShowAgain(): Boolean = actionState.value.getOrDefault(CodeWhispererExploreStateType.ConnectionExpiredDoNotShowAgain, false) + + fun setConnectionExpiredDoNotShowAgain(doNotShowAgain: Boolean) { + actionState.value[CodeWhispererExploreStateType.ConnectionExpiredDoNotShowAgain] = doNotShowAgain + } + + fun getAccountlessNullified(): Boolean = actionState.value.getOrDefault(CodeWhispererExploreStateType.AccountlessNullified, false) + + fun setAccountlessNullified(accountlessNullified: Boolean) { + actionState.value[CodeWhispererExploreStateType.AccountlessNullified] = accountlessNullified + } + + fun setAutoSuggestion(project: Project, isAutoEnabled: Boolean) { + setAutoEnabled(isAutoEnabled) + val autoSuggestionState = if (isAutoEnabled) CodeWhispererConstants.AutoSuggestion.ACTIVATED else CodeWhispererConstants.AutoSuggestion.DEACTIVATED + AwsTelemetry.modifySetting(project, settingId = CodeWhispererConstants.AutoSuggestion.SETTING_ID, settingState = autoSuggestionState) + project.refreshCwQTree() + } + + @Deprecated("Accountless credential will be removed soon") + @ScheduledForRemoval + // Will keep it for existing accountless users + /** + * Will be called from CodeWhispererService.showRecommendationInPopup() + * Caller (e.x. CodeWhispererService) should take care if null value returned, popup a notification/hint window or dialog etc. + */ + fun resolveAccessToken(): String? { + if (actionState.token == null) { + LOG.warn { "Logical Error: Try to get access token before token initialization" } + } + return actionState.token + } + + fun checkActiveCodeWhispererConnectionType(project: Project): CodeWhispererLoginType { + val conn = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance()) as? AwsBearerTokenConnection + return conn?.let { + val provider = (it.getConnectionSettings().tokenProvider.delegate as? BearerTokenProvider) ?: return@let CodeWhispererLoginType.Logout + + when (provider.state()) { + BearerTokenAuthState.AUTHORIZED -> { + if (it.isSono()) { + CodeWhispererLoginType.Sono + } else { + CodeWhispererLoginType.SSO + } + } + + BearerTokenAuthState.NEEDS_REFRESH -> CodeWhispererLoginType.Expired + + BearerTokenAuthState.NOT_AUTHENTICATED -> CodeWhispererLoginType.Logout + } + } ?: CodeWhispererLoginType.Logout + } + + fun nullifyAccountlessCredentialIfNeeded() { + if (actionState.token != null) { + setAccountlessNullified(true) + actionState.token = null + } + } + + override fun getState(): CodeWhispererExploreActionState = CodeWhispererExploreActionState().apply { + value.putAll(actionState.value) + token = actionState.token + accountlessWarnTimestamp = actionState.accountlessWarnTimestamp + accountlessErrorTimestamp = actionState.accountlessErrorTimestamp + } + + override fun loadState(state: CodeWhispererExploreActionState) { + actionState.value.clear() + actionState.token = state.token + actionState.value.putAll(state.value) + actionState.accountlessWarnTimestamp = state.accountlessWarnTimestamp + actionState.accountlessErrorTimestamp = state.accountlessErrorTimestamp + } + + companion object { + @JvmStatic + fun getInstance(): CodeWhispererExplorerActionManager = service() + + private val LOG = getLogger() + } +} + +class CodeWhispererExploreActionState : BaseState() { + @get:Property + val value by map() + + // can not remove this as we want to support existing accountless users + @get:Property + var token by string() + + @get:Property + var accountlessWarnTimestamp by string() + + @get:Property + var accountlessErrorTimestamp by string() +} + +// TODO: Don't remove IsManualEnabled +enum class CodeWhispererExploreStateType { + IsAutoEnabled, + IsManualEnabled, + HasAcceptedTermsOfServices, + HasShownHowToUseCodeWhisperer, + HasShownNewOnboardingPage, + DoNotShowAgainWarn, + DoNotShowAgainError, + AccountlessNullified, + ConnectionExpiredDoNotShowAgain +} + +interface CodeWhispererActivationChangedListener { + fun activationChanged(value: Boolean) {} +} + +fun isCodeWhispererEnabled(project: Project) = with(CodeWhispererExplorerActionManager.getInstance()) { + checkActiveCodeWhispererConnectionType(project) != CodeWhispererLoginType.Logout +} + +/** + * Note: please use this util with extra caution, it will return "false" for a "logout" scenario, + * the reasoning is we need handling specifically for a "Expired" condition thus excluding logout from here + * If callers rather need a predicate "isInvalidConnection", please use the combination of the two (!isCodeWhispererEnabled() || isCodeWhispererExpired()) + */ +fun isCodeWhispererExpired(project: Project) = with(CodeWhispererExplorerActionManager.getInstance()) { + checkActiveCodeWhispererConnectionType(project) == CodeWhispererLoginType.Expired +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerTreeStructureProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerTreeStructureProvider.kt new file mode 100644 index 0000000000..2d54012d24 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererExplorerTreeStructureProvider.kt @@ -0,0 +1,22 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer + +import com.intellij.ide.util.treeView.AbstractTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.DevToolsTreeStructureProvider +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.CodeWhispererActionNode + +class CodeWhispererExplorerTreeStructureProvider : DevToolsTreeStructureProvider() { + override fun modify(parent: AbstractTreeNode<*>, children: MutableCollection>): MutableCollection> = + when (parent) { + is CodeWhispererServiceNode -> + children + .sortedWith { x, y -> + val order1 = (x as? CodeWhispererActionNode)?.order ?: Int.MAX_VALUE + val order2 = (y as? CodeWhispererActionNode)?.order ?: Int.MAX_VALUE + order1.compareTo(order2) + }.toMutableList() + else -> children + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererNodeActionGroup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererNodeActionGroup.kt new file mode 100644 index 0000000000..49e282e164 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererNodeActionGroup.kt @@ -0,0 +1,48 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Separator +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.actions.SsoLogoutAction +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererShowSettingsAction +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions.ActionProvider +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions.Customize +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions.Learn +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions.OpenCodeReference +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions.Pause +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions.Resume +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions.buildActionList + +class CodeWhispererNodeActionGroup : DefaultActionGroup() { + private val actionProvider = object : ActionProvider { + override val pause = Pause() + override val resume = Resume() + override val openCodeReference = OpenCodeReference() + override val customize = Customize() + override val learn = Learn() + } + + override fun getChildren(e: AnActionEvent?) = e?.project?.let { + buildList { + addAll(buildActionList(it, actionProvider)) + + add(Separator.create()) + + add(CodeWhispererShowSettingsAction()) + + ToolkitConnectionManager.getInstance(it).activeConnectionForFeature(CodeWhispererConnection.getInstance())?.let { c -> + (c as? AwsBearerTokenConnection)?.let { connection -> + add(SsoLogoutAction(connection)) + } + } + }.toTypedArray() + }.orEmpty() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererServiceNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererServiceNode.kt new file mode 100644 index 0000000000..829d7e5adb --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/CodeWhispererServiceNode.kt @@ -0,0 +1,124 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer + +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import com.intellij.util.text.DateTimeFormatManager +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.PinnedConnectionNode +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions.ActionProvider +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions.buildActionList +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.CodeWhispererReconnectNode +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.CustomizationNode +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.FreeTierUsageLimitHitNode +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.GetStartedNode +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.LearnCodeWhispererNode +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.OpenCodeReferenceNode +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.PauseCodeWhispererNode +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.ResumeCodeWhispererNode +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.RunCodeScanNode +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes.WhatIsCodeWhispererNode +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend +import software.aws.toolkits.resources.message +import java.awt.event.MouseEvent +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAdjusters + +class CodeWhispererServiceNode( + project: Project, + value: String, +) : AbstractActionTreeNode(project, value, null), ActionGroupOnRightClick, PinnedConnectionNode { + private val nodeProject + get() = myProject + private val whatIsCodeWhispererNode by lazy { WhatIsCodeWhispererNode(nodeProject) } + private val getStartedCodeWhispererNode by lazy { GetStartedNode(nodeProject) } + private val runCodeScanNode by lazy { RunCodeScanNode(nodeProject) } + private val codeWhispererReconnectNode by lazy { CodeWhispererReconnectNode(nodeProject) } + private val freeTierUsageLimitHitNode by lazy { + // we should probably build the text dynamically in case the format setting changes, + // but that shouldn't happen often enough for us to care + val formatter = tryOrNull { + DateTimeFormatter.ofPattern(DateTimeFormatManager.getInstance().dateFormatPattern) + } ?: DateTimeFormatter.ofPattern(DateTimeFormatManager.DEFAULT_DATE_FORMAT) + val date = LocalDate.now().with(TemporalAdjusters.firstDayOfNextMonth()) + + FreeTierUsageLimitHitNode(nodeProject, formatter.format(date)) + } + private val actionProvider by lazy { + object : ActionProvider> { + override val pause = PauseCodeWhispererNode(nodeProject) + override val resume = ResumeCodeWhispererNode(nodeProject) + override val openCodeReference = OpenCodeReferenceNode(nodeProject) + override val customize = CustomizationNode(nodeProject) + override val learn = LearnCodeWhispererNode(nodeProject) + } + } + + override fun onDoubleClick(event: MouseEvent) {} + + override fun getChildren(): Collection> { + if (isRunningOnRemoteBackend()) { + return emptyList() + } + + val manager = CodeWhispererExplorerActionManager.getInstance() + val activeConnectionType = manager.checkActiveCodeWhispererConnectionType(project) + + return when (activeConnectionType) { + CodeWhispererLoginType.Logout -> listOf(getStartedCodeWhispererNode, whatIsCodeWhispererNode) + CodeWhispererLoginType.Expired -> listOf(codeWhispererReconnectNode, whatIsCodeWhispererNode) + + else -> { + if (manager.isSuspended(nodeProject)) { + return listOf(freeTierUsageLimitHitNode, runCodeScanNode, actionProvider.openCodeReference) + } + + return buildActionList(nodeProject, actionProvider) + listOf( + runCodeScanNode, + ) + } + } + } + + override fun update(presentation: PresentationData) { + super.update(presentation) + if (isRunningOnRemoteBackend()) { + presentation.addText(message("codewhisperer.explorer.root_node.unavailable"), SimpleTextAttributes.GRAY_ATTRIBUTES) + return + } + + val connectionType = CodeWhispererExplorerActionManager.getInstance().checkActiveCodeWhispererConnectionType(project) + when (connectionType) { + CodeWhispererLoginType.Expired -> { + presentation.addText(message("codewhisperer.explorer.root_node.login_type.expired"), SimpleTextAttributes.GRAY_ATTRIBUTES) + } + + CodeWhispererLoginType.Accountless -> { + presentation.addText(message("codewhisperer.explorer.root_node.login_type.accountless"), SimpleTextAttributes.GRAY_ATTRIBUTES) + } + + CodeWhispererLoginType.SSO -> { + presentation.addText(message("codewhisperer.explorer.root_node.login_type.sso"), SimpleTextAttributes.GRAY_ATTRIBUTES) + } + + CodeWhispererLoginType.Sono -> { + presentation.addText(message("codewhisperer.explorer.root_node.login_type.aws_builder_id"), SimpleTextAttributes.GRAY_ATTRIBUTES) + } + + else -> {} + } + } + + override fun actionGroupName(): String = "aws.toolkit.explorer.codewhisperer" + + override fun feature() = CodeWhispererConnection.getInstance() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/ActionFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/ActionFactory.kt new file mode 100644 index 0000000000..edc8a6aaed --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/ActionFactory.kt @@ -0,0 +1,41 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager + +interface ActionProvider { + val pause: T + val resume: T + val openCodeReference: T + val customize: T + val learn: T +} + +fun buildActionList(project: Project, actionProvider: ActionProvider): List { + val manager = CodeWhispererExplorerActionManager.getInstance() + val activeConnectionType = manager.checkActiveCodeWhispererConnectionType(project) + + return buildList { + if (manager.isAutoEnabled()) { + add(actionProvider.pause) + } else { + add(actionProvider.resume) + } + + add(actionProvider.openCodeReference) + + // We only show this customization node to SSO users who are in CodeWhisperer Gated Preview list + if (activeConnectionType == CodeWhispererLoginType.SSO && + CodeWhispererModelConfigurator.getInstance().shouldDisplayCustomNode(project) + ) { + add(actionProvider.customize) + } + + add(actionProvider.learn) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Customize.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Customize.kt new file mode 100644 index 0000000000..0715523e2d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Customize.kt @@ -0,0 +1,43 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import icons.AwsIcons +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.resources.message + +class Customize : DumbAwareAction( + { message("codewhisperer.explorer.customization.select") }, + AwsIcons.Resources.CodeWhisperer.CUSTOM +) { + override fun update(e: AnActionEvent) { + val project = e.project ?: return + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance()) + val activeCustomization = CodeWhispererModelConfigurator.getInstance().activeCustomization(project) + + if (connection != null) { + val newCount = CodeWhispererModelConfigurator.getInstance().getNewUpdate(connection.id)?.count { it.isNew } ?: 0 + + val suffix = if (newCount > 0) { + " ($newCount new)" + } else if (activeCustomization != null) { + " ${activeCustomization.name}" + } else { + "" + } + + e.presentation.text = message("codewhisperer.explorer.customization.select") + suffix + } + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + + CodeWhispererModelConfigurator.getInstance().showConfigDialog(project) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Learn.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Learn.kt new file mode 100644 index 0000000000..254482919d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Learn.kt @@ -0,0 +1,23 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import icons.AwsIcons +import software.aws.toolkits.jetbrains.services.codewhisperer.learn.LearnCodeWhispererEditorProvider +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.UiTelemetry + +class Learn : DumbAwareAction( + { message("codewhisperer.explorer.learn") }, + AwsIcons.Misc.LEARN +) { + override fun actionPerformed(e: AnActionEvent) { + UiTelemetry.click(e.project, "codewhisperer_Learn_ButtonClick") + val project = e.project ?: return + + LearnCodeWhispererEditorProvider.openEditor(project) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/OpenCodeReference.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/OpenCodeReference.kt new file mode 100644 index 0000000000..cda045a91d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/OpenCodeReference.kt @@ -0,0 +1,20 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow.CodeWhispererCodeReferenceManager +import software.aws.toolkits.resources.message + +class OpenCodeReference : DumbAwareAction( + { message("codewhisperer.explorer.code_reference.open") }, + AllIcons.Actions.Preview +) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + CodeWhispererCodeReferenceManager.getInstance(project).showCodeReferencePanel() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Pause.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Pause.kt new file mode 100644 index 0000000000..bb1963fc58 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Pause.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.resources.message + +class Pause : DumbAwareAction( + { message("codewhisperer.explorer.pause_auto") }, + AllIcons.Actions.Pause +) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + + CodeWhispererExplorerActionManager.getInstance().setAutoSuggestion(project, false) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Resume.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Resume.kt new file mode 100644 index 0000000000..d3d1611ccd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/actions/Resume.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.resources.message + +class Resume : DumbAwareAction( + { message("codewhisperer.explorer.resume_auto") }, + AllIcons.Actions.Resume +) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + + CodeWhispererExplorerActionManager.getInstance().setAutoSuggestion(project, true) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/CodeWhispererActionNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/CodeWhispererActionNode.kt new file mode 100644 index 0000000000..4af484e5ee --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/CodeWhispererActionNode.kt @@ -0,0 +1,22 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode +import javax.swing.Icon + +abstract class CodeWhispererActionNode( + project: Project, + actionName: String, + val order: Int, + icon: Icon +) : AbstractActionTreeNode( + project, + actionName, + icon +) { + override fun getChildren(): List> = emptyList() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/CodeWhispererReconnectNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/CodeWhispererReconnectNode.kt new file mode 100644 index 0000000000..551ef8ce0d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/CodeWhispererReconnectNode.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.reconnectCodeWhisperer +import software.aws.toolkits.resources.message +import java.awt.event.MouseEvent + +class CodeWhispererReconnectNode(nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + message("codewhisperer.explorer.reconnect"), + 0, + AllIcons.Actions.Execute +) { + override fun onDoubleClick(event: MouseEvent) { + reconnectCodeWhisperer(project) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/CustomizationNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/CustomizationNode.kt new file mode 100644 index 0000000000..2de506b991 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/CustomizationNode.kt @@ -0,0 +1,42 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.ide.projectView.PresentationData +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import icons.AwsIcons +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.resources.message +import java.awt.event.MouseEvent + +class CustomizationNode(nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + message("codewhisperer.explorer.customization.select"), + 2, + AwsIcons.Resources.CodeWhisperer.CUSTOM +) { + override fun update(presentation: PresentationData) { + super.update(presentation) + + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance()) + val activeCustomization = CodeWhispererModelConfigurator.getInstance().activeCustomization(project) + + if (connection != null) { + val newCount = CodeWhispererModelConfigurator.getInstance().getNewUpdate(connection.id)?.count { it.isNew } ?: 0 + + if (newCount > 0) { + presentation.addText(" ($newCount new)", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } else if (activeCustomization != null) { + presentation.addText(" ${activeCustomization.name}", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + } + } + + override fun onDoubleClick(event: MouseEvent) { + CodeWhispererModelConfigurator.getInstance().showConfigDialog(project) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/FreeTierUsageLimitHitNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/FreeTierUsageLimitHitNode.kt new file mode 100644 index 0000000000..fddb631e4e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/FreeTierUsageLimitHitNode.kt @@ -0,0 +1,18 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import software.aws.toolkits.resources.message +import java.awt.event.MouseEvent + +class FreeTierUsageLimitHitNode(nodeProject: Project, val date: String) : CodeWhispererActionNode( + nodeProject, + message("codewhisperer.explorer.usage_limit_hit", date), + -1, + AllIcons.Actions.Suspend +) { + override fun onDoubleClick(event: MouseEvent) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/GetStartedNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/GetStartedNode.kt new file mode 100644 index 0000000000..2c1002a308 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/GetStartedNode.kt @@ -0,0 +1,58 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.credentials.reauthConnectionIfNeeded +import software.aws.toolkits.jetbrains.core.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsForCodeWhisperer +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.startup.CodeWhispererProjectStartupActivity +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.UiTelemetry +import java.awt.event.MouseEvent + +class GetStartedNode(nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + message("q.sign.in"), + 0, + AllIcons.CodeWithMe.CwmAccess +) { + override fun onDoubleClick(event: MouseEvent) { + enableCodeWhisperer(project) + UiTelemetry.click(project, "cw_signUp_Cta") + UiTelemetry.click(project, "auth_start_CodeWhisperer") + } + + /** + * 2 cases + * (1) User who don't have SSO based connection click on CodeWhisperer Start node + * (2) User who already have SSO based connection from previous operation via i.g. Toolkit Add Connection click on CodeWhisperer Start node + */ + private fun enableCodeWhisperer(project: Project) { + val connectionManager = ToolkitConnectionManager.getInstance(project) + connectionManager.activeConnectionForFeature(CodeWhispererConnection.getInstance())?.let { + project.refreshCwQTree() + reauthConnectionIfNeeded(project, it) + } ?: run { + runInEdt { + // Start from scratch if no active connection + if (requestCredentialsForCodeWhisperer(project)) { + project.refreshCwQTree() + } + } + } + + if (isCodeWhispererEnabled(project)) { + StartupActivity.POST_STARTUP_ACTIVITY.findExtension(CodeWhispererProjectStartupActivity::class.java)?.let { + it.runActivity(project) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/LearnCodeWhispererNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/LearnCodeWhispererNode.kt new file mode 100644 index 0000000000..c7bed4b803 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/LearnCodeWhispererNode.kt @@ -0,0 +1,23 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.openapi.project.Project +import icons.AwsIcons +import software.aws.toolkits.jetbrains.services.codewhisperer.learn.LearnCodeWhispererEditorProvider +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.UiTelemetry +import java.awt.event.MouseEvent + +class LearnCodeWhispererNode(nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + message("codewhisperer.explorer.learn"), + 4, + AwsIcons.Misc.LEARN +) { + override fun onDoubleClick(event: MouseEvent) { + UiTelemetry.click(project, "codewhisperer_Learn_ButtonClick") + LearnCodeWhispererEditorProvider.openEditor(project) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/OpenCodeReferenceNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/OpenCodeReferenceNode.kt new file mode 100644 index 0000000000..d259f6837b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/OpenCodeReferenceNode.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow.CodeWhispererCodeReferenceManager +import software.aws.toolkits.resources.message +import java.awt.event.MouseEvent + +class OpenCodeReferenceNode(nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + message("codewhisperer.explorer.code_reference.open"), + 3, + AllIcons.Actions.Preview +) { + override fun onDoubleClick(event: MouseEvent) { + CodeWhispererCodeReferenceManager.getInstance(project).showCodeReferencePanel() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/PauseCodeWhispererNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/PauseCodeWhispererNode.kt new file mode 100644 index 0000000000..303023cffe --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/PauseCodeWhispererNode.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.resources.message +import java.awt.event.MouseEvent + +class PauseCodeWhispererNode(nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + message("codewhisperer.explorer.pause_auto"), + 1, + AllIcons.Actions.Pause +) { + override fun onDoubleClick(event: MouseEvent) { + CodeWhispererExplorerActionManager.getInstance().setAutoSuggestion(project, false) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/ResumeCodeWhispererNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/ResumeCodeWhispererNode.kt new file mode 100644 index 0000000000..b7031502f4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/ResumeCodeWhispererNode.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.resources.message +import java.awt.event.MouseEvent + +class ResumeCodeWhispererNode(nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + message("codewhisperer.explorer.resume_auto"), + 1, + AllIcons.Actions.Resume +) { + override fun onDoubleClick(event: MouseEvent) { + CodeWhispererExplorerActionManager.getInstance().setAutoSuggestion(project, true) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/RunCodeScanNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/RunCodeScanNode.kt new file mode 100644 index 0000000000..d17113c0a5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/RunCodeScanNode.kt @@ -0,0 +1,26 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager +import java.awt.event.MouseEvent + +class RunCodeScanNode(nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + CodeWhispererCodeScanManager.getInstance(nodeProject).getActionButtonText(), + 2, + CodeWhispererCodeScanManager.getInstance(nodeProject).getActionButtonIconForExplorerNode() +) { + + private val codeScanManager = CodeWhispererCodeScanManager.getInstance(project) + + override fun onDoubleClick(event: MouseEvent) { + if (codeScanManager.isCodeScanInProgress()) { + codeScanManager.stopCodeScan() + } else { + codeScanManager.runCodeScan() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/WhatIsCodeWhispererNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/WhatIsCodeWhispererNode.kt new file mode 100644 index 0000000000..0eb19f162d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/explorer/nodes/WhatIsCodeWhispererNode.kt @@ -0,0 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.UiTelemetry +import java.awt.event.MouseEvent +import java.net.URI + +class WhatIsCodeWhispererNode(nodeProject: Project) : CodeWhispererActionNode( + nodeProject, + message("codewhisperer.explorer.what_is"), + 1, + AllIcons.Actions.Help +) { + override fun onDoubleClick(event: MouseEvent) { + BrowserUtil.browse(URI(CodeWhispererConstants.CODEWHISPERER_LEARN_MORE_URI)) + UiTelemetry.click(project, "cw_learnMore_Cta") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererFallbackImportAdder.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererFallbackImportAdder.kt new file mode 100644 index 0000000000..537a3cdf28 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererFallbackImportAdder.kt @@ -0,0 +1,61 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.importadder + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileTypes.PlainTextLanguage +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiFileFactory +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererUnknownLanguage + +/** + * This class provides a fallback implementation of the CodeWhispererImportAdder abstract class for handling + * import statements for Java, Python, and JavaScript files in cases when the respective language plugins are not + * installed. It is meant to be generic and create bare import elements and insert them to the top of the file. + */ +class CodeWhispererFallbackImportAdder : CodeWhispererImportAdder() { + override val supportedLanguages: List = listOf( + CodeWhispererUnknownLanguage.INSTANCE + ) + override val dummyFileName: String = "test.txt" + + override fun createNewImportPsiElement(psiFile: PsiFile, statement: String): PsiElement? { + val statementWithNewLine = if (statement.endsWith("\n")) statement else "$statement\n" + val project = psiFile.project + val fileFactory = PsiFileFactory.getInstance(project) + val dummyFile = fileFactory.createFileFromText(dummyFileName, PlainTextLanguage.INSTANCE, statementWithNewLine) + ?: return null + return dummyFile.firstChild + } + + /** + * Always returns null, as duplicates are not checked in the fallback implementation. + */ + override fun hasDuplicatedImportsHelper(newImport: PsiElement, existingImports: List): PsiElement? = null + + /** + * Always returns an empty list, as top-level imports are not checked in the fallback implementation. + */ + override fun getTopLevelImports(psiFile: PsiFile, editor: Editor): List = emptyList() + + /** + * Always returns an empty list, as local imports are not checked in the fallback implementation. + */ + override fun getLocalImports(psiFile: PsiFile, editor: Editor): List = emptyList() + + /** + * Adds the new import statement to the beginning of the file. + */ + override fun addImport(psiFile: PsiFile, editor: Editor, newImport: PsiElement): Boolean { + val first = psiFile.firstChild + if (first == null) { + psiFile.add(newImport) + } else { + psiFile.addBefore(newImport, first) + } + return true + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererImportAdder.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererImportAdder.kt new file mode 100644 index 0000000000..e2f8945c0b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererImportAdder.kt @@ -0,0 +1,115 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.importadder + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import software.amazon.awssdk.services.codewhispererruntime.model.Import +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContext + +abstract class CodeWhispererImportAdder { + abstract val supportedLanguages: List + abstract val dummyFileName: String + + fun insertImportStatements(states: InvocationContext, sessionContext: SessionContext) { + val imports = states.recommendationContext.details[sessionContext.selectedIndex] + .recommendation.mostRelevantMissingImports() + LOG.info { "Adding ${imports.size} imports for completions, sessionId: ${states.responseContext.sessionId}" } + imports.forEach { + insertImportStatement(states, it) + } + } + + private fun insertImportStatement(states: InvocationContext, import: Import) { + val project = states.requestContext.project + val editor = states.requestContext.editor + val document = editor.document + val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(document) ?: return + + val statement = import.statement() + LOG.info { "Import statement to be added: $statement" } + val newImport = createNewImportPsiElement(psiFile, statement) + if (newImport == null) { + LOG.debug { "Failed to create the import element using the import string" } + return + } + + if (!isSupportedImportStyle(newImport)) { + LOG.debug { "Import statement \"${newImport.text}\" is not supported" } + return + } + + LOG.debug { "Checking duplicates with existing imports" } + val hasDuplicate = hasDuplicatedImports(psiFile, editor, newImport) + if (hasDuplicate) { + LOG.debug { "Found duplicates with existing imports, not adding the new import" } + return + } else { + LOG.debug { "Found no duplicates with existing imports" } + } + + val added = addImport(psiFile, editor, newImport) + LOG.info { "Added import: $added" } + } + + abstract fun createNewImportPsiElement(psiFile: PsiFile, statement: String): PsiElement? + + open fun isSupportedImportStyle(newImport: PsiElement) = true + + // Currently if the new import is 'from a import b, c', a duplicate match to any of the import element + // will return as a valid duplicate to the whole import statement. + open fun hasDuplicatedImports(psiFile: PsiFile, editor: Editor, newImport: PsiElement): Boolean { + val topLevelImports = getTopLevelImports(psiFile, editor) + LOG.debug { + "Checking top-level imports: [${topLevelImports.map { it.text }.reduceOrNull { acc, s -> "$acc, $s" }.orEmpty()}]" + } + + var duplicate = hasDuplicatedImportsHelper(newImport, topLevelImports) + if (duplicate != null) { + LOG.debug { "Found duplicates from top-level imports \"${duplicate?.text}\"" } + return true + } else { + LOG.debug { "Found no duplicates from top-level imports" } + } + + val localImports = getLocalImports(psiFile, editor) + LOG.debug { + "Checking local imports: [${localImports.map { it.text }.reduceOrNull { acc, s -> "$acc, $s" }.orEmpty()}]" + } + duplicate = hasDuplicatedImportsHelper(newImport, localImports) + if (duplicate != null) { + LOG.debug { "Found duplicates from local imports \"${duplicate.text}\"" } + return true + } else { + LOG.debug { "Found no duplicates from local imports" } + } + + return false + } + + abstract fun hasDuplicatedImportsHelper(newImport: PsiElement, existingImports: List): PsiElement? + + abstract fun getTopLevelImports(psiFile: PsiFile, editor: Editor): List + + abstract fun getLocalImports(psiFile: PsiFile, editor: Editor): List + + abstract fun addImport(psiFile: PsiFile, editor: Editor, newImport: PsiElement): Boolean + + companion object { + private val EP = ExtensionPointName.create("aws.toolkit.codewhisperer.importAdder") + internal val LOG = getLogger() + + fun get(language: CodeWhispererProgrammingLanguage): CodeWhispererImportAdder? = + EP.extensionList.firstOrNull { language in it.supportedLanguages } + ?: EP.extensionList.find { it is CodeWhispererFallbackImportAdder } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererImportAdderListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererImportAdderListener.kt new file mode 100644 index 0000000000..0a2f8110b5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererImportAdderListener.kt @@ -0,0 +1,33 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.importadder + +import com.intellij.openapi.editor.RangeMarker +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererUserActionListener +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererSettings + +object CodeWhispererImportAdderListener : CodeWhispererUserActionListener { + internal val LOG = getLogger() + override fun afterAccept(states: InvocationContext, sessionContext: SessionContext, rangeMarker: RangeMarker) { + if (!CodeWhispererSettings.getInstance().isImportAdderEnabled()) { + LOG.debug { "Import adder not enabled in user settings" } + return + } + val language = states.requestContext.fileContextInfo.programmingLanguage + if (!language.isImportAdderSupported()) { + LOG.debug { "Import adder is not supported for $language" } + return + } + val importAdder = CodeWhispererImportAdder.get(language) + if (importAdder == null) { + LOG.debug { "No import adder found for $language" } + return + } + importAdder.insertImportStatements(states, sessionContext) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererJavaImportAdder.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererJavaImportAdder.kt new file mode 100644 index 0000000000..b0fb64b84b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererJavaImportAdder.kt @@ -0,0 +1,59 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.importadder + +import com.intellij.lang.java.JavaLanguage +import com.intellij.openapi.editor.Editor +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiFileFactory +import com.intellij.psi.PsiImportStatement +import com.intellij.psi.PsiImportStatementBase +import com.intellij.psi.PsiJavaFile +import com.intellij.util.IncorrectOperationException +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJava + +class CodeWhispererJavaImportAdder : CodeWhispererImportAdder() { + override val supportedLanguages: List = listOf(CodeWhispererJava.INSTANCE) + override val dummyFileName: String = "dummy.java" + + override fun createNewImportPsiElement(psiFile: PsiFile, statement: String): PsiElement? { + val project = psiFile.project + val fileFactory = PsiFileFactory.getInstance(project) + val dummyFile = fileFactory.createFileFromText(dummyFileName, JavaLanguage.INSTANCE, statement) + as? PsiJavaFile ?: return null + return dummyFile.importList?.allImportStatements?.getOrNull(0) + } + + override fun hasDuplicatedImportsHelper(newImport: PsiElement, existingImports: List): PsiElement? { + if (newImport !is PsiImportStatement) return newImport + existingImports.forEach { + if (it !is PsiImportStatementBase) return@forEach + if (it.text == newImport.text) return it + } + return null + } + + override fun getTopLevelImports(psiFile: PsiFile, editor: Editor): List { + if (psiFile !is PsiJavaFile) return emptyList() + return psiFile.importList?.allImportStatements?.toList().orEmpty() + } + + override fun getLocalImports(psiFile: PsiFile, editor: Editor): List = emptyList() + + override fun addImport(psiFile: PsiFile, editor: Editor, newImport: PsiElement): Boolean { + if (psiFile !is PsiJavaFile) return false + if (newImport !is PsiImportStatement) return false + + val addedImport = + try { + psiFile.importList?.add(newImport) ?: return false + } catch (e: IncorrectOperationException) { + return false + } + if (addedImport !is PsiImportStatement) return false + return true + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererPythonImportAdder.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererPythonImportAdder.kt new file mode 100644 index 0000000000..1622fee636 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/importadder/CodeWhispererPythonImportAdder.kt @@ -0,0 +1,106 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.importadder + +import com.intellij.openapi.editor.Editor +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiFileFactory +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.QualifiedName +import com.intellij.refactoring.suggested.startOffset +import com.jetbrains.python.PythonLanguage +import com.jetbrains.python.codeInsight.PyCodeInsightSettings +import com.jetbrains.python.codeInsight.imports.AddImportHelper +import com.jetbrains.python.psi.PyFile +import com.jetbrains.python.psi.PyFromImportStatement +import com.jetbrains.python.psi.PyImportStatement +import com.jetbrains.python.psi.PyImportStatementBase +import com.jetbrains.python.psi.PyStatementList +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererPython + +class CodeWhispererPythonImportAdder : CodeWhispererImportAdder() { + override val supportedLanguages: List = listOf(CodeWhispererPython.INSTANCE) + override val dummyFileName: String = "dummy.py" + + override fun createNewImportPsiElement(psiFile: PsiFile, statement: String): PsiElement? { + val fileFactory = PsiFileFactory.getInstance(psiFile.project) + val dummyFile = fileFactory.createFileFromText(dummyFileName, PythonLanguage.INSTANCE, statement) + as? PyFile ?: return null + return dummyFile.importBlock.firstOrNull() + } + + override fun hasDuplicatedImportsHelper(newImport: PsiElement, existingImports: List): PsiElement? { + if (newImport !is PyImportStatementBase) return newImport + + newImport.importElements.map { it.importedQName }.forEachIndexed outer@{ newI, newImportedQName -> + if (newImportedQName == null) return@outer + existingImports.forEach { existingImport -> + if (existingImport !is PyImportStatementBase) return@outer + if (existingImport::class.java != newImport::class.java) return@outer + existingImport.importElements.map { it.importedQName }.forEachIndexed inner@{ existingI, existingImportedQName -> + if (existingImportedQName == null) return@inner + val existingImportAsName = existingImport.importElements[existingI].asName + val newImportAsName = newImport.importElements[newI].asName + if (existingImportAsName != newImportAsName) return@inner + + val newFullyQName = QualifiedName.fromDottedString(newImport.fullyQualifiedObjectNames[newI]) + val existingFullyQName = QualifiedName.fromDottedString(existingImport.fullyQualifiedObjectNames[existingI]) + if (newImport is PyImportStatement) { + if (newImportAsName != null) { + if (existingFullyQName == newFullyQName) return existingImport + } else { + if (existingFullyQName.matchesPrefix(newFullyQName)) return existingImport + } + } else if (newImport is PyFromImportStatement) { + if (newImportedQName == existingImportedQName && newFullyQName == existingFullyQName) return existingImport + } + } + } + } + return null + } + + override fun getTopLevelImports(psiFile: PsiFile, editor: Editor): List = + if (psiFile !is PyFile) emptyList() else psiFile.importBlock + + override fun getLocalImports(psiFile: PsiFile, editor: Editor): List { + val localImports = mutableListOf() + val offset = editor.caretModel.offset + val element = psiFile.findElementAt(offset) ?: return emptyList() + val elementStartOffset = element.startOffset + var block: PsiElement? = PsiTreeUtil.getParentOfType(element, PyStatementList::class.java) + while (block != null) { + localImports.addAll( + block.children.filter { + (it is PyFromImportStatement || it is PyImportStatement) && it.startOffset < elementStartOffset + } + ) + block = PsiTreeUtil.getParentOfType(block, PyStatementList::class.java) + } + return localImports + } + + override fun addImport(psiFile: PsiFile, editor: Editor, newImport: PsiElement): Boolean { + if (newImport !is PyImportStatementBase) return false + if (newImport is PyFromImportStatement && newImport.isStarImport) { + val from = newImport.importSourceQName.toString() + AddImportHelper.addOrUpdateFromImportStatement(psiFile, from, "*", null, null, null) + return true + } + newImport.importElements.forEach { + val path = it.importedQName.toString() + val asName = it.asName + + if (!PyCodeInsightSettings.getInstance().PREFER_FROM_IMPORT || newImport !is PyFromImportStatement) { + AddImportHelper.addImportStatement(psiFile, path, asName, null, null) + } else { + val from = newImport.importSourceQName.toString() + AddImportHelper.addOrUpdateFromImportStatement(psiFile, from, path, asName, null, null) + } + } + return true + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayBlockRenderer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayBlockRenderer.kt new file mode 100644 index 0000000000..22e3708a79 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayBlockRenderer.kt @@ -0,0 +1,35 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.inlay + +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.markup.TextAttributes +import java.awt.Graphics +import java.awt.Rectangle +import kotlin.math.max + +class CodeWhispererInlayBlockRenderer(myValue: String) : CodeWhispererInlayRenderer(myValue) { + private val myLines: List + init { + myLines = myValue.split("\n") + } + + override fun calcWidthInPixels(inlay: Inlay<*>): Int { + val fontMetrics = getFontInfo(inlay.editor).fontMetrics() + var maxWidthForSingleLine = fontMetrics.stringWidth(myLines[0]) + for (i in myLines.indices) { + maxWidthForSingleLine = max(maxWidthForSingleLine, fontMetrics.stringWidth(myLines[i])) + } + return maxWidthForSingleLine + } + + override fun calcHeightInPixels(inlay: Inlay<*>): Int = myLines.size * inlay.editor.lineHeight + + override fun paint(inlay: Inlay<*>, g: Graphics, targetRegion: Rectangle, textAttributes: TextAttributes) { + applyCodeWhispererColorAndFontSettings(inlay.editor, g) + for (i in myLines.indices) { + g.drawString(myLines[i], 0, targetRegion.y + i * inlay.editor.lineHeight + inlay.editor.ascent) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayInlineRenderer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayInlineRenderer.kt new file mode 100644 index 0000000000..ec8770260e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayInlineRenderer.kt @@ -0,0 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.inlay + +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.markup.TextAttributes +import java.awt.Graphics +import java.awt.Rectangle + +class CodeWhispererInlayInlineRenderer(myValue: String) : CodeWhispererInlayRenderer(myValue) { + override fun calcWidthInPixels(inlay: Inlay<*>): Int { + val fontInfo = getFontInfo(inlay.editor) + return if (myValue.isEmpty()) { + 1 + } else { + fontInfo.fontMetrics().stringWidth(myValue) + } + } + + override fun paint(inlay: Inlay<*>, g: Graphics, targetRegion: Rectangle, textAttributes: TextAttributes) { + applyCodeWhispererColorAndFontSettings(inlay.editor, g) + g.drawString(myValue, targetRegion.x, targetRegion.y + inlay.editor.ascent) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayManager.kt new file mode 100644 index 0000000000..54bf55e57f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayManager.kt @@ -0,0 +1,75 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.inlay + +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.util.Disposer +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.RecommendationChunk + +class CodeWhispererInlayManager { + fun updateInlays(states: InvocationContext, chunks: List) { + val editor = states.requestContext.editor + clearInlays(editor) + + chunks.forEach { chunk -> + createCodeWhispererInlays(editor, chunk.inlayOffset, chunk.text, states.popup) + } + } + + private fun createCodeWhispererInlays(editor: Editor, startOffset: Int, inlayText: String, popup: JBPopup) { + if (inlayText.isEmpty()) return + val firstNewlineIndex = inlayText.indexOf("\n") + val firstLine: String + val otherLines: String + if (firstNewlineIndex != -1 && firstNewlineIndex < inlayText.length - 1) { + firstLine = inlayText.substring(0, firstNewlineIndex) + otherLines = inlayText.substring(firstNewlineIndex + 1) + } else { + firstLine = inlayText + otherLines = "" + } + + val firstLineRenderer = CodeWhispererInlayInlineRenderer(firstLine) + val inlineInlay = editor.inlayModel.addInlineElement(startOffset, true, firstLineRenderer) + inlineInlay?.let { Disposer.register(popup, it) } + + if (otherLines.isEmpty()) { + return + } + val otherLinesRenderer = CodeWhispererInlayBlockRenderer(otherLines) + val blockInlay = editor.inlayModel.addBlockElement( + startOffset, + true, + false, + 0, + otherLinesRenderer + ) + blockInlay?.let { Disposer.register(popup, it) } + } + + fun clearInlays(editor: Editor) { + editor.inlayModel.getInlineElementsInRange( + 0, + editor.document.textLength, + CodeWhispererInlayInlineRenderer::class.java + ).forEach { disposable -> + Disposer.dispose(disposable) + } + editor.inlayModel.getBlockElementsInRange( + 0, + editor.document.textLength, + CodeWhispererInlayBlockRenderer::class.java + ).forEach { disposable -> + Disposer.dispose(disposable) + } + } + + companion object { + @JvmStatic + fun getInstance(): CodeWhispererInlayManager = service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayRenderer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayRenderer.kt new file mode 100644 index 0000000000..ef45b179c6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/inlay/CodeWhispererInlayRenderer.kt @@ -0,0 +1,35 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.inlay + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorCustomElementRenderer +import com.intellij.openapi.editor.impl.ComplementaryFontsRegistry +import com.intellij.openapi.editor.impl.FontInfo +import com.intellij.xdebugger.ui.DebuggerColors +import java.awt.Font +import java.awt.Graphics + +abstract class CodeWhispererInlayRenderer(protected val myValue: String) : EditorCustomElementRenderer { + fun getFontInfo(editor: Editor): FontInfo { + val colorsScheme = editor.colorsScheme + val fontPreferences = colorsScheme.fontPreferences + val attributes = editor.colorsScheme.getAttributes(DebuggerColors.INLINED_VALUES_EXECUTION_LINE) + val fontStyle = attributes?.fontType ?: Font.PLAIN + return ComplementaryFontsRegistry.getFontAbleToDisplay( + 'a'.toInt(), + fontStyle, + fontPreferences, + FontInfo.getFontRenderContext(editor.contentComponent) + ) + } + + fun applyCodeWhispererColorAndFontSettings(editor: Editor, g: Graphics) { + val attributes = editor.colorsScheme.getAttributes(DebuggerColors.INLINED_VALUES_EXECUTION_LINE) ?: return + val fgColor = attributes.foregroundColor ?: return + g.color = fgColor + val fontInfo = getFontInfo(editor) + g.font = fontInfo.font + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/CodeWhispererLanguageManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/CodeWhispererLanguageManager.kt new file mode 100644 index 0000000000..ccb36f4d57 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/CodeWhispererLanguageManager.kt @@ -0,0 +1,138 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language + +import com.intellij.openapi.components.service +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererC +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererCpp +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererCsharp +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererGo +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJava +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJavaScript +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJson +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJsx +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererKotlin +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererPhp +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererPlainText +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererPython +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererRuby +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererRust +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererScala +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererShell +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererSql +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererTf +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererTsx +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererTypeScript +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererUnknownLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererYaml + +class CodeWhispererLanguageManager { + // Always use this method to check for language support for CodeWhisperer features. + // The return type here implicitly means that the corresponding language plugin has been installed to the user's IDE, + // (e.g. 'Python' plugin for Python and 'JavaScript and TypeScript' for JS/TS). So we can leverage these language + // plugin features when developing CodeWhisperer features. + /** + * resolve language by + * 1. file type + * 2. extension + * 3. fallback to unknown + */ + fun getLanguage(vFile: VirtualFile): CodeWhispererProgrammingLanguage { + val fileTypeName = vFile.fileType.name.lowercase() + + val fileExtension = vFile.extension?.lowercase() + + // We want to support Python Console which does not have a file extension + if (fileExtension == null && !fileTypeName.contains("python")) { + return CodeWhispererUnknownLanguage.INSTANCE + } + + return when { + fileTypeName.contains("python") -> CodeWhispererPython.INSTANCE + fileTypeName.contains("javascript") -> CodeWhispererJavaScript.INSTANCE + fileTypeName.contains("java") -> CodeWhispererJava.INSTANCE + fileTypeName.contains("jsx harmony") -> CodeWhispererJsx.INSTANCE + fileTypeName.contains("c#") -> CodeWhispererCsharp.INSTANCE + fileTypeName.contains("json") -> CodeWhispererJson.INSTANCE + fileTypeName.contains("yaml") -> CodeWhispererYaml.INSTANCE + fileTypeName.contains("tf") -> CodeWhispererTf.INSTANCE + fileTypeName.contains("hcl") -> CodeWhispererTf.INSTANCE + fileTypeName.contains("terraform") -> CodeWhispererTf.INSTANCE + fileTypeName.contains("packer") -> CodeWhispererTf.INSTANCE + fileTypeName.contains("terragrunt") -> CodeWhispererTf.INSTANCE + fileTypeName.contains("typescript jsx") -> CodeWhispererTsx.INSTANCE + fileTypeName.contains("typescript") -> CodeWhispererTypeScript.INSTANCE + fileTypeName.contains("scala") -> CodeWhispererScala.INSTANCE + fileTypeName.contains("kotlin") -> CodeWhispererKotlin.INSTANCE + fileTypeName.contains("ruby") -> CodeWhispererRuby.INSTANCE + fileTypeName.contains("php") -> CodeWhispererPhp.INSTANCE + fileTypeName.contains("sql") -> CodeWhispererSql.INSTANCE + fileTypeName.contains("go") -> CodeWhispererGo.INSTANCE + fileTypeName.contains("shell") -> CodeWhispererShell.INSTANCE + fileTypeName.contains("rust") -> CodeWhispererRust.INSTANCE + // fileTypeName.contains("plain_text") -> CodeWhispererPlainText.INSTANCE // This needs to be removed because Hcl files are recognised as plain_text by JB + else -> null + } + ?: languageExtensionsMap[fileExtension] + ?: CodeWhispererUnknownLanguage.INSTANCE + } + + /** + * will call getLanguage(virtualFile) first, then fallback to string resolve in case of psi only exists in memeory + */ + fun getLanguage(psiFile: PsiFile): CodeWhispererProgrammingLanguage = psiFile.virtualFile?.let { + getLanguage(it) + } ?: languageExtensionsMap.keys.find { ext -> psiFile.name.endsWith(ext) }?.let { languageExtensionsMap[it] } + ?: CodeWhispererUnknownLanguage.INSTANCE + + companion object { + fun getInstance(): CodeWhispererLanguageManager = service() + + /** + * languageExtensionMap will look like + * { + * "cpp" to CodeWhispererCpp, + * "c++" to CodeWhispererCpp, + * "cc" to CodeWhispererCpp, + * "java" to CodeWhispererJava, + * ... + * } + */ + val languageExtensionsMap = listOf( + listOf("java") to CodeWhispererJava.INSTANCE, + listOf("py") to CodeWhispererPython.INSTANCE, + listOf("js") to CodeWhispererJavaScript.INSTANCE, + listOf("jsx") to CodeWhispererJsx.INSTANCE, + listOf("ts") to CodeWhispererTypeScript.INSTANCE, + listOf("tsx") to CodeWhispererTsx.INSTANCE, + listOf("cs") to CodeWhispererCsharp.INSTANCE, + listOf("yaml") to CodeWhispererYaml.INSTANCE, + listOf("json") to CodeWhispererJson.INSTANCE, + listOf("tf") to CodeWhispererTf.INSTANCE, + listOf("hcl") to CodeWhispererTf.INSTANCE, // TF and HCL both emit "tf" as Telemetry Language + listOf("kt") to CodeWhispererKotlin.INSTANCE, + listOf("scala") to CodeWhispererScala.INSTANCE, + listOf("c", "h") to CodeWhispererC.INSTANCE, + listOf("cpp", "c++", "cc") to CodeWhispererCpp.INSTANCE, + listOf("sh") to CodeWhispererShell.INSTANCE, + listOf("rb") to CodeWhispererRuby.INSTANCE, + listOf("rs") to CodeWhispererRust.INSTANCE, + listOf("go") to CodeWhispererGo.INSTANCE, + listOf("php") to CodeWhispererPhp.INSTANCE, + listOf("sql") to CodeWhispererSql.INSTANCE, + listOf("txt") to CodeWhispererPlainText.INSTANCE + ).map { + val exts = it.first + val lang = it.second + exts.map { ext -> ext to lang } + }.flatten() + .associateBy({ it.first }, { it.second }) + } +} + +fun PsiFile.programmingLanguage(): CodeWhispererProgrammingLanguage = CodeWhispererLanguageManager.getInstance().getLanguage(this) + +fun VirtualFile.programmingLanguage(): CodeWhispererProgrammingLanguage = CodeWhispererLanguageManager.getInstance().getLanguage(this) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/CodeWhispererProgrammingLanguage.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/CodeWhispererProgrammingLanguage.kt new file mode 100644 index 0000000000..5387949e83 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/CodeWhispererProgrammingLanguage.kt @@ -0,0 +1,47 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language + +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileCrawler +import software.aws.toolkits.jetbrains.services.codewhisperer.util.NoOpFileCrawler +import software.aws.toolkits.telemetry.CodewhispererLanguage + +/** + * Interface defining CodeWhisperer's feature support on language levels, note that the expectation is not aligning with the IDE's behavior. That being said, + * on Intellij Community, users are still able to trigger CodeWhisperer service on .js .ts files whereas the IDE doesn't recognize the .js .ts file type. + * Specifically, any implementation leveraging JetBrains' language support, for example [PyFile], [ClassOwner] should live in their corresponding module or + * extension point otherwise it will result in dependency problem at runtime. For example, JS/TS is only supported in Intellij Ultimate thus it should live in + * "Ultimate" module if the implementation is utilizing JetBrains JS/TS APIs. + * + * Any subclass of CodeWhispererProgrammingLanguage should have private constructor + */ +abstract class CodeWhispererProgrammingLanguage { + abstract val languageId: String + open val fileCrawler: FileCrawler = NoOpFileCrawler() + + abstract fun toTelemetryType(): CodewhispererLanguage + + open fun isCodeCompletionSupported(): Boolean = false + + open fun isCodeScanSupported(): Boolean = false + + open fun isImportAdderSupported(): Boolean = false + + open fun isSupplementalContextSupported(): Boolean = false + + open fun isUTGSupported(): Boolean = false + + open fun toCodeWhispererRuntimeLanguage(): CodeWhispererProgrammingLanguage = this + + final override fun equals(other: Any?): Boolean { + if (other !is CodeWhispererProgrammingLanguage) return false + return this.languageId == other.languageId + } + + /** + * we want to force CodeWhispererProgrammingLanguage(any language implement it) be singleton, + * override hashCode is the backup plan if another object is being created + */ + final override fun hashCode(): Int = this.languageId.hashCode() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/classresolver/CodeWhispereJavaClassResolver.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/classresolver/CodeWhispereJavaClassResolver.kt new file mode 100644 index 0000000000..84469d0e4d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/classresolver/CodeWhispereJavaClassResolver.kt @@ -0,0 +1,35 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.classresolver + +import com.intellij.openapi.application.runReadAction +import com.intellij.psi.PsiClassOwner +import com.intellij.psi.PsiFile + +class CodeWhispereJavaClassResolver : CodeWhispererClassResolver { + override fun resolveClassAndMembers(psiFile: PsiFile): Map> { + if (psiFile !is PsiClassOwner) { + return emptyMap() + } + + val classNames = runReadAction { + psiFile.classes.mapNotNull { it.name } + } + + val methodNames = runReadAction { + psiFile.classes.mapNotNull { clazz -> + clazz.methods.mapNotNull { method -> + method.name + } + } + }.flatten() + + return mapOf( + ClassResolverKey.ClassName to classNames, + ClassResolverKey.MethodName to methodNames + ) + } + + override fun resolveTopLevelFunction(psiFile: PsiFile): List = emptyList() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/classresolver/CodeWhispererClassResolver.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/classresolver/CodeWhispererClassResolver.kt new file mode 100644 index 0000000000..53ddab11b9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/classresolver/CodeWhispererClassResolver.kt @@ -0,0 +1,26 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.classresolver + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.psi.PsiFile + +/** + * Note the implementation of [CodeWhispererClassResolver] should live in its corresponding module if it's dependent on + * JB's specific language support. For example [CodeWhispererPythonClassResolver] uses [PyFile] which makes it depends on python extension point + */ +interface CodeWhispererClassResolver { + fun resolveClassAndMembers(psiFile: PsiFile): Map> + + fun resolveTopLevelFunction(psiFile: PsiFile): List + + companion object { + val EP_NAME = ExtensionPointName("aws.toolkit.codewhisperer.classResolver") + } +} + +enum class ClassResolverKey { + ClassName, + MethodName +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/classresolver/CodeWhispererPythonClassResolver.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/classresolver/CodeWhispererPythonClassResolver.kt new file mode 100644 index 0000000000..e2fab10f96 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/classresolver/CodeWhispererPythonClassResolver.kt @@ -0,0 +1,44 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.classresolver + +import com.intellij.openapi.application.runReadAction +import com.intellij.psi.PsiFile +import com.jetbrains.python.psi.PyFile + +class CodeWhispererPythonClassResolver : CodeWhispererClassResolver { + override fun resolveClassAndMembers(psiFile: PsiFile): Map> { + if (psiFile !is PyFile) { + return emptyMap() + } + val classNames = runReadAction { + psiFile.topLevelClasses.mapNotNull { it.name } + } + + val methodNames = runReadAction { + psiFile.topLevelClasses.mapNotNull { clazz -> + clazz.methods.mapNotNull { method -> + method.name + } + } + }.flatten() + + return mapOf( + ClassResolverKey.ClassName to classNames, + ClassResolverKey.MethodName to methodNames + ) + } + + override fun resolveTopLevelFunction(psiFile: PsiFile): List { + if (psiFile !is PyFile) { + return emptyList() + } + + val functionNames = runReadAction { + psiFile.topLevelFunctions.mapNotNull { it.name } + } + + return functionNames + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererC.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererC.kt new file mode 100644 index 0000000000..e763ec328b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererC.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererC private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.C + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "c" + + val INSTANCE = CodeWhispererC() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererCpp.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererCpp.kt new file mode 100644 index 0000000000..90663830cf --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererCpp.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererCpp private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Cpp + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "cpp" + + val INSTANCE = CodeWhispererCpp() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererCsharp.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererCsharp.kt new file mode 100644 index 0000000000..5975011c67 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererCsharp.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererCsharp private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Csharp + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "csharp" + + val INSTANCE = CodeWhispererCsharp() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererGo.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererGo.kt new file mode 100644 index 0000000000..b704db49af --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererGo.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererGo private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Go + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "go" + + val INSTANCE = CodeWhispererGo() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJava.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJava.kt new file mode 100644 index 0000000000..d838c41d29 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJava.kt @@ -0,0 +1,32 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileCrawler +import software.aws.toolkits.jetbrains.services.codewhisperer.util.JavaCodeWhispererFileCrawler +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererJava private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + override val fileCrawler: FileCrawler = JavaCodeWhispererFileCrawler + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Java + + override fun isCodeCompletionSupported(): Boolean = true + + override fun isCodeScanSupported(): Boolean = true + + override fun isImportAdderSupported(): Boolean = true + + override fun isSupplementalContextSupported() = true + + override fun isUTGSupported() = true + + companion object { + const val ID = "java" + + val INSTANCE = CodeWhispererJava() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJavaScript.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJavaScript.kt new file mode 100644 index 0000000000..9f469d50cd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJavaScript.kt @@ -0,0 +1,28 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileCrawler +import software.aws.toolkits.jetbrains.services.codewhisperer.util.JavascriptCodeWhispererFileCrawler +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererJavaScript private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + override val fileCrawler: FileCrawler = JavascriptCodeWhispererFileCrawler + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Javascript + + override fun isCodeCompletionSupported(): Boolean = true + + override fun isImportAdderSupported(): Boolean = true + + override fun isSupplementalContextSupported() = true + + companion object { + const val ID = "javascript" + + val INSTANCE = CodeWhispererJavaScript() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJson.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJson.kt new file mode 100644 index 0000000000..5f7d227ac6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJson.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererJson private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Json + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "json" + + val INSTANCE = CodeWhispererJson() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJsx.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJsx.kt new file mode 100644 index 0000000000..6039b31815 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererJsx.kt @@ -0,0 +1,28 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileCrawler +import software.aws.toolkits.jetbrains.services.codewhisperer.util.JavascriptCodeWhispererFileCrawler +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererJsx private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + override val fileCrawler: FileCrawler = JavascriptCodeWhispererFileCrawler + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Jsx + + override fun isCodeCompletionSupported(): Boolean = true + + override fun toCodeWhispererRuntimeLanguage(): CodeWhispererProgrammingLanguage = CodeWhispererJavaScript.INSTANCE + + override fun isSupplementalContextSupported() = true + + companion object { + const val ID = "jsx" + + val INSTANCE = CodeWhispererJsx() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererKotlin.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererKotlin.kt new file mode 100644 index 0000000000..0edb7042c1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererKotlin.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererKotlin private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Kotlin + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "kotlin" + + val INSTANCE = CodeWhispererKotlin() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererPhp.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererPhp.kt new file mode 100644 index 0000000000..c5d54e200f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererPhp.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererPhp private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Php + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "php" + + val INSTANCE = CodeWhispererPhp() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererPlainText.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererPlainText.kt new file mode 100644 index 0000000000..eab0f514b7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererPlainText.kt @@ -0,0 +1,19 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererPlainText private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Plaintext + + companion object { + const val ID = "plaintext" + + val INSTANCE = CodeWhispererPlainText() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererPython.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererPython.kt new file mode 100644 index 0000000000..793f667611 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererPython.kt @@ -0,0 +1,32 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileCrawler +import software.aws.toolkits.jetbrains.services.codewhisperer.util.PythonCodeWhispererFileCrawler +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererPython private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId = ID + override val fileCrawler: FileCrawler = PythonCodeWhispererFileCrawler + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Python + + override fun isCodeCompletionSupported(): Boolean = true + + override fun isCodeScanSupported(): Boolean = true + + override fun isImportAdderSupported(): Boolean = true + + override fun isUTGSupported() = true + + override fun isSupplementalContextSupported() = true + + companion object { + const val ID = "python" + + val INSTANCE = CodeWhispererPython() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererRuby.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererRuby.kt new file mode 100644 index 0000000000..2a91e698c2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererRuby.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererRuby private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Ruby + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "ruby" + + val INSTANCE = CodeWhispererRuby() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererRust.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererRust.kt new file mode 100644 index 0000000000..cf953ff1da --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererRust.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererRust private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Rust + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "rust" + + val INSTANCE = CodeWhispererRust() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererScala.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererScala.kt new file mode 100644 index 0000000000..ffd5594706 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererScala.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererScala private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Scala + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "scala" + + val INSTANCE = CodeWhispererScala() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererShell.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererShell.kt new file mode 100644 index 0000000000..bd8fa3450f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererShell.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererShell private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Shell + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "shell" + + val INSTANCE = CodeWhispererShell() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererSql.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererSql.kt new file mode 100644 index 0000000000..f316c1c375 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererSql.kt @@ -0,0 +1,21 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererSql private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Sql + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "sql" + + val INSTANCE = CodeWhispererSql() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererTf.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererTf.kt new file mode 100644 index 0000000000..168efafb63 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererTf.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererTf private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Tf + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "tf" + + val INSTANCE = CodeWhispererTf() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererTsx.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererTsx.kt new file mode 100644 index 0000000000..4334d3b233 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererTsx.kt @@ -0,0 +1,28 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileCrawler +import software.aws.toolkits.jetbrains.services.codewhisperer.util.TypescriptCodeWhispererFileCrawler +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererTsx private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + override val fileCrawler: FileCrawler = TypescriptCodeWhispererFileCrawler + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Tsx + + override fun isCodeCompletionSupported(): Boolean = true + + override fun toCodeWhispererRuntimeLanguage(): CodeWhispererProgrammingLanguage = CodeWhispererTypeScript.INSTANCE + + override fun isSupplementalContextSupported() = true + + companion object { + const val ID = "tsx" + + val INSTANCE = CodeWhispererTsx() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererTypeScript.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererTypeScript.kt new file mode 100644 index 0000000000..e11b317277 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererTypeScript.kt @@ -0,0 +1,26 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileCrawler +import software.aws.toolkits.jetbrains.services.codewhisperer.util.TypescriptCodeWhispererFileCrawler +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererTypeScript private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + override val fileCrawler: FileCrawler = TypescriptCodeWhispererFileCrawler + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Typescript + + override fun isCodeCompletionSupported(): Boolean = true + + override fun isSupplementalContextSupported() = true + + companion object { + const val ID = "typescript" + + val INSTANCE = CodeWhispererTypeScript() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererUnknownLanguage.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererUnknownLanguage.kt new file mode 100644 index 0000000000..0b85d667f6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererUnknownLanguage.kt @@ -0,0 +1,19 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererUnknownLanguage private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Unknown + + companion object { + const val ID = "unknown" + + val INSTANCE = CodeWhispererUnknownLanguage() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererYaml.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererYaml.kt new file mode 100644 index 0000000000..75eb2b8ee1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/language/languages/CodeWhispererYaml.kt @@ -0,0 +1,21 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.language.languages + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.telemetry.CodewhispererLanguage + +class CodeWhispererYaml private constructor() : CodeWhispererProgrammingLanguage() { + override val languageId: String = ID + + override fun toTelemetryType(): CodewhispererLanguage = CodewhispererLanguage.Yaml + + override fun isCodeCompletionSupported(): Boolean = true + + companion object { + const val ID = "yaml" + + val INSTANCE = CodeWhispererYaml() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/layout/CodeWhispererLayoutConfig.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/layout/CodeWhispererLayoutConfig.kt new file mode 100644 index 0000000000..589c4ec1f0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/layout/CodeWhispererLayoutConfig.kt @@ -0,0 +1,84 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.layout + +import com.intellij.util.ui.JBInsets +import java.awt.GridBagConstraints +import java.awt.GridBagConstraints.EAST +import java.awt.GridBagConstraints.HORIZONTAL +import java.awt.GridBagConstraints.WEST +import javax.swing.JLabel +import javax.swing.JPanel + +object CodeWhispererLayoutConfig { + val horizontalPanelConstraints = GridBagConstraints().apply { + gridx = 0 + weightx = 1.0 + fill = HORIZONTAL + } + val navigationButtonConstraints = GridBagConstraints().apply { + ipady = 6 + } + val middleButtonConstraints = GridBagConstraints().apply { + insets = JBInsets.create(0, 6) + ipady = 6 + } + val inlineLabelConstraints = GridBagConstraints().apply { + anchor = WEST + } + val kebabMenuConstraints = GridBagConstraints().apply { + anchor = EAST + } + val tryExampleLabelConstraints = GridBagConstraints().apply { + anchor = WEST + insets = JBInsets.create(0, 10) + } + val tryExampleButtonConstraints = GridBagConstraints().apply { + anchor = EAST + insets = JBInsets.create(0, 10) + } + val commandDescriptionConstraints = GridBagConstraints().apply { + anchor = WEST + insets = JBInsets.create(0, 10) + } + val commandKeyShortcutConstraints = GridBagConstraints().apply { + anchor = EAST + insets = JBInsets.create(0, 10) + } + val tryExampleRowConstraints = GridBagConstraints().apply { + gridx = 0 + weightx = 1.0 + fill = HORIZONTAL + ipadx = 8 + ipady = 26 + } + val commandRowConstraints = GridBagConstraints().apply { + gridx = 0 + weightx = 1.0 + fill = HORIZONTAL + ipadx = 8 + ipady = 12 + } + val componentPanelConstraints = (horizontalPanelConstraints.clone() as GridBagConstraints).apply { + insets = JBInsets(0, 0, 10, 0) + } + private val horizontalGlueConstraints = GridBagConstraints().apply { + gridy = 0 + weightx = 1.0 + weighty = 1.0 + fill = GridBagConstraints.BOTH + } + private val verticalGlueConstraints = GridBagConstraints().apply { + gridx = 0 + weightx = 1.0 + weighty = 1.0 + fill = GridBagConstraints.BOTH + } + fun JPanel.addHorizontalGlue() { + this.add(JLabel(), horizontalGlueConstraints) + } + fun JPanel.addVerticalGlue() { + this.add(JLabel(), verticalGlueConstraints) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererEditor.kt new file mode 100644 index 0000000000..f46f4af788 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererEditor.kt @@ -0,0 +1,105 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.learn + +import com.intellij.codeHighlighting.BackgroundEditorHighlighter +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorLocation +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.fileEditor.FileEditorStateLevel +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignY +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.gridLayout.Gaps +import icons.AwsIcons +import software.aws.toolkits.jetbrains.services.codewhisperer.learn.LearnCodeWhispererUIComponents.examplesDescriptionPanel +import software.aws.toolkits.jetbrains.services.codewhisperer.learn.LearnCodeWhispererUIComponents.tryExamplePanel +import software.aws.toolkits.resources.message +import java.beans.PropertyChangeListener +import javax.swing.JComponent + +class LearnCodeWhispererEditor(val project: Project, val virtualFile: VirtualFile) : UserDataHolderBase(), FileEditor { + private val contentPanel = panel { + row { + panel { + customize(Gaps(20, 50, 0, 0)) + row { + icon(AwsIcons.Logos.CODEWHISPERER_LARGE) + + panel { + title(message("codewhisperer.learn_page.header.title")) + row { + label(message("codewhisperer.learn_page.header.description")) + } + } + } + } + }.topGap(TopGap.MEDIUM).bottomGap(BottomGap.MEDIUM) + + row { + // Single panel + panel { + customize(Gaps(0, 50, 0, 0)) + align(AlignY.TOP) + + title(message("codewhisperer.learn_page.examples.title")).bottomGap(BottomGap.MEDIUM) + row { + cell(tryExamplePanel(project)).widthGroup(FIRST_COLUMN_WIDTH_GROUP) + }.bottomGap(BottomGap.MEDIUM) + row { + cell(examplesDescriptionPanel).widthGroup(FIRST_COLUMN_WIDTH_GROUP) + }.bottomGap(BottomGap.MEDIUM) + } + } + } + private val rootPanel = panel { + row { + scrollCell(contentPanel).align(Align.FILL) + }.resizableRow() + } + + override fun getComponent(): JComponent = rootPanel + + override fun getName(): String = "LearnCodeWhisperer" + + override fun getPreferredFocusedComponent(): JComponent? = null + + override fun isValid(): Boolean = true + + override fun getCurrentLocation(): FileEditorLocation? = null + + override fun getState(level: FileEditorStateLevel): FileEditorState = FileEditorState.INSTANCE + + override fun isModified(): Boolean = false + + override fun dispose() {} + + override fun addPropertyChangeListener(listener: PropertyChangeListener) {} + + override fun deselectNotify() {} + + override fun getBackgroundHighlighter(): BackgroundEditorHighlighter? = null + + override fun selectNotify() {} + + override fun removePropertyChangeListener(listener: PropertyChangeListener) {} + + override fun setState(state: FileEditorState) {} + + override fun getFile(): VirtualFile = virtualFile + + private fun Panel.title(text: String) = row { + label(text).bold().applyToComponent { font = font.deriveFont(24f) } + } + + companion object { + private const val FIRST_COLUMN_WIDTH_GROUP = "firstColumn" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererEditorProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererEditorProvider.kt new file mode 100644 index 0000000000..b859d4d7b1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererEditorProvider.kt @@ -0,0 +1,50 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.learn + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend +import software.aws.toolkits.telemetry.UiTelemetry + +class LearnCodeWhispererEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile): Boolean = file is LearnCodeWhispererVirtualFile + + override fun createEditor(project: Project, file: VirtualFile): FileEditor = LearnCodeWhispererManager.getInstance(project).getEditor(file) + + override fun getEditorTypeId(): String = "LearnCodeWhispererEditor" + + override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR + + companion object { + private val LOG = getLogger() + + // Will be called every time the getting started page is opened + fun openEditor(project: Project) { + if (isRunningOnRemoteBackend()) return + + val virtualFile = LearnCodeWhispererVirtualFile() + + runInEdt { + try { + FileEditorManager.getInstance(project).openFileEditor(OpenFileDescriptor(project, virtualFile), true) + UiTelemetry.click(project, "codewhisperer_Learn_PageOpen") + CodeWhispererExplorerActionManager.getInstance().setHasShownNewOnboardingPage(true) + } catch (e: Exception) { + LOG.debug(e) { "Getting Started page failed to open" } + } + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererManager.kt new file mode 100644 index 0000000000..6f48be229a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererManager.kt @@ -0,0 +1,28 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.learn + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJava +import software.aws.toolkits.telemetry.CodewhispererGettingStartedTask + +class LearnCodeWhispererManager(private val project: Project) { + // Only supporting Java at the moment + val language: CodeWhispererProgrammingLanguage = CodeWhispererJava.INSTANCE + val fileExtension = ".java" + + fun getEditor(file: VirtualFile) = LearnCodeWhispererEditor(project, file) + + companion object { + fun getInstance(project: Project) = project.service() + val taskTypeToFilename = mapOf( + CodewhispererGettingStartedTask.AutoTrigger to "CodeWhisperer_generate_suggestion", + CodewhispererGettingStartedTask.ManualTrigger to "CodeWhisperer_manual_invoke", + CodewhispererGettingStartedTask.UnitTest to "CodeWhisperer_generate_unit_tests", + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererUIComponents.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererUIComponents.kt new file mode 100644 index 0000000000..79f47684db --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererUIComponents.kt @@ -0,0 +1,172 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.learn + +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.editor.impl.FoldingModelImpl +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.fileEditor.impl.NonProjectFileWritingAccessProvider +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.ui.JBColor +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.BrowserLink +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.addHorizontalGlue +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.inlineLabelConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.kebabMenuConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.tryExampleButtonConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.tryExampleLabelConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.tryExampleRowConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.learn.LearnCodeWhispererManager.Companion.taskTypeToFilename +import software.aws.toolkits.jetbrains.services.codewhisperer.model.TryExampleRowContext +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_BUTTON_BORDER +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.TRY_EXAMPLE_EVEN_ROW_COLOR +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.CODEWHISPERER_SUPPORTED_LANG_URI +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.TryExampleFileContent.tryExampleFileContexts +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodewhispererGettingStartedTask +import software.aws.toolkits.telemetry.UiTelemetry +import java.awt.GridBagLayout +import java.io.File +import javax.swing.BorderFactory +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JPanel + +object LearnCodeWhispererUIComponents { + // "Banner" section components + fun bannerPanel() = JPanel(GridBagLayout()).apply { + background = JBColor.BLUE.darker().darker() + add(JLabel(AllIcons.General.Information), inlineLabelConstraints) + add(JLabel(" "), inlineLabelConstraints) + add(JLabel(message("codewhisperer.learn_page.banner.message.new_user")), inlineLabelConstraints) + addHorizontalGlue() + add( + ActionLink(message("codewhisperer.learn_page.banner.dismiss")) { + this@apply.isVisible = false + this@apply.repaint() + }, + kebabMenuConstraints + ) + } + + // "Try Example" section components + private val tryExampleRowContexts = mapOf( + CodewhispererGettingStartedTask.AutoTrigger to + TryExampleRowContext( + message("codewhisperer.learn_page.examples.tasks.description_1"), + taskTypeToFilename[CodewhispererGettingStartedTask.AutoTrigger] + ), + CodewhispererGettingStartedTask.ManualTrigger to + TryExampleRowContext( + if (SystemInfo.isMac) { + message("codewhisperer.learn_page.examples.tasks.description_2.mac") + } else { + message("codewhisperer.learn_page.examples.tasks.description_2.win") + }, + taskTypeToFilename[CodewhispererGettingStartedTask.ManualTrigger] + ), + CodewhispererGettingStartedTask.UnitTest to + TryExampleRowContext( + message("codewhisperer.learn_page.examples.tasks.description_3"), + taskTypeToFilename[CodewhispererGettingStartedTask.UnitTest] + ) + ) + + private fun tryExampleRow(project: Project, taskType: CodewhispererGettingStartedTask, isEvenRow: Boolean = false): JPanel { + val tryExampleRowContext = tryExampleRowContexts[taskType] ?: return JPanel() + + return JPanel(GridBagLayout()).apply { + add(JLabel(tryExampleRowContext.description), tryExampleLabelConstraints) + addHorizontalGlue() + val button = JButton(message("codewhisperer.learn_page.examples.tasks.button")).apply { + isOpaque = !isEvenRow + + addActionListener { + val currentLanguage = LearnCodeWhispererManager.getInstance(project).language + val fileContext = tryExampleFileContexts[taskType]?.get(currentLanguage) ?: return@addActionListener + val fileContent = fileContext.first + val caretOffset = fileContext.second + CodeWhispererTelemetryService.getInstance().sendOnboardingClickEvent(currentLanguage, taskType) + val fileExtension = LearnCodeWhispererManager.getInstance(project).fileExtension + val fullFilename = "${tryExampleRowContext.filename}$fileExtension" + val (editor, fileExists) = createOrOpenFileInEditor(project, fullFilename, fileContent) + if (editor == null) return@addActionListener + (editor.foldingModel as FoldingModelImpl).isFoldingEnabled = false + (editor.foldingModel as FoldingModelImpl).rebuild() + (editor as EditorImpl).resetSizes() + editor.caretModel.updateVisualPosition() + if (fileExists) return@addActionListener + editor.caretModel.moveToOffset(caretOffset) + } + } + + add(button, tryExampleButtonConstraints) + if (isEvenRow) { + background = TRY_EXAMPLE_EVEN_ROW_COLOR + } + } + } + + val examplesDescriptionPanel = JPanel(GridBagLayout()).apply { + add(JLabel(message("codewhisperer.learn_page.examples.description.part_1")), inlineLabelConstraints) + add( + BrowserLink( + message("codewhisperer.learn_page.examples.description.part_2"), + CODEWHISPERER_SUPPORTED_LANG_URI + ).apply { + addActionListener { + UiTelemetry.click(null as Project?, "codewhisperer_GenerateSuggestions_LearnMore") + } + }, + inlineLabelConstraints + ) + add(JLabel(message("codewhisperer.learn_page.examples.description.part_3")), inlineLabelConstraints) + addHorizontalGlue() + } + + private fun createOrOpenFileInEditor(project: Project, fileName: String, content: String): Pair { + // Get the idea.system.path + val systemPath = PathManager.getSystemPath() + + // Create the "codewhisperer" directory if it doesn't exist + val directory = File("$systemPath/codewhisperer") + if (!directory.exists()) { + directory.mkdirs() + } + + val file = File(directory, fileName) + val fileExists = file.exists() + if (!fileExists) { + file.writeText(content) + } + + // Refresh the file system to recognize the new file + val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) ?: return null to false + if (!NonProjectFileWritingAccessProvider.isWriteAccessAllowed(virtualFile, project)) { + NonProjectFileWritingAccessProvider.allowWriting(listOf(virtualFile)) + } + + return FileEditorManager.getInstance(project).openTextEditor( + OpenFileDescriptor(project, virtualFile), + true + ) to fileExists + } + + fun tryExamplePanel(project: Project) = JPanel(GridBagLayout()).apply { + val firstTryExampleRow = tryExampleRow(project, CodewhispererGettingStartedTask.AutoTrigger) + val secondTryExampleRow = tryExampleRow(project, CodewhispererGettingStartedTask.ManualTrigger, true) + val thirdTryExampleRow = tryExampleRow(project, CodewhispererGettingStartedTask.UnitTest) + add(firstTryExampleRow, tryExampleRowConstraints) + add(secondTryExampleRow, tryExampleRowConstraints) + add(thirdTryExampleRow, tryExampleRowConstraints) + border = BorderFactory.createLineBorder(POPUP_BUTTON_BORDER) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererVirtualFile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererVirtualFile.kt new file mode 100644 index 0000000000..7ec0f2a06e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/learn/LearnCodeWhispererVirtualFile.kt @@ -0,0 +1,22 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.learn + +import com.intellij.testFramework.LightVirtualFile + +/** + * Light virtual file to represent a Learn CodeWhisperer tutorial file, used to open the custom editor + */ +class LearnCodeWhispererVirtualFile : LightVirtualFile("Learn CodeWhisperer") { + override fun getPresentableName(): String = "Learn CodeWhisperer" + + override fun getPath(): String = "learnCodeWhisperer" + + override fun isWritable(): Boolean = false + + // This along with hashCode() is to make sure only one editor for this is opened at a time + override fun equals(other: Any?) = other is LearnCodeWhispererVirtualFile && this.hashCode() == other.hashCode() + + override fun hashCode(): Int = presentableName.hashCode() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt new file mode 100644 index 0000000000..db250f83bd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt @@ -0,0 +1,204 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.model + +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.VisualPosition +import com.intellij.openapi.editor.markup.RangeHighlighter +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.vfs.VirtualFile +import software.amazon.awssdk.services.codewhispererruntime.model.Completion +import software.amazon.awssdk.services.codewhispererruntime.model.GenerateCompletionsResponse +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.sessionconfig.PayloadContext +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutomatedTriggerType +import software.aws.toolkits.jetbrains.services.codewhisperer.service.RequestContext +import software.aws.toolkits.jetbrains.services.codewhisperer.service.ResponseContext +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CrossFileStrategy +import software.aws.toolkits.jetbrains.services.codewhisperer.util.SupplementalContextStrategy +import software.aws.toolkits.jetbrains.services.codewhisperer.util.UtgStrategy +import software.aws.toolkits.telemetry.CodewhispererCompletionType +import software.aws.toolkits.telemetry.CodewhispererTriggerType +import software.aws.toolkits.telemetry.Result +import java.util.concurrent.TimeUnit + +data class Chunk( + val content: String, + val path: String, + val nextChunk: String = "", + val score: Double = 0.0 +) + +data class ListUtgCandidateResult( + val vfile: VirtualFile?, + val strategy: UtgStrategy +) + +data class CaretContext(val leftFileContext: String, val rightFileContext: String, val leftContextOnCurrentLine: String = "") + +data class FileContextInfo( + val caretContext: CaretContext, + val filename: String, + val programmingLanguage: CodeWhispererProgrammingLanguage +) + +data class SupplementalContextInfo( + val isUtg: Boolean, + val contents: List, + val targetFileName: String, + val strategy: SupplementalContextStrategy, + val latency: Long = 0L, +) { + val contentLength: Int + get() = contents.fold(0) { acc, chunk -> + acc + chunk.content.length + } + + val isProcessTimeout: Boolean + get() = latency > CodeWhispererConstants.SUPPLEMENTAL_CONTEXT_TIMEOUT + + companion object { + fun emptyCrossFileContextInfo(targetFileName: String): SupplementalContextInfo = SupplementalContextInfo( + isUtg = false, + contents = emptyList(), + targetFileName = targetFileName, + strategy = CrossFileStrategy.Empty, + latency = 0L + ) + + fun emptyUtgFileContextInfo(targetFileName: String): SupplementalContextInfo = SupplementalContextInfo( + isUtg = true, + contents = emptyList(), + targetFileName = targetFileName, + strategy = UtgStrategy.Empty, + latency = 0L + ) + } +} + +data class RecommendationContext( + val details: List, + val userInputOriginal: String, + val userInputSinceInvocation: String, + val position: VisualPosition +) + +data class DetailContext( + val requestId: String, + val recommendation: Completion, + val reformatted: Completion, + val isDiscarded: Boolean, + val isTruncatedOnRight: Boolean, + val rightOverlap: String = "", + val completionType: CodewhispererCompletionType, +) + +data class SessionContext( + val typeahead: String = "", + val typeaheadOriginal: String = "", + val selectedIndex: Int = 0, + val seen: MutableSet = mutableSetOf(), + val isFirstTimeShowingPopup: Boolean = true, + var toBeRemovedHighlighter: RangeHighlighter? = null, + var insertEndOffset: Int = -1 +) + +data class RecommendationChunk( + val text: String, + val offset: Int, + val inlayOffset: Int +) + +data class CaretPosition(val offset: Int, val line: Int) + +data class TriggerTypeInfo( + val triggerType: CodewhispererTriggerType, + val automatedTriggerType: CodeWhispererAutomatedTriggerType, +) + +data class InvocationContext( + val requestContext: RequestContext, + val responseContext: ResponseContext, + val recommendationContext: RecommendationContext, + val popup: JBPopup +) : Disposable { + override fun dispose() {} +} + +data class WorkerContext( + val requestContext: RequestContext, + val responseContext: ResponseContext, + val response: GenerateCompletionsResponse, + val popup: JBPopup +) + +data class CodeScanTelemetryEvent( + val codeScanResponseContext: CodeScanResponseContext, + val duration: Double, + val result: Result, + val totalProjectSizeInBytes: Double?, + val connection: ToolkitConnection? +) + +data class CodeScanServiceInvocationContext( + val artifactsUploadDuration: Long, + val serviceInvocationDuration: Long +) + +data class CodeScanResponseContext( + val payloadContext: PayloadContext, + val serviceInvocationContext: CodeScanServiceInvocationContext, + val codeScanJobId: String? = null, + val codeScanTotalIssues: Int = 0, + val codeScanIssuesWithFixes: Int = 0, + val reason: String? = null +) + +data class LatencyContext( + var credentialFetchingStart: Long = 0L, + var credentialFetchingEnd: Long = 0L, + + var codewhispererPreprocessingStart: Long = 0L, + var codewhispererPreprocessingEnd: Long = 0L, + + var paginationFirstCompletionTime: Double = 0.0, + + var codewhispererPostprocessingStart: Long = 0L, + var codewhispererPostprocessingEnd: Long = 0L, + + var codewhispererEndToEndStart: Long = 0L, + var codewhispererEndToEndEnd: Long = 0L, + + var paginationAllCompletionsStart: Long = 0L, + var paginationAllCompletionsEnd: Long = 0L, + + var firstRequestId: String = "" +) { + fun getCodeWhispererEndToEndLatency() = TimeUnit.NANOSECONDS.toMillis( + codewhispererEndToEndEnd - codewhispererEndToEndStart + ).toDouble() + + fun getCodeWhispererAllCompletionsLatency() = TimeUnit.NANOSECONDS.toMillis( + paginationAllCompletionsEnd - paginationAllCompletionsStart + ).toDouble() + + fun getCodeWhispererPostprocessingLatency() = TimeUnit.NANOSECONDS.toMillis( + codewhispererPostprocessingEnd - codewhispererPostprocessingStart + ).toDouble() + + fun getCodeWhispererCredentialFetchingLatency() = TimeUnit.NANOSECONDS.toMillis( + credentialFetchingEnd - credentialFetchingStart + ).toDouble() + + fun getCodeWhispererPreprocessingLatency() = TimeUnit.NANOSECONDS.toMillis( + codewhispererPreprocessingEnd - codewhispererPreprocessingStart + ).toDouble() +} + +data class TryExampleRowContext( + val description: String, + val filename: String? +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupComponents.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupComponents.kt new file mode 100644 index 0000000000..aa0430c91a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupComponents.kt @@ -0,0 +1,156 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.impl.ActionButton +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.components.ActionLink +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererLearnMoreAction +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererProvideFeedbackAction +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererShowSettingsAction +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.addHorizontalGlue +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.horizontalPanelConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.inlineLabelConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.kebabMenuConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.middleButtonConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.navigationButtonConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererLicenseInfoManager +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_BUTTON_BORDER +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_DIM_HEX +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_HOVER +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_PANEL_SEPARATOR +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_REF_INFO +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_REF_NOTICE_HEX +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.POPUP_BUTTON_TEXT_SIZE +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.POPUP_INFO_TEXT_SIZE +import software.aws.toolkits.resources.message +import java.awt.GridBagLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.BorderFactory +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JPanel + +class CodeWhispererPopupComponents { + val prevButton = createNavigationButton( + message("codewhisperer.popup.button.prev", POPUP_DIM_HEX) + ) + val nextButton = createNavigationButton( + message("codewhisperer.popup.button.next", POPUP_DIM_HEX) + ).apply { + preferredSize = prevButton.preferredSize + } + val acceptButton = createNavigationButton( + message("codewhisperer.popup.button.accept", POPUP_DIM_HEX) + ) + val buttonsPanel = CodeWhispererPopupInfoPanel { + border = BorderFactory.createCompoundBorder( + border, + BorderFactory.createEmptyBorder(3, 0, 3, 0) + ) + add(acceptButton, navigationButtonConstraints) + add(prevButton, middleButtonConstraints) + add(nextButton, navigationButtonConstraints) + } + val recommendationInfoLabel = JLabel().apply { + font = font.deriveFont(POPUP_INFO_TEXT_SIZE) + } + private val kebabMenuAction = DefaultActionGroup().apply { + isPopup = true + add(CodeWhispererProvideFeedbackAction()) + add(CodeWhispererLearnMoreAction()) + add(CodeWhispererShowSettingsAction()) + } + private val kebabMenuPresentation = Presentation().apply { + icon = AllIcons.Actions.More + putClientProperty(ActionButton.HIDE_DROPDOWN_ICON, true) + } + private val kebabMenu = ActionButton( + kebabMenuAction, + kebabMenuPresentation, + ActionPlaces.EDITOR_POPUP, + ActionToolbar.NAVBAR_MINIMUM_BUTTON_SIZE + ) + private val recommendationInfoPanel = CodeWhispererPopupInfoPanel { + add(recommendationInfoLabel, inlineLabelConstraints) + addHorizontalGlue() + add(kebabMenu, kebabMenuConstraints) + } + val importLabel = JLabel().apply { + font = font.deriveFont(POPUP_INFO_TEXT_SIZE) + } + + val importPanel = CodeWhispererPopupInfoPanel { + add(importLabel, inlineLabelConstraints) + addHorizontalGlue() + } + + val licenseCodeLabelPrefixText = JLabel().apply { + text = message("codewhisperer.popup.reference.license_info.prefix", POPUP_REF_NOTICE_HEX) + foreground = POPUP_REF_INFO + } + + val codeReferencePanelLink = ActionLink(message("codewhisperer.popup.reference.panel_link")) + val licenseCodePanel = JPanel(GridBagLayout()).apply { + border = BorderFactory.createEmptyBorder(0, 0, 3, 0) + add(licenseCodeLabelPrefixText, inlineLabelConstraints) + add(ActionLink(), inlineLabelConstraints) + add(codeReferencePanelLink, inlineLabelConstraints) + addHorizontalGlue() + } + + fun licenseLink(license: String) = ActionLink(license) { + BrowserUtil.browse(CodeWhispererLicenseInfoManager.getInstance().getLicenseLink(license)) + } + + val codeReferencePanel = CodeWhispererPopupInfoPanel { + add(licenseCodePanel, horizontalPanelConstraints) + } + val panel = JPanel(GridBagLayout()).apply { + add(buttonsPanel, horizontalPanelConstraints) + add(recommendationInfoPanel, horizontalPanelConstraints) + add(importPanel, horizontalPanelConstraints) + add(codeReferencePanel, horizontalPanelConstraints) + } + + private fun createNavigationButton(buttonText: String) = JButton(buttonText).apply { + font = font.deriveFont(POPUP_BUTTON_TEXT_SIZE) + border = IdeBorderFactory.createRoundedBorder().apply { + setColor(POPUP_BUTTON_BORDER) + } + isContentAreaFilled = false + + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent?) { + foreground = POPUP_HOVER + } + + override fun mouseClicked(e: MouseEvent?) { + foreground = POPUP_HOVER + } + + override fun mouseExited(e: MouseEvent?) { + foreground = UIUtil.getLabelForeground() + } + }) + } + + class CodeWhispererPopupInfoPanel(function: CodeWhispererPopupInfoPanel.() -> Unit) : JPanel(GridBagLayout()) { + init { + border = BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, POPUP_PANEL_SEPARATOR), + BorderFactory.createEmptyBorder(2, 5, 2, 5) + ) + function() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupListener.kt new file mode 100644 index 0000000000..c485c52acb --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupListener.kt @@ -0,0 +1,34 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup + +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStatus +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import java.time.Duration +import java.time.Instant + +class CodeWhispererPopupListener(private val states: InvocationContext) : JBPopupListener { + override fun beforeShown(event: LightweightWindowEvent) { + super.beforeShown(event) + CodeWhispererInvocationStatus.getInstance().setPopupStartTimestamp() + } + override fun onClosed(event: LightweightWindowEvent) { + super.onClosed(event) + val (requestContext, responseContext, recommendationContext) = states + + CodeWhispererTelemetryService.getInstance().sendUserDecisionEventForAll( + requestContext, + responseContext, + recommendationContext, + CodeWhispererPopupManager.getInstance().sessionContext, + event.isOk, + CodeWhispererInvocationStatus.getInstance().popupStartTimestamp?.let { Duration.between(it, Instant.now()) } + ) + + CodeWhispererInvocationStatus.getInstance().setPopupActive(false) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupManager.kt new file mode 100644 index 0000000000..2c54b4237c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupManager.kt @@ -0,0 +1,667 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup + +import com.intellij.codeInsight.CodeInsightSettings +import com.intellij.codeInsight.hint.ParameterInfoController +import com.intellij.codeInsight.lookup.LookupManager +import com.intellij.openapi.actionSystem.IdeActions.ACTION_EDITOR_BACKSPACE +import com.intellij.openapi.actionSystem.IdeActions.ACTION_EDITOR_ENTER +import com.intellij.openapi.actionSystem.IdeActions.ACTION_EDITOR_MOVE_CARET_LEFT +import com.intellij.openapi.actionSystem.IdeActions.ACTION_EDITOR_MOVE_CARET_RIGHT +import com.intellij.openapi.actionSystem.IdeActions.ACTION_EDITOR_TAB +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.RangeMarker +import com.intellij.openapi.editor.actionSystem.EditorActionManager +import com.intellij.openapi.editor.actionSystem.TypedAction +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsListener +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.event.CaretEvent +import com.intellij.openapi.editor.event.CaretListener +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.event.SelectionEvent +import com.intellij.openapi.editor.event.SelectionListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.WindowManager +import com.intellij.ui.ComponentUtil +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.popup.AbstractPopup +import com.intellij.util.messages.Topic +import com.intellij.util.ui.UIUtil +import software.amazon.awssdk.services.codewhispererruntime.model.Import +import software.amazon.awssdk.services.codewhispererruntime.model.Reference +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorManager +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.addHorizontalGlue +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.horizontalPanelConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.inlineLabelConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.model.DetailContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererEditorActionHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererPopupBackspaceHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererPopupEnterHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererPopupLeftArrowHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererPopupRightArrowHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererPopupTabHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers.CodeWhispererPopupTypedHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners.CodeWhispererAcceptButtonActionListener +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners.CodeWhispererActionListener +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners.CodeWhispererNextButtonActionListener +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners.CodeWhispererPrevButtonActionListener +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners.CodeWhispererScrollListener +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStatus +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow.CodeWhispererCodeReferenceManager +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.POPUP_DIM_HEX +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.POPUP_INFO_TEXT_SIZE +import software.aws.toolkits.resources.message +import java.awt.Point +import java.awt.Rectangle +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.ComponentListener +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JLabel + +class CodeWhispererPopupManager { + val popupComponents = CodeWhispererPopupComponents() + + var shouldListenerCancelPopup: Boolean = true + private set + var sessionContext = SessionContext() + private set + + init { + // Listen for global scheme changes + ApplicationManager.getApplication().messageBus.connect().subscribe( + EditorColorsManager.TOPIC, + EditorColorsListener { scheme -> + if (scheme == null) return@EditorColorsListener + popupComponents.apply { + panel.background = scheme.defaultBackground + panel.components.forEach { + it.background = scheme.getColor(EditorColors.DOCUMENTATION_COLOR) + it.foreground = scheme.defaultForeground + } + buttonsPanel.components.forEach { + it.foreground = UIUtil.getLabelForeground() + } + recommendationInfoLabel.foreground = UIUtil.getLabelForeground() + codeReferencePanel.components.forEach { + it.background = scheme.getColor(EditorColors.DOCUMENTATION_COLOR) + it.foreground = UIUtil.getLabelForeground() + } + } + } + ) + } + + fun changeStates( + states: InvocationContext, + indexChange: Int, + typeaheadChange: String, + typeaheadAdded: Boolean, + recommendationAdded: Boolean = false + ) { + val (_, _, recommendationContext, popup) = states + val (details) = recommendationContext + if (recommendationAdded) { + LOG.debug { + "Add recommendations to the existing CodeWhisperer session, current number of recommendations: ${details.size}" + } + ApplicationManager.getApplication().messageBus.syncPublisher(CODEWHISPERER_POPUP_STATE_CHANGED) + .recommendationAdded(states, sessionContext) + return + } + val typeaheadOriginal = + if (typeaheadAdded) { + sessionContext.typeaheadOriginal + typeaheadChange + } else { + if (typeaheadChange.length > sessionContext.typeaheadOriginal.length) { + cancelPopup(popup) + return + } + sessionContext.typeaheadOriginal.substring( + 0, + sessionContext.typeaheadOriginal.length - typeaheadChange.length + ) + } + val isReverse = indexChange < 0 + val userInput = states.recommendationContext.userInputSinceInvocation + val validCount = getValidCount(details, userInput, typeaheadOriginal) + val validSelectedIndex = getValidSelectedIndex(details, userInput, sessionContext.selectedIndex, typeaheadOriginal) + if ((validSelectedIndex == validCount - 1 && indexChange == 1) || + (validSelectedIndex == 0 && indexChange == -1) + ) { + return + } + val selectedIndex = findNewSelectedIndex( + isReverse, + details, + userInput, + sessionContext.selectedIndex + indexChange, + typeaheadOriginal + ) + if (selectedIndex == -1 || !isValidRecommendation(details[selectedIndex], userInput, typeaheadOriginal)) { + LOG.debug { "None of the recommendation is valid at this point, cancelling the popup" } + cancelPopup(popup) + return + } + val typeahead = resolveTypeahead(states, selectedIndex, typeaheadOriginal) + val isFirstTimeShowingPopup = indexChange == 0 && typeaheadChange.isEmpty() + sessionContext = SessionContext( + typeahead, + typeaheadOriginal, + selectedIndex, + sessionContext.seen, + isFirstTimeShowingPopup, + sessionContext.toBeRemovedHighlighter + ) + + ApplicationManager.getApplication().messageBus.syncPublisher(CODEWHISPERER_POPUP_STATE_CHANGED).stateChanged( + states, + sessionContext + ) + } + + private fun resolveTypeahead(states: InvocationContext, selectedIndex: Int, typeahead: String): String { + val recommendation = states.recommendationContext.details[selectedIndex].reformatted.content() + val userInput = states.recommendationContext.userInputSinceInvocation + var indexOfFirstNonWhiteSpace = typeahead.indexOfFirst { !it.isWhitespace() } + if (indexOfFirstNonWhiteSpace == -1) { + indexOfFirstNonWhiteSpace = typeahead.length + } + + for (i in 0..indexOfFirstNonWhiteSpace) { + val subTypeahead = typeahead.substring(i) + if (recommendation.startsWith(userInput + subTypeahead)) return subTypeahead + } + return typeahead + } + + fun updatePopupPanel(states: InvocationContext, sessionContext: SessionContext) { + val userInput = states.recommendationContext.userInputSinceInvocation + val details = states.recommendationContext.details + val selectedIndex = sessionContext.selectedIndex + val typeaheadOriginal = sessionContext.typeaheadOriginal + val validCount = getValidCount(details, userInput, typeaheadOriginal) + val validSelectedIndex = getValidSelectedIndex(details, userInput, selectedIndex, typeaheadOriginal) + updateSelectedRecommendationLabelText(validSelectedIndex, validCount) + updateNavigationPanel(validSelectedIndex, validCount) + updateImportPanel(details[selectedIndex].recommendation.mostRelevantMissingImports()) + updateCodeReferencePanel(states.requestContext.project, details[selectedIndex].recommendation.references()) + } + + fun render( + states: InvocationContext, + sessionContext: SessionContext, + overlappingLinesCount: Int, + isRecommendationAdded: Boolean, + isScrolling: Boolean + ) { + updatePopupPanel(states, sessionContext) + + val caretPoint = states.requestContext.editor.offsetToXY(states.requestContext.caretPosition.offset) + sessionContext.seen.add(sessionContext.selectedIndex) + + // There are four cases that render() is called: + // 1. Popup showing for the first time, both booleans are false, we should show the popup and update the latency + // end time, and emit the event if it's at the pagination end. + // 2. New recommendations being added to the existing ones, we should not update the latency end time, and emit + // the event if it's at the pagination end. + // 3. User scrolling (so popup is changing positions), we should not update the latency end time and should not + // emit any events. + // 4. User navigating through the completions or typing as the completion shows. We should not update the latency + // end time and should not emit any events in this case. + if (!isRecommendationAdded) { + showPopup(states, sessionContext, states.popup, caretPoint, overlappingLinesCount) + if (!isScrolling) { + states.requestContext.latencyContext.codewhispererPostprocessingEnd = System.nanoTime() + states.requestContext.latencyContext.codewhispererEndToEndEnd = System.nanoTime() + } + } + if (isScrolling || + CodeWhispererInvocationStatus.getInstance().hasExistingInvocation() || + !sessionContext.isFirstTimeShowingPopup + ) { + return + } + CodeWhispererTelemetryService.getInstance().sendClientComponentLatencyEvent(states) + } + + fun dontClosePopupAndRun(runnable: () -> Unit) { + try { + shouldListenerCancelPopup = false + runnable() + } finally { + shouldListenerCancelPopup = true + } + } + + fun reset() { + sessionContext = SessionContext() + } + + fun cancelPopup(popup: JBPopup) { + popup.cancel() + } + + fun closePopup(popup: JBPopup) { + popup.closeOk(null) + } + + fun showPopup( + states: InvocationContext, + sessionContext: SessionContext, + popup: JBPopup, + p: Point, + overlappingLinesCount: Int + ) { + val editor = states.requestContext.editor + val detailContexts = states.recommendationContext.details + val userInputOriginal = states.recommendationContext.userInputOriginal + val userInput = states.recommendationContext.userInputSinceInvocation + val selectedIndex = sessionContext.selectedIndex + val typeaheadOriginal = sessionContext.typeaheadOriginal + val typeahead = sessionContext.typeahead + val userInputLines = userInputOriginal.split("\n").size - 1 + val lineCount = getReformattedRecommendation(detailContexts[selectedIndex], userInput).split("\n").size + val additionalLines = typeaheadOriginal.split("\n").size - typeahead.split("\n").size + val popupSize = (popup as AbstractPopup).preferredContentSize + val yBelowLastLine = p.y + (lineCount + additionalLines + userInputLines - overlappingLinesCount) * editor.lineHeight + val yAboveFirstLine = p.y - popupSize.height + (additionalLines + userInputLines) * editor.lineHeight + val editorRect = editor.scrollingModel.visibleArea + var popupRect = Rectangle(p.x, yBelowLastLine, popupSize.width, popupSize.height) + var shouldHidePopup = false + + CodeWhispererInvocationStatus.getInstance().setPopupActive(true) + + // Check if the current editor still has focus. If not, don't show the popup. + if (!editor.contentComponent.isFocusOwner) { + cancelPopup(popup) + return + } + + val popupLocation = + if (!editorRect.contains(popupRect)) { + popupRect = Rectangle(p.x, yAboveFirstLine, popupSize.width, popupSize.height) + if (!editorRect.contains(popupRect)) { + // both popup location (below last line and above first line) don't work, so don't show the popup + shouldHidePopup = true + } + LOG.debug { + "Show popup above the first line of recommendation. " + + "Editor position: $editorRect, popup position: $popupRect" + } + Point(p.x, yAboveFirstLine) + } else { + LOG.debug { + "Show popup below the last line of recommendation. " + + "Editor position: $editorRect, popup position: $popupRect" + } + Point(p.x, yBelowLastLine) + } + + val relativePopupLocationToEditor = RelativePoint(editor.contentComponent, popupLocation) + if (popup.isVisible) { + if (!shouldHidePopup) { + popup.setLocation(relativePopupLocationToEditor.screenPoint) + popup.size = popup.preferredContentSize + } + } else { + val originalAutoPopupCompletionLookup = CodeInsightSettings.getInstance().AUTO_POPUP_COMPLETION_LOOKUP + CodeInsightSettings.getInstance().AUTO_POPUP_COMPLETION_LOOKUP = false + Disposer.register(popup) { + CodeInsightSettings.getInstance().AUTO_POPUP_COMPLETION_LOOKUP = originalAutoPopupCompletionLookup + } + popup.show(relativePopupLocationToEditor) + val perceivedLatency = CodeWhispererInvocationStatus.getInstance().getTimeSinceDocumentChanged() + CodeWhispererTelemetryService.getInstance().sendPerceivedLatencyEvent( + detailContexts[selectedIndex].requestId, + states.requestContext, + states.responseContext, + perceivedLatency + ) + } + if (shouldHidePopup) { + WindowManager.getInstance().setAlphaModeRatio(popup.popupWindow, 1f) + } else { + WindowManager.getInstance().setAlphaModeRatio(popup.popupWindow, 0.1f) + } + } + + fun initPopup(): JBPopup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(popupComponents.panel, null) + .setAlpha(0.1F) + .setCancelOnClickOutside(true) + .setCancelOnOtherWindowOpen(true) + .setCancelKeyEnabled(true) + .setCancelOnWindowDeactivation(true) + .createPopup() + + fun getReformattedRecommendation(detailContext: DetailContext, userInput: String) = + detailContext.reformatted.content().substring(userInput.length) + + fun initPopupListener(states: InvocationContext) { + addPopupListener(states) + states.requestContext.editor.scrollingModel.addVisibleAreaListener(CodeWhispererScrollListener(states), states) + addButtonActionListeners(states) + addMessageSubscribers(states) + setPopupActionHandlers(states) + addComponentListeners(states) + } + + private fun addPopupListener(states: InvocationContext) { + val listener = CodeWhispererPopupListener(states) + states.popup.addListener(listener) + Disposer.register(states) { states.popup.removeListener(listener) } + } + + private fun addMessageSubscribers(states: InvocationContext) { + val connect = ApplicationManager.getApplication().messageBus.connect(states) + connect.subscribe( + CODEWHISPERER_USER_ACTION_PERFORMED, + object : CodeWhispererUserActionListener { + override fun navigateNext(states: InvocationContext) { + changeStates(states, 1, "", true) + } + + override fun navigatePrevious(states: InvocationContext) { + changeStates(states, -1, "", true) + } + + override fun backspace(states: InvocationContext, diff: String) { + changeStates(states, 0, diff, false) + } + + override fun enter(states: InvocationContext, diff: String) { + changeStates(states, 0, diff, true) + } + + override fun type(states: InvocationContext, diff: String) { + // remove the character at primaryCaret if it's the same as the typed character + val caretOffset = states.requestContext.editor.caretModel.primaryCaret.offset + val document = states.requestContext.editor.document + val text = document.charsSequence + if (caretOffset < text.length && diff == text[caretOffset].toString()) { + WriteCommandAction.runWriteCommandAction(states.requestContext.project) { + document.deleteString(caretOffset, caretOffset + 1) + } + } + changeStates(states, 0, diff, true) + } + + override fun beforeAccept(states: InvocationContext, sessionContext: SessionContext) { + dontClosePopupAndRun { + CodeWhispererEditorManager.getInstance().updateEditorWithRecommendation(states, sessionContext) + } + closePopup(states.popup) + } + } + ) + } + + private fun addButtonActionListeners(states: InvocationContext) { + popupComponents.prevButton.addButtonActionListener(CodeWhispererPrevButtonActionListener(states)) + popupComponents.nextButton.addButtonActionListener(CodeWhispererNextButtonActionListener(states)) + popupComponents.acceptButton.addButtonActionListener(CodeWhispererAcceptButtonActionListener(states)) + } + + private fun JButton.addButtonActionListener(listener: CodeWhispererActionListener) { + this.addActionListener(listener) + Disposer.register(listener.states) { this.removeActionListener(listener) } + } + + private fun setPopupActionHandlers(states: InvocationContext) { + val actionManager = EditorActionManager.getInstance() + setPopupTypedHandler(CodeWhispererPopupTypedHandler(TypedAction.getInstance().rawHandler, states)) + setPopupActionHandler(ACTION_EDITOR_TAB, CodeWhispererPopupTabHandler(states)) + setPopupActionHandler(ACTION_EDITOR_MOVE_CARET_LEFT, CodeWhispererPopupLeftArrowHandler(states)) + setPopupActionHandler(ACTION_EDITOR_MOVE_CARET_RIGHT, CodeWhispererPopupRightArrowHandler(states)) + setPopupActionHandler( + ACTION_EDITOR_ENTER, + CodeWhispererPopupEnterHandler(actionManager.getActionHandler(ACTION_EDITOR_ENTER), states) + ) + setPopupActionHandler( + ACTION_EDITOR_BACKSPACE, + CodeWhispererPopupBackspaceHandler(actionManager.getActionHandler(ACTION_EDITOR_BACKSPACE), states) + ) + } + + private fun setPopupTypedHandler(newHandler: CodeWhispererPopupTypedHandler) { + val oldTypedHandler = TypedAction.getInstance().setupRawHandler(newHandler) + Disposer.register(newHandler.states) { TypedAction.getInstance().setupRawHandler(oldTypedHandler) } + } + + private fun setPopupActionHandler(id: String, newHandler: CodeWhispererEditorActionHandler) { + val oldHandler = EditorActionManager.getInstance().setActionHandler(id, newHandler) + Disposer.register(newHandler.states) { EditorActionManager.getInstance().setActionHandler(id, oldHandler) } + } + + private fun addComponentListeners(states: InvocationContext) { + val editor = states.requestContext.editor + val codewhispererSelectionListener: SelectionListener = object : SelectionListener { + override fun selectionChanged(event: SelectionEvent) { + if (shouldListenerCancelPopup) { + cancelPopup(states.popup) + } + super.selectionChanged(event) + } + } + editor.selectionModel.addSelectionListener(codewhispererSelectionListener) + Disposer.register(states) { editor.selectionModel.removeSelectionListener(codewhispererSelectionListener) } + + val codewhispererDocumentListener: DocumentListener = object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + if (shouldListenerCancelPopup) { + cancelPopup(states.popup) + } + super.documentChanged(event) + } + } + editor.document.addDocumentListener(codewhispererDocumentListener, states) + + val codewhispererCaretListener: CaretListener = object : CaretListener { + override fun caretPositionChanged(event: CaretEvent) { + if (shouldListenerCancelPopup) { + cancelPopup(states.popup) + } + super.caretPositionChanged(event) + } + } + editor.caretModel.addCaretListener(codewhispererCaretListener) + Disposer.register(states) { editor.caretModel.removeCaretListener(codewhispererCaretListener) } + + val editorComponent = editor.contentComponent + if (editorComponent.isShowing) { + val window = ComponentUtil.getWindow(editorComponent) + val windowListener: ComponentListener = object : ComponentAdapter() { + override fun componentMoved(event: ComponentEvent) { + cancelPopup(states.popup) + } + + override fun componentShown(e: ComponentEvent?) { + cancelPopup(states.popup) + super.componentShown(e) + } + } + window?.addComponentListener(windowListener) + Disposer.register(states) { window?.removeComponentListener(windowListener) } + } + } + + private fun updateSelectedRecommendationLabelText(validSelectedIndex: Int, validCount: Int) { + if (CodeWhispererInvocationStatus.getInstance().hasExistingInvocation()) { + popupComponents.recommendationInfoLabel.text = message("codewhisperer.popup.pagination_info") + LOG.debug { "Pagination in progress. Current total: $validCount" } + } else { + popupComponents.recommendationInfoLabel.text = + message( + "codewhisperer.popup.recommendation_info", + validSelectedIndex + 1, + validCount, + POPUP_DIM_HEX + ) + LOG.debug { "Updated popup recommendation label text. Index: $validSelectedIndex, total: $validCount" } + } + } + + private fun updateNavigationPanel(validSelectedIndex: Int, validCount: Int) { + val multipleRecommendation = validCount > 1 + popupComponents.prevButton.isEnabled = multipleRecommendation && validSelectedIndex != 0 + popupComponents.nextButton.isEnabled = multipleRecommendation && validSelectedIndex != validCount - 1 + } + + private fun updateImportPanel(imports: List) { + popupComponents.panel.apply { + if (components.contains(popupComponents.importPanel)) { + remove(popupComponents.importPanel) + } + } + if (imports.isEmpty()) return + + val firstImport = imports.first() + val choice = if (imports.size > 2) 2 else imports.size - 1 + val message = message("codewhisperer.popup.import_info", firstImport.statement(), imports.size - 1, choice) + popupComponents.panel.add(popupComponents.importPanel, horizontalPanelConstraints) + popupComponents.importLabel.text = message + } + + private fun updateCodeReferencePanel(project: Project, references: List) { + popupComponents.panel.apply { + if (components.contains(popupComponents.codeReferencePanel)) { + remove(popupComponents.codeReferencePanel) + } + } + if (references.isEmpty()) return + + popupComponents.panel.add(popupComponents.codeReferencePanel, horizontalPanelConstraints) + val licenses = references.map { it.licenseName() }.toSet() + popupComponents.codeReferencePanelLink.apply { + actionListeners.toList().forEach { + removeActionListener(it) + } + addActionListener { + CodeWhispererCodeReferenceManager.getInstance(project).showCodeReferencePanel() + } + } + popupComponents.licenseCodePanel.apply { + removeAll() + add(popupComponents.licenseCodeLabelPrefixText, inlineLabelConstraints) + licenses.forEachIndexed { i, license -> + add(popupComponents.licenseLink(license), inlineLabelConstraints) + if (i == licenses.size - 1) return@forEachIndexed + add(JLabel(", "), inlineLabelConstraints) + } + + add(JLabel(". "), inlineLabelConstraints) + add(popupComponents.codeReferencePanelLink, inlineLabelConstraints) + addHorizontalGlue() + } + popupComponents.licenseCodePanel.components.forEach { + if (it !is JComponent) return@forEach + it.font = it.font.deriveFont(POPUP_INFO_TEXT_SIZE) + } + } + + fun hasConflictingPopups(editor: Editor): Boolean = + ParameterInfoController.existsWithVisibleHintForEditor(editor, true) || + LookupManager.getActiveLookup(editor) != null + + private fun findNewSelectedIndex( + isReverse: Boolean, + detailContexts: List, + userInput: String, + start: Int, + typeahead: String + ): Int { + val count = detailContexts.size + val unit = if (isReverse) -1 else 1 + var currIndex: Int + for (i in 0 until count) { + currIndex = (start + i * unit) % count + if (currIndex < 0) { + currIndex += count + } + if (isValidRecommendation(detailContexts[currIndex], userInput, typeahead)) { + return currIndex + } + } + return -1 + } + + private fun getValidCount(detailContexts: List, userInput: String, typeahead: String): Int = + detailContexts.filter { isValidRecommendation(it, userInput, typeahead) }.size + + private fun getValidSelectedIndex( + detailContexts: List, + userInput: String, + selectedIndex: Int, + typeahead: String + ): Int { + var currIndexIgnoreInvalid = 0 + detailContexts.forEachIndexed { index, value -> + if (index == selectedIndex) { + return currIndexIgnoreInvalid + } + if (isValidRecommendation(value, userInput, typeahead)) { + currIndexIgnoreInvalid++ + } + } + return -1 + } + + private fun isValidRecommendation(detailContext: DetailContext, userInput: String, typeahead: String): Boolean { + if (detailContext.isDiscarded) return false + if (detailContext.recommendation.content().isEmpty()) return false + val indexOfFirstNonWhiteSpace = typeahead.indexOfFirst { !it.isWhitespace() } + if (indexOfFirstNonWhiteSpace == -1) return true + + for (i in 0..indexOfFirstNonWhiteSpace) { + val subTypeahead = typeahead.substring(i) + if (detailContext.reformatted.content().startsWith(userInput + subTypeahead)) return true + } + return false + } + + companion object { + private val LOG = getLogger() + fun getInstance(): CodeWhispererPopupManager = service() + val CODEWHISPERER_POPUP_STATE_CHANGED: Topic = Topic.create( + "CodeWhisperer popup state changed", + CodeWhispererPopupStateChangeListener::class.java + ) + val CODEWHISPERER_USER_ACTION_PERFORMED: Topic = Topic.create( + "CodeWhisperer user action performed", + CodeWhispererUserActionListener::class.java + ) + } +} + +interface CodeWhispererPopupStateChangeListener { + fun stateChanged(states: InvocationContext, sessionContext: SessionContext) {} + fun scrolled(states: InvocationContext, sessionContext: SessionContext) {} + fun recommendationAdded(states: InvocationContext, sessionContext: SessionContext) {} +} + +interface CodeWhispererUserActionListener { + fun backspace(states: InvocationContext, diff: String) {} + fun enter(states: InvocationContext, diff: String) {} + fun type(states: InvocationContext, diff: String) {} + fun navigatePrevious(states: InvocationContext) {} + fun navigateNext(states: InvocationContext) {} + fun beforeAccept(states: InvocationContext, sessionContext: SessionContext) {} + fun afterAccept(states: InvocationContext, sessionContext: SessionContext, rangeMarker: RangeMarker) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererUIChangeListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererUIChangeListener.kt new file mode 100644 index 0000000000..e4bb87feec --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererUIChangeListener.kt @@ -0,0 +1,143 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup + +import com.intellij.openapi.editor.markup.EffectType +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.util.Disposer +import com.intellij.xdebugger.ui.DebuggerColors +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorManager +import software.aws.toolkits.jetbrains.services.codewhisperer.inlay.CodeWhispererInlayManager +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.RecommendationChunk +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContext +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererRecommendationManager + +class CodeWhispererUIChangeListener : CodeWhispererPopupStateChangeListener { + override fun stateChanged(states: InvocationContext, sessionContext: SessionContext) { + val editor = states.requestContext.editor + val editorManager = CodeWhispererEditorManager.getInstance() + val selectedIndex = sessionContext.selectedIndex + val typeahead = sessionContext.typeahead + val detail = states.recommendationContext.details[selectedIndex] + val caretOffset = editor.caretModel.primaryCaret.offset + val document = editor.document + val lineEndOffset = document.getLineEndOffset(document.getLineNumber(caretOffset)) + + // get matching brackets from recommendations to the brackets after caret position + val remaining = CodeWhispererPopupManager.getInstance().getReformattedRecommendation( + detail, + states.recommendationContext.userInputSinceInvocation + ).substring(typeahead.length) + + val remainingLines = remaining.split("\n") + val firstLineOfRemaining = remainingLines.first() + val otherLinesOfRemaining = remainingLines.drop(1) + + // process first line inlays, where we do subsequence matching as much as possible + val matchingSymbols = editorManager.getMatchingSymbolsFromRecommendation( + editor, + firstLineOfRemaining, + detail.isTruncatedOnRight, + sessionContext + ) + + sessionContext.toBeRemovedHighlighter?.let { + editor.markupModel.removeHighlighter(it) + } + + // Add the strike-though hint for the remaining non-matching first-line right context for multi-line completions + if (!detail.isTruncatedOnRight && otherLinesOfRemaining.isNotEmpty()) { + val rangeHighlighter = editor.markupModel.addRangeHighlighter( + matchingSymbols[matchingSymbols.size - 2].second, + lineEndOffset, + HighlighterLayer.LAST + 1, + TextAttributes().apply { + effectType = EffectType.STRIKEOUT + effectColor = editor.colorsScheme.getAttributes(DebuggerColors.INLINED_VALUES_EXECUTION_LINE).foregroundColor + }, + HighlighterTargetArea.EXACT_RANGE + ) + Disposer.register(states.popup) { + editor.markupModel.removeHighlighter(rangeHighlighter) + } + sessionContext.toBeRemovedHighlighter = rangeHighlighter + } + + val chunks = CodeWhispererRecommendationManager.getInstance().buildRecommendationChunks( + firstLineOfRemaining, + matchingSymbols + ) + + // process other lines inlays, where we do tail-head matching as much as possible + val overlappingLinesCount = editorManager.findOverLappingLines( + editor, + otherLinesOfRemaining, + detail.isTruncatedOnRight, + sessionContext + ) + + var otherLinesInlayText = "" + otherLinesOfRemaining.subList(0, otherLinesOfRemaining.size - overlappingLinesCount).forEach { + otherLinesInlayText += "\n" + it + } + + // inlay chunks are chunks from first line(chunks) and an additional chunk from other lines + val inlayChunks = chunks + listOf(RecommendationChunk(otherLinesInlayText, 0, chunks.last().inlayOffset)) + CodeWhispererInlayManager.getInstance().updateInlays(states, inlayChunks) + CodeWhispererPopupManager.getInstance().render( + states, + sessionContext, + overlappingLinesCount, + isRecommendationAdded = false, + isScrolling = false + ) + } + + override fun scrolled(states: InvocationContext, sessionContext: SessionContext) { + if (states.popup.isDisposed) return + val editor = states.requestContext.editor + val editorManager = CodeWhispererEditorManager.getInstance() + val selectedIndex = sessionContext.selectedIndex + val typeahead = sessionContext.typeahead + val detail = states.recommendationContext.details[selectedIndex] + + // get matching brackets from recommendations to the brackets after caret position + val remaining = CodeWhispererPopupManager.getInstance().getReformattedRecommendation( + detail, + states.recommendationContext.userInputSinceInvocation + ).substring(typeahead.length) + + val remainingLines = remaining.split("\n") + val otherLinesOfRemaining = remainingLines.drop(1) + + // process other lines inlays, where we do tail-head matching as much as possible + val overlappingLinesCount = editorManager.findOverLappingLines( + editor, + otherLinesOfRemaining, + detail.isTruncatedOnRight, + sessionContext + ) + + CodeWhispererPopupManager.getInstance().render( + states, + sessionContext, + overlappingLinesCount, + isRecommendationAdded = false, + isScrolling = true + ) + } + + override fun recommendationAdded(states: InvocationContext, sessionContext: SessionContext) { + CodeWhispererPopupManager.getInstance().render( + states, + sessionContext, + 0, + isRecommendationAdded = true, + isScrolling = false + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererEditorActionHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererEditorActionHandler.kt new file mode 100644 index 0000000000..0e02fb260b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererEditorActionHandler.kt @@ -0,0 +1,9 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers + +import com.intellij.openapi.editor.actionSystem.EditorActionHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext + +abstract class CodeWhispererEditorActionHandler(val states: InvocationContext) : EditorActionHandler() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupBackspaceHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupBackspaceHandler.kt new file mode 100644 index 0000000000..8f32eceac3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupBackspaceHandler.kt @@ -0,0 +1,30 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.actionSystem.EditorActionHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager + +class CodeWhispererPopupBackspaceHandler( + private val defaultHandler: EditorActionHandler, + states: InvocationContext +) : CodeWhispererEditorActionHandler(states) { + override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) { + val popupManager = CodeWhispererPopupManager.getInstance() + popupManager.dontClosePopupAndRun { + val oldOffset = editor.caretModel.offset + defaultHandler.execute(editor, caret, dataContext) + val newOffset = editor.caretModel.offset + val newText = "a".repeat(oldOffset - newOffset) + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED + ).backspace(states, newText) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupEnterHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupEnterHandler.kt new file mode 100644 index 0000000000..1c71f0675d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupEnterHandler.kt @@ -0,0 +1,31 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.actionSystem.EditorActionHandler +import com.intellij.openapi.util.TextRange +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager + +class CodeWhispererPopupEnterHandler( + private val defaultHandler: EditorActionHandler, + states: InvocationContext +) : CodeWhispererEditorActionHandler(states) { + override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) { + val popupManager = CodeWhispererPopupManager.getInstance() + popupManager.dontClosePopupAndRun { + val oldOffset = editor.caretModel.offset + defaultHandler.execute(editor, caret, dataContext) + val newOffset = editor.caretModel.offset + val newText = editor.document.getText(TextRange.create(oldOffset, newOffset)) + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED + ).enter(states, newText) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupLeftArrowHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupLeftArrowHandler.kt new file mode 100644 index 0000000000..020e143480 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupLeftArrowHandler.kt @@ -0,0 +1,19 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager + +class CodeWhispererPopupLeftArrowHandler(states: InvocationContext) : CodeWhispererEditorActionHandler(states) { + override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) { + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED + ).navigatePrevious(states) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupRightArrowHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupRightArrowHandler.kt new file mode 100644 index 0000000000..9efba65a80 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupRightArrowHandler.kt @@ -0,0 +1,19 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager + +class CodeWhispererPopupRightArrowHandler(states: InvocationContext) : CodeWhispererEditorActionHandler(states) { + override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) { + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED + ).navigateNext(states) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupTabHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupTabHandler.kt new file mode 100644 index 0000000000..c92eae9106 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupTabHandler.kt @@ -0,0 +1,19 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager + +class CodeWhispererPopupTabHandler(states: InvocationContext) : CodeWhispererEditorActionHandler(states) { + override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) { + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED + ).beforeAccept(states, CodeWhispererPopupManager.getInstance().sessionContext) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupTypedHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupTypedHandler.kt new file mode 100644 index 0000000000..7e18feaf3e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/handlers/CodeWhispererPopupTypedHandler.kt @@ -0,0 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.handlers + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.actionSystem.TypedActionHandler +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager + +class CodeWhispererPopupTypedHandler( + private val defaultHandler: TypedActionHandler, + val states: InvocationContext, +) : TypedActionHandler { + override fun execute(editor: Editor, charTyped: Char, dataContext: DataContext) { + CodeWhispererPopupManager.getInstance().dontClosePopupAndRun { + defaultHandler.execute(editor, charTyped, dataContext) + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED + ).type(states, charTyped.toString()) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererAcceptButtonActionListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererAcceptButtonActionListener.kt new file mode 100644 index 0000000000..7bc77295a9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererAcceptButtonActionListener.kt @@ -0,0 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners + +import com.intellij.openapi.application.ApplicationManager +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager +import java.awt.event.ActionEvent + +class CodeWhispererAcceptButtonActionListener(states: InvocationContext) : CodeWhispererActionListener(states) { + override fun actionPerformed(e: ActionEvent?) { + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED + ).beforeAccept(states, CodeWhispererPopupManager.getInstance().sessionContext) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererActionListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererActionListener.kt new file mode 100644 index 0000000000..c04f8cc444 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererActionListener.kt @@ -0,0 +1,9 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners + +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import java.awt.event.ActionListener + +abstract class CodeWhispererActionListener(val states: InvocationContext) : ActionListener diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererNextButtonActionListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererNextButtonActionListener.kt new file mode 100644 index 0000000000..ce1d34432e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererNextButtonActionListener.kt @@ -0,0 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners + +import com.intellij.openapi.application.ApplicationManager +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager +import java.awt.event.ActionEvent + +class CodeWhispererNextButtonActionListener(states: InvocationContext) : CodeWhispererActionListener(states) { + override fun actionPerformed(e: ActionEvent?) { + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED + ).navigateNext(states) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererPrevButtonActionListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererPrevButtonActionListener.kt new file mode 100644 index 0000000000..e77fdf469b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererPrevButtonActionListener.kt @@ -0,0 +1,17 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners + +import com.intellij.openapi.application.ApplicationManager +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager +import java.awt.event.ActionEvent + +class CodeWhispererPrevButtonActionListener(states: InvocationContext) : CodeWhispererActionListener(states) { + override fun actionPerformed(e: ActionEvent?) { + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED + ).navigatePrevious(states) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererScrollListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererScrollListener.kt new file mode 100644 index 0000000000..f1dfab068a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/listeners/CodeWhispererScrollListener.kt @@ -0,0 +1,25 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.popup.listeners + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.event.VisibleAreaEvent +import com.intellij.openapi.editor.event.VisibleAreaListener +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStatus + +class CodeWhispererScrollListener(private val states: InvocationContext) : VisibleAreaListener { + override fun visibleAreaChanged(e: VisibleAreaEvent) { + val oldRect = e.oldRectangle + val newRect = e.newRectangle + if (CodeWhispererInvocationStatus.getInstance().isPopupActive() && + (oldRect.x != newRect.x || oldRect.y != newRect.y) + ) { + ApplicationManager.getApplication().messageBus.syncPublisher( + CodeWhispererPopupManager.CODEWHISPERER_POPUP_STATE_CHANGED + ).scrolled(states, CodeWhispererPopupManager.getInstance().sessionContext) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerHandler.kt new file mode 100644 index 0000000000..c536f0c0ca --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerHandler.kt @@ -0,0 +1,28 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.intellij.openapi.editor.Editor +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.model.LatencyContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.TriggerTypeInfo +import software.aws.toolkits.telemetry.CodewhispererTriggerType + +interface CodeWhispererAutoTriggerHandler { + fun performAutomatedTriggerAction( + editor: Editor, + automatedTriggerType: CodeWhispererAutomatedTriggerType, + latencyContext: LatencyContext, + ) { + val triggerTypeInfo = TriggerTypeInfo(CodewhispererTriggerType.AutoTrigger, automatedTriggerType) + + LOG.debug { "autotriggering CodeWhisperer with type ${automatedTriggerType.telemetryType}" } + CodeWhispererService.getInstance().showRecommendationsInPopup(editor, triggerTypeInfo, latencyContext) + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerService.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerService.kt new file mode 100644 index 0000000000..fa171405fe --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutoTriggerService.kt @@ -0,0 +1,330 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.Alarm +import com.intellij.util.AlarmFactory +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.apache.commons.collections4.queue.CircularFifoQueue +import software.aws.toolkits.jetbrains.core.coroutines.applicationCoroutineScope +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererUnknownLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.model.LatencyContext +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.telemetry.CodewhispererAutomatedTriggerType +import software.aws.toolkits.telemetry.CodewhispererPreviousSuggestionState +import software.aws.toolkits.telemetry.CodewhispererTriggerType +import java.time.Duration +import java.time.Instant +import kotlin.math.exp + +data class ClassifierResult(val shouldTrigger: Boolean, val calculatedResult: Double = 0.0) + +class CodeWhispererAutoTriggerService : CodeWhispererAutoTriggerHandler, Disposable { + private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this) + private val previousUserTriggerDecisions = CircularFifoQueue(5) + + private var lastInvocationTime: Instant? = null + private var lastInvocationLineNum: Int? = null + + init { + scheduleReset() + } + + fun addPreviousDecision(decision: CodewhispererPreviousSuggestionState) { + previousUserTriggerDecisions.add(decision) + } + + // a util wrapper + fun tryInvokeAutoTrigger(editor: Editor, triggerType: CodeWhispererAutomatedTriggerType): Job? { + // only needed for Classifier group, thus calculate it lazily + val classifierResult: ClassifierResult by lazy { shouldTriggerClassifier(editor, triggerType.telemetryType) } + val language = runReadAction { + FileDocumentManager.getInstance().getFile(editor.document)?.programmingLanguage() + } ?: CodeWhispererUnknownLanguage.INSTANCE + + // we need classifier result for any type of triggering for classifier group for supported languages + triggerType.calculationResult = classifierResult.calculatedResult + + return when (triggerType) { + // only invoke service if result > threshold for classifier trigger + is CodeWhispererAutomatedTriggerType.Classifier -> run { + if (classifierResult.shouldTrigger) { + invoke(editor, triggerType) + } else { + null + } + } + + // invoke whatever the result is for char / enter based trigger + else -> run { + invoke(editor, triggerType) + } + } + } + + // real auto trigger logic + fun invoke(editor: Editor, triggerType: CodeWhispererAutomatedTriggerType): Job? { + if (!CodeWhispererService.getInstance().canDoInvocation(editor, CodewhispererTriggerType.AutoTrigger)) { + return null + } + + lastInvocationTime = Instant.now() + lastInvocationLineNum = runReadAction { editor.caretModel.visualPosition.line } + + val latencyContext = LatencyContext().apply { + codewhispererPreprocessingStart = System.nanoTime() + codewhispererEndToEndStart = System.nanoTime() + } + + val coroutineScope = applicationCoroutineScope() + + return when (triggerType) { + is CodeWhispererAutomatedTriggerType.IdleTime -> run { + coroutineScope.launch { + // TODO: potential race condition between hasExistingInvocation and entering edt + // but in that case we will just return in performAutomatedTriggerAction + while (!CodeWhispererInvocationStatus.getInstance().hasEnoughDelayToInvokeCodeWhisperer() || + CodeWhispererInvocationStatus.getInstance().hasExistingInvocation() + ) { + if (!isActive) return@launch + delay(CodeWhispererConstants.IDLE_TIME_CHECK_INTERVAL) + } + runInEdt { + if (CodeWhispererInvocationStatus.getInstance().isPopupActive()) return@runInEdt + performAutomatedTriggerAction(editor, CodeWhispererAutomatedTriggerType.IdleTime(), latencyContext) + } + } + } + + else -> run { + coroutineScope.launch { + performAutomatedTriggerAction(editor, triggerType, latencyContext) + } + } + } + } + + private fun scheduleReset() { + if (!alarm.isDisposed) { + alarm.addRequest({ resetPreviousStates() }, Duration.ofSeconds(120).toMillis()) + } + } + + private fun resetPreviousStates() { + try { + previousUserTriggerDecisions.clear() + lastInvocationLineNum = null + lastInvocationTime = null + } finally { + scheduleReset() + } + } + + fun shouldTriggerClassifier( + editor: Editor, + automatedTriggerType: CodewhispererAutomatedTriggerType = CodewhispererAutomatedTriggerType.Classifier // TODO: need this? + ): ClassifierResult { + val caretContext = runReadAction { CodeWhispererEditorUtil.extractCaretContext(editor) } + val language = runReadAction { + FileDocumentManager.getInstance().getFile(editor.document)?.programmingLanguage() + } ?: CodeWhispererUnknownLanguage.INSTANCE + val caretPosition = runReadAction { CodeWhispererEditorUtil.getCaretPosition(editor) } + + val leftContextLines = caretContext.leftFileContext.split(Regex("\r?\n")) + val leftContextLength = caretContext.leftFileContext.length + val leftContextAtCurrentLine = if (leftContextLines.size - 1 >= 0) leftContextLines[leftContextLines.size - 1] else "" + var keyword = "" + val lastToken = leftContextAtCurrentLine.trim().split(" ").let { tokens -> + if (tokens.size - 1 >= 0) tokens[tokens.size - 1] else "" + } + if (lastToken.length > 1) keyword = lastToken + + val lengthOfLeftCurrent = leftContextAtCurrentLine.length + val lengthOfLeftPrev = if (leftContextLines.size - 2 >= 0) { + leftContextLines[leftContextLines.size - 2].length.toDouble() + } else { + 0.0 + } + + val rightContext = caretContext.rightFileContext + val lengthOfRight = rightContext.trim().length + + val triggerTypeCoefficient = CodeWhispererClassifierConstants.triggerTypeCoefficientMap[automatedTriggerType] ?: 0.0 + + val osCoefficient: Double = if (SystemInfo.isMac) { + CodeWhispererClassifierConstants.osMap["Mac OS X"] ?: 0.0 + } else if (SystemInfo.isWindows) { + val osVersion = SystemInfo.OS_VERSION + if (osVersion.contains("11", true) || osVersion.contains("10", true)) { + CodeWhispererClassifierConstants.osMap["Windows 10"] + } else { + CodeWhispererClassifierConstants.osMap["Windows"] + } + } else { + 0.0 + } ?: 0.0 + + val lastCharCoefficient = if (leftContextAtCurrentLine.length - 1 >= 0) { + CodeWhispererClassifierConstants.coefficientsMap[leftContextAtCurrentLine[leftContextAtCurrentLine.length - 1].toString()] ?: 0.0 + } else { + 0.0 + } + + val keywordCoefficient = CodeWhispererClassifierConstants.coefficientsMap[keyword] ?: 0.0 + val languageCoefficient = CodeWhispererClassifierConstants.languageMap[language] ?: 0.0 + val ideCoefficient = 0.0 + + var previousOneAccept: Double = 0.0 + var previousOneReject: Double = 0.0 + var previousOneOther: Double = 0.0 + val previousOneDecision = CodeWhispererTelemetryService.getInstance().previousUserTriggerDecision + if (previousOneDecision == null) { + previousOneAccept = 0.0 + previousOneReject = 0.0 + previousOneOther = 0.0 + } else { + previousOneAccept = + if (previousOneDecision == CodewhispererPreviousSuggestionState.Accept) { + CodeWhispererClassifierConstants.prevDecisionAcceptCoefficient + } else { + 0.0 + } + previousOneReject = + if (previousOneDecision == CodewhispererPreviousSuggestionState.Reject) { + CodeWhispererClassifierConstants.prevDecisionRejectCoefficient + } else { + 0.0 + } + previousOneOther = + if ( + previousOneDecision != CodewhispererPreviousSuggestionState.Accept && + previousOneDecision != CodewhispererPreviousSuggestionState.Reject + ) { + CodeWhispererClassifierConstants.prevDecisionOtherCoefficient + } else { + 0.0 + } + } + + var leftContextLengthCoefficient: Double = 0.0 + + leftContextLengthCoefficient = when (leftContextLength) { + in 0..4 -> CodeWhispererClassifierConstants.lengthLeft0To5 + in 5..9 -> CodeWhispererClassifierConstants.lengthLeft5To10 + in 10..19 -> CodeWhispererClassifierConstants.lengthLeft10To20 + in 20..29 -> CodeWhispererClassifierConstants.lengthLeft20To30 + in 30..39 -> CodeWhispererClassifierConstants.lengthLeft30To40 + in 40..49 -> CodeWhispererClassifierConstants.lengthLeft40To50 + else -> 0.0 + } + + val normalizedLengthOfRight = CodeWhispererClassifierConstants.lengthofRightCoefficient * VariableTypeNeedNormalize.LenRight.normalize( + lengthOfRight.toDouble() + ) + + val normalizedLengthOfLeftCurrent = CodeWhispererClassifierConstants.lengthOfLeftCurrentCoefficient * VariableTypeNeedNormalize.LenLeftCur.normalize( + lengthOfLeftCurrent.toDouble() + ) + + val normalizedLengthOfPrev = CodeWhispererClassifierConstants.lengthOfLeftPrevCoefficient * VariableTypeNeedNormalize.LenLeftPrev.normalize( + lengthOfLeftPrev + ) + + val normalizedLineNum = CodeWhispererClassifierConstants.lineNumCoefficient * VariableTypeNeedNormalize.LineNum.normalize(caretPosition.line.toDouble()) + + val intercept = CodeWhispererClassifierConstants.intercept + + val resultBeforeSigmoid = + normalizedLengthOfRight + + normalizedLengthOfLeftCurrent + + normalizedLengthOfPrev + + normalizedLineNum + + languageCoefficient + + osCoefficient + + triggerTypeCoefficient + + lastCharCoefficient + + keywordCoefficient + + ideCoefficient + + previousOneAccept + + previousOneReject + + previousOneOther + + leftContextLengthCoefficient + + intercept + + val shouldTrigger = sigmoid(resultBeforeSigmoid) > getThreshold() + + return ClassifierResult(shouldTrigger, sigmoid(resultBeforeSigmoid)) + } + + override fun dispose() {} + + companion object { + private const val triggerThreshold: Double = 0.43 + + fun getInstance(): CodeWhispererAutoTriggerService = service() + + fun getThreshold(): Double = triggerThreshold + + fun sigmoid(x: Double): Double = 1 / (1 + exp(-x)) + } +} + +private enum class VariableTypeNeedNormalize { + Cursor { + override fun normalize(value: Double): Double = 0.0 + }, + LineNum { + override fun normalize(value: Double): Double = (value - minn.lineNum) / (maxx.lineNum - minn.lineNum) + }, + LenLeftCur { + override fun normalize(value: Double): Double = (value - minn.lenLeftCur) / (maxx.lenLeftCur - minn.lenLeftCur) + }, + LenLeftPrev { + override fun normalize(value: Double): Double = (value - minn.lenLeftPrev) / (maxx.lenLeftPrev - minn.lenLeftPrev) + }, + LenRight { + override fun normalize(value: Double): Double = (value - minn.lenRight) / (maxx.lenRight - minn.lenRight) + }, + LineDiff { + override fun normalize(value: Double): Double = 0.0 + }; + + abstract fun normalize(toDouble: Double): Double + + data class NormalizedCoefficients( + val lineNum: Double, + val lenLeftCur: Double, + val lenLeftPrev: Double, + val lenRight: Double, + ) + + companion object { + private val maxx = NormalizedCoefficients( + lineNum = 4631.0, + lenLeftCur = 157.0, + lenLeftPrev = 176.0, + lenRight = 10239.0, + ) + + private val minn = NormalizedCoefficients( + lineNum = 0.0, + lenLeftCur = 0.0, + lenLeftPrev = 0.0, + lenRight = 0.0, + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutomatedTriggerType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutomatedTriggerType.kt new file mode 100644 index 0000000000..e083689b7e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererAutomatedTriggerType.kt @@ -0,0 +1,24 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import software.aws.toolkits.telemetry.CodewhispererAutomatedTriggerType + +sealed class CodeWhispererAutomatedTriggerType( + val telemetryType: CodewhispererAutomatedTriggerType, + var calculationResult: Double? = null +) { + class Classifier : CodeWhispererAutomatedTriggerType(CodewhispererAutomatedTriggerType.Classifier) + class SpecialChar(val specialChar: Char) : + CodeWhispererAutomatedTriggerType(CodewhispererAutomatedTriggerType.SpecialCharacters) + + class Enter : CodeWhispererAutomatedTriggerType(CodewhispererAutomatedTriggerType.Enter) + + class IntelliSense : + CodeWhispererAutomatedTriggerType(CodewhispererAutomatedTriggerType.IntelliSenseAcceptance) + + class IdleTime : CodeWhispererAutomatedTriggerType(CodewhispererAutomatedTriggerType.IdleTime) + + class Unknown : CodeWhispererAutomatedTriggerType(CodewhispererAutomatedTriggerType.Unknown) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererClassifierConstants.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererClassifierConstants.kt new file mode 100644 index 0000000000..f100578d8c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererClassifierConstants.kt @@ -0,0 +1,441 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererCpp +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererCsharp +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererGo +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJava +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJavaScript +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJson +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJsx +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererKotlin +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererPhp +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererPlainText +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererPython +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererRuby +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererRust +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererScala +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererShell +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererSql +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererTf +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererTsx +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererTypeScript +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererYaml +import software.aws.toolkits.telemetry.CodewhispererAutomatedTriggerType + +object CodeWhispererClassifierConstants { + val osMap: Map = mapOf( + "Mac OS X" to -0.1552, + "Windows 10" to -0.0238, + "Windows" to 0.0412, + "win32" to -0.0559, + ) + + // these are used for 100% classifier driven auto trigger + val triggerTypeCoefficientMap: Map = mapOf( + CodewhispererAutomatedTriggerType.SpecialCharacters to 0.0209, + CodewhispererAutomatedTriggerType.Enter to 0.2853 + ) + + val languageMap: Map = mapOf( + CodeWhispererPython.INSTANCE to -0.3052, + CodeWhispererJava.INSTANCE to -0.4622, + CodeWhispererJavaScript.INSTANCE to -0.4688, + CodeWhispererCsharp.INSTANCE to -0.3475, + CodeWhispererPlainText.INSTANCE to 0.0, + CodeWhispererTypeScript.INSTANCE to -0.6084, + CodeWhispererTsx.INSTANCE to -0.6084, + CodeWhispererJsx.INSTANCE to -0.4688, + CodeWhispererShell.INSTANCE to -0.4718, + CodeWhispererRuby.INSTANCE to -0.7356, + CodeWhispererSql.INSTANCE to -0.4937, + CodeWhispererRust.INSTANCE to -0.4309, + CodeWhispererKotlin.INSTANCE to -0.4739, + CodeWhispererPhp.INSTANCE to -0.3917, + CodeWhispererGo.INSTANCE to -0.3504, + CodeWhispererScala.INSTANCE to -0.534, + CodeWhispererCpp.INSTANCE to -0.1734, + CodeWhispererJson.INSTANCE to 0.0, + CodeWhispererYaml.INSTANCE to -0.3, + CodeWhispererTf.INSTANCE to -0.55 + ) + + // other metadata coefficient + const val lineNumCoefficient = -0.0416 + + // length of the current line of left_context + const val lengthOfLeftCurrentCoefficient = -1.1747 + + // length of the previous line of left context + const val lengthOfLeftPrevCoefficient = 0.4033 + + // lenght of right_context + const val lengthofRightCoefficient = -0.3321 + + const val prevDecisionAcceptCoefficient = 0.5397 + + const val prevDecisionRejectCoefficient = -0.1656 + + const val prevDecisionOtherCoefficient = 0.0 + + // intercept of logistic regression classifier + const val intercept = 0.3738713 + + // length of left context + const val lengthLeft0To5 = -0.8756 + const val lengthLeft5To10 = -0.5463 + const val lengthLeft10To20 = -0.4081 + const val lengthLeft20To30 = -0.3272 + const val lengthLeft30To40 = -0.2442 + const val lengthLeft40To50 = -0.1471 + + val coefficientsMap = mapOf( + "throw" to 1.5868, + ";" to -1.268, + "any" to -1.1565, + "7" to -1.1347, + "false" to -1.1307, + "nil" to -1.0653, + "elif" to 1.0122, + "9" to -1.0098, + "pass" to -1.0058, + "True" to -1.0002, + "False" to -0.9434, + "6" to -0.9222, + "true" to -0.9142, + "None" to -0.9027, + "8" to -0.9013, + "break" to -0.8475, + "}" to -0.847, + "5" to -0.8414, + "4" to -0.8197, + "1" to -0.8085, + "\\" to -0.8019, + "static" to -0.7748, + "0" to -0.77, + "end" to -0.7617, + "(" to 0.7239, + "/" to -0.7104, + "where" to -0.6981, + "readonly" to -0.6741, + "async" to -0.6723, + "3" to -0.654, + "continue" to -0.6413, + "struct" to -0.64, + "try" to -0.6369, + "float" to -0.6341, + "using" to 0.6079, + "@" to 0.6016, + "|" to 0.5993, + "impl" to 0.5808, + "private" to -0.5746, + "for" to 0.5741, + "2" to -0.5634, + "let" to -0.5187, + "foreach" to 0.5186, + "select" to -0.5148, + "export" to -0.5, + "mut" to -0.4921, + ")" to -0.463, + "]" to -0.4611, + "when" to 0.4602, + "virtual" to -0.4583, + "extern" to -0.4465, + "catch" to 0.4446, + "new" to 0.4394, + "val" to -0.4339, + "map" to 0.4284, + "case" to 0.4271, + "throws" to 0.4221, + "null" to -0.4197, + "protected" to -0.4133, + "q" to 0.4125, + "except" to 0.4115, + ": " to 0.4072, + "^" to -0.407, + " " to 0.4066, + "$" to 0.3981, + "this" to 0.3962, + "switch" to 0.3947, + "*" to -0.3931, + "module" to 0.3912, + "array" to 0.385, + "=" to 0.3828, + "p" to 0.3728, + "ON" to 0.3708, + "`" to 0.3693, + "u" to 0.3658, + "a" to 0.3654, + "require" to 0.3646, + ">" to -0.3644, + "const" to -0.3476, + "o" to 0.3423, + "sizeof" to 0.3416, + "object" to 0.3362, + "w" to 0.3345, + "print" to 0.3344, + "range" to 0.3336, + "if" to 0.3324, + "abstract" to -0.3293, + "var" to -0.3239, + "i" to 0.321, + "while" to 0.3138, + "J" to 0.3137, + "c" to 0.3118, + "await" to -0.3072, + "from" to 0.3057, + "f" to 0.302, + "echo" to 0.2995, + "#" to 0.2984, + "e" to 0.2962, + "r" to 0.2925, + "mod" to 0.2893, + "loop" to 0.2874, + "t" to 0.2832, + "~" to 0.282, + "final" to -0.2816, + "del" to 0.2785, + "override" to -0.2746, + "ref" to -0.2737, + "h" to 0.2693, + "m" to 0.2681, + "{" to 0.2674, + "implements" to 0.2672, + "inline" to -0.2642, + "match" to 0.2613, + "with" to -0.261, + "x" to 0.2597, + "namespace" to -0.2596, + "operator" to 0.2573, + "double" to -0.2563, + "source" to -0.2482, + "import" to -0.2419, + "NULL" to -0.2399, + "l" to 0.239, + "or" to 0.2378, + "s" to 0.2366, + "then" to 0.2354, + "W" to 0.2354, + "y" to 0.2333, + "local" to 0.2288, + "is" to 0.2282, + "n" to 0.2254, + "+" to -0.2251, + "G" to 0.223, + "public" to -0.2229, + "WHERE" to 0.2224, + "list" to 0.2204, + "Q" to 0.2204, + "[" to 0.2136, + "VALUES" to 0.2134, + "H" to 0.2105, + "g" to 0.2094, + "else" to -0.208, + "bool" to -0.2066, + "long" to -0.2059, + "R" to 0.2025, + "S" to 0.2021, + "d" to 0.2003, + "V" to 0.1974, + "K" to -0.1961, + "<" to 0.1958, + "debugger" to -0.1929, + "NOT" to -0.1911, + "b" to 0.1907, + "boolean" to -0.1891, + "z" to -0.1866, + "LIKE" to -0.1793, + "raise" to 0.1782, + "L" to 0.1768, + "fn" to 0.176, + "delete" to 0.1714, + "unsigned" to -0.1675, + "auto" to -0.1648, + "finally" to 0.1616, + "k" to 0.1599, + "as" to 0.156, + "instanceof" to 0.1558, + "&" to 0.1554, + "E" to 0.1551, + "M" to 0.1542, + "I" to 0.1503, + "Y" to 0.1493, + "typeof" to 0.1475, + "j" to 0.1445, + "INTO" to 0.1442, + "IF" to 0.1437, + "next" to 0.1433, + "undef" to -0.1427, + "THEN" to -0.1416, + "v" to 0.1415, + "C" to 0.1383, + "P" to 0.1353, + "AND" to -0.1345, + "constructor" to 0.1337, + "void" to -0.1336, + "class" to -0.1328, + "defer" to 0.1316, + "begin" to 0.1306, + "FROM" to -0.1304, + "SET" to 0.1291, + "decimal" to -0.1278, + "friend" to 0.1277, + "SELECT" to -0.1265, + "event" to 0.1259, + "lambda" to 0.1253, + "enum" to 0.1215, + "A" to 0.121, + "lock" to 0.1187, + "ensure" to 0.1184, + "%" to 0.1177, + "isset" to 0.1175, + "O" to 0.1174, + "." to 0.1146, + "UNION" to -0.1145, + "alias" to -0.1129, + "template" to -0.1102, + "WHEN" to 0.1093, + "rescue" to 0.1083, + "DISTINCT" to -0.1074, + "trait" to -0.1073, + "D" to 0.1062, + "in" to 0.1045, + "internal" to -0.1029, + "," to 0.1027, + "static_cast" to 0.1016, + "do" to -0.1005, + "OR" to 0.1003, + "AS" to -0.1001, + "interface" to 0.0996, + "super" to 0.0989, + "B" to 0.0963, + "U" to 0.0962, + "T" to 0.0943, + "CALL" to -0.0918, + "BETWEEN" to -0.0915, + "N" to 0.0897, + "yield" to 0.0867, + "done" to -0.0857, + "string" to -0.0837, + "out" to -0.0831, + "volatile" to -0.0819, + "retry" to 0.0816, + "?" to -0.0796, + "number" to -0.0791, + "short" to 0.0787, + "sealed" to -0.0776, + "package" to 0.0765, + "OPEN" to -0.0756, + "base" to 0.0735, + "and" to 0.0729, + "exit" to 0.0726, + "_" to 0.0721, + "keyof" to -0.072, + "def" to 0.0713, + "crate" to -0.0706, + "-" to -0.07, + "FUNCTION" to 0.0692, + "declare" to -0.0678, + "include" to 0.0671, + "COUNT" to -0.0669, + "INDEX" to -0.0666, + "CLOSE" to -0.0651, + "fi" to -0.0644, + "uint" to 0.0624, + "params" to 0.0575, + "HAVING" to 0.0575, + "byte" to -0.0575, + "clone" to -0.0552, + "char" to -0.054, + "func" to 0.0538, + "never" to -0.053, + "unset" to -0.0524, + "unless" to -0.051, + "esac" to -0.0509, + "shift" to -0.0507, + "require_once" to 0.0486, + "ELSE" to -0.0477, + "extends" to 0.0461, + "elseif" to 0.0452, + "mutable" to -0.0451, + "asm" to 0.0449, + "!" to 0.0446, + "LIMIT" to 0.0444, + "ushort" to -0.0438, + "\"" to -0.0433, + "Z" to 0.0431, + "exec" to -0.0431, + "IS" to -0.0429, + "DECLARE" to -0.0425, + "__LINE__" to -0.0424, + "BEGIN" to -0.0418, + "typedef" to 0.0414, + "EXIT" to -0.0412, + "'" to 0.041, + "function" to -0.0393, + "dyn" to -0.039, + "wchar_t" to -0.0388, + "unique" to -0.0383, + "include_once" to 0.0367, + "stackalloc" to 0.0359, + "RETURN" to -0.0356, + "const_cast" to 0.035, + "MAX" to 0.0341, + "assert" to -0.0331, + "JOIN" to -0.0328, + "use" to 0.0318, + "GET" to 0.0317, + "VIEW" to 0.0314, + "move" to 0.0308, + "typename" to 0.0308, + "die" to 0.0305, + "asserts" to -0.0304, + "reinterpret_cast" to -0.0302, + "USING" to -0.0289, + "elsif" to -0.0285, + "FIRST" to -0.028, + "self" to -0.0278, + "RETURNING" to -0.0278, + "symbol" to -0.0273, + "OFFSET" to 0.0263, + "bigint" to 0.0253, + "register" to -0.0237, + "union" to -0.0227, + "return" to -0.0227, + "until" to -0.0224, + "endfor" to -0.0213, + "implicit" to -0.021, + "LOOP" to 0.0195, + "pub" to 0.0182, + "global" to 0.0179, + "EXCEPTION" to 0.0175, + "delegate" to 0.0173, + "signed" to -0.0163, + "FOR" to 0.0156, + "unsafe" to 0.014, + "NEXT" to -0.0133, + "IN" to 0.0129, + "MIN" to -0.0123, + "go" to -0.0112, + "type" to -0.0109, + "explicit" to -0.0107, + "eval" to -0.0104, + "int" to -0.0099, + "CASE" to -0.0096, + "END" to 0.0084, + "UPDATE" to 0.0074, + "default" to 0.0072, + "chan" to 0.0068, + "fixed" to 0.0066, + "not" to -0.0052, + "X" to -0.0047, + "endforeach" to 0.0031, + "goto" to 0.0028, + "empty" to 0.0022, + "checked" to 0.0012, + "F" to -0.001 + ) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererFeatureConfigService.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererFeatureConfigService.kt new file mode 100644 index 0000000000..4e023e162f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererFeatureConfigService.kt @@ -0,0 +1,80 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import software.amazon.awssdk.services.codewhispererruntime.model.FeatureValue +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererExpired + +class CodeWhispererFeatureConfigService { + private val featureConfigs = mutableMapOf() + + @RequiresBackgroundThread + fun fetchFeatureConfigs(project: Project) { + if (isCodeWhispererExpired(project)) return + + LOG.debug { "Fetching feature configs" } + try { + val response = CodeWhispererClientAdaptor.getInstance(project).listFeatureEvaluations() + + // Simply force overwrite feature configs from server response, no needed to check existing values. + response.featureEvaluations().forEach { + featureConfigs[it.feature()] = FeatureContext(it.feature(), it.variation(), it.value()) + } + } catch (e: Exception) { + LOG.debug(e) { "Error when fetching feature configs" } + } + LOG.debug { "Current feature configs: ${getFeatureConfigsTelemetry()}" } + } + + fun getFeatureConfigsTelemetry(): String = + "{${featureConfigs.entries.joinToString(", ") { (name, context) -> + "$name: ${context.variation}" + }}}" + + // TODO: for all feature variations, define a contract that can be enforced upon the implementation of + // the business logic. + // When we align on a new feature config, client-side will implement specific business logic to utilize + // these values by: + // 1) Add an entry in FEATURE_DEFINITIONS, which is to . + // 2) Add a function with name `getXXX`, where XXX refers to the feature name. + // 3) Specify the return type: One of the return type String/Boolean/Long/Double should be used here. + // 4) Specify the key for the `getFeatureValueForKey` helper function which is the feature name. + // 5) Specify the corresponding type value getter for the `FeatureValue` class. For example, + // if the return type is Long, then the corresponding type value getter is `longValue()`. + // 6) Add a test case for this feature. + fun getTestFeature(): String = getFeatureValueForKey(TEST_FEATURE_NAME).stringValue() + + // Get the feature value for the given key. + // In case of a misconfiguration, it will return a default feature value of Boolean true. + private fun getFeatureValueForKey(name: String): FeatureValue = + featureConfigs[name]?.value ?: FEATURE_DEFINITIONS[name]?.value + ?: FeatureValue.builder().boolValue(true).build() + + companion object { + fun getInstance(): CodeWhispererFeatureConfigService = service() + private const val TEST_FEATURE_NAME = "testFeature" + private val LOG = getLogger() + + // TODO: add real feature later + internal val FEATURE_DEFINITIONS = mapOf( + TEST_FEATURE_NAME to FeatureContext( + TEST_FEATURE_NAME, + "CONTROL", + FeatureValue.builder().stringValue("testValue").build() + ) + ) + } +} + +data class FeatureContext( + val name: String, + val variation: String, + val value: FeatureValue +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererInvocationStatus.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererInvocationStatus.kt new file mode 100644 index 0000000000..365d5522d0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererInvocationStatus.kt @@ -0,0 +1,102 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.util.messages.Topic +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean + +class CodeWhispererInvocationStatus { + private val isInvokingCodeWhisperer: AtomicBoolean = AtomicBoolean(false) + private var invokingSessionId: String? = null + private var timeAtLastInvocationComplete: Instant? = null + var timeAtLastDocumentChanged: Instant = Instant.now() + private set + private var isPopupActive: Boolean = false + private var timeAtLastInvocationStart: Instant? = null + var popupStartTimestamp: Instant? = null + private set + + fun checkExistingInvocationAndSet(): Boolean = + if (isInvokingCodeWhisperer.getAndSet(true)) { + LOG.debug { "Have existing CodeWhisperer invocation, sessionId: $invokingSessionId" } + true + } else { + ApplicationManager.getApplication().messageBus.syncPublisher(CODEWHISPERER_INVOCATION_STATE_CHANGED).invocationStateChanged(true) + LOG.debug { "Starting CodeWhisperer invocation" } + false + } + + fun hasExistingInvocation(): Boolean = isInvokingCodeWhisperer.get() + + fun finishInvocation() { + if (isInvokingCodeWhisperer.compareAndSet(true, false)) { + ApplicationManager.getApplication().messageBus.syncPublisher(CODEWHISPERER_INVOCATION_STATE_CHANGED).invocationStateChanged(false) + LOG.debug { "Ending CodeWhisperer invocation" } + invokingSessionId = null + } + } + + fun setInvocationComplete() { + timeAtLastInvocationComplete = Instant.now() + } + + fun documentChanged() { + timeAtLastDocumentChanged = Instant.now() + } + + fun setPopupStartTimestamp() { + popupStartTimestamp = Instant.now() + } + + fun getTimeSinceDocumentChanged(): Double { + val timeSinceDocumentChanged = Duration.between(timeAtLastDocumentChanged, Instant.now()) + val timeInDouble = timeSinceDocumentChanged.toMillis().toDouble() + return timeInDouble + } + + fun hasEnoughDelayToShowCodeWhisperer(): Boolean { + val timeCanShowCodeWhisperer = timeAtLastDocumentChanged.plusMillis(CodeWhispererConstants.POPUP_DELAY) + return timeCanShowCodeWhisperer.isBefore(Instant.now()) + } + + fun isPopupActive(): Boolean = isPopupActive + + fun setPopupActive(value: Boolean) { + isPopupActive = value + } + + fun setInvocationStart() { + timeAtLastInvocationStart = Instant.now() + } + + fun setInvocationSessionId(sessionId: String?) { + LOG.debug { "Set current CodeWhisperer invocation sessionId: $sessionId" } + invokingSessionId = sessionId + } + + fun hasEnoughDelayToInvokeCodeWhisperer(): Boolean { + val timeCanShowCodeWhisperer = timeAtLastInvocationStart?.plusMillis(CodeWhispererConstants.INVOCATION_INTERVAL) ?: return true + return timeCanShowCodeWhisperer.isBefore(Instant.now()) + } + + companion object { + private val LOG = getLogger() + fun getInstance(): CodeWhispererInvocationStatus = service() + val CODEWHISPERER_INVOCATION_STATE_CHANGED: Topic = Topic.create( + "CodeWhisperer popup state changed", + CodeWhispererInvocationStateChangeListener::class.java + ) + } +} + +interface CodeWhispererInvocationStateChangeListener { + fun invocationStateChanged(value: Boolean) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererLicenseInfoManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererLicenseInfoManager.kt new file mode 100644 index 0000000000..43904dd025 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererLicenseInfoManager.kt @@ -0,0 +1,28 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.components.service +import software.aws.toolkits.jetbrains.utils.runUnderProgressIfNeeded +import software.aws.toolkits.resources.message + +class CodeWhispererLicenseInfoManager { + private val licenseLinks by lazy { + runUnderProgressIfNeeded(null, message("codewhisperer.loading_licenses"), cancelable = false) { + this.javaClass.getResourceAsStream("/codewhisperer/licenses.json")?.use { resourceStream -> + MAPPER.readValue>(resourceStream) + } ?: throw RuntimeException("CodeWhisperer license info not found") + } + } + + fun getLicenseLink(code: String) = licenseLinks.getOrDefault(code, "") + + companion object { + fun getInstance(): CodeWhispererLicenseInfoManager = service() + private val MAPPER = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererRecommendationManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererRecommendationManager.kt new file mode 100644 index 0000000000..d544a6b129 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererRecommendationManager.kt @@ -0,0 +1,170 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.intellij.openapi.components.service +import software.amazon.awssdk.services.codewhispererruntime.model.Completion +import software.amazon.awssdk.services.codewhispererruntime.model.Span +import software.aws.toolkits.jetbrains.services.codewhisperer.model.DetailContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.RecommendationChunk +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getCompletionType +import kotlin.math.max +import kotlin.math.min + +class CodeWhispererRecommendationManager { + fun reformatReference(requestContext: RequestContext, recommendation: Completion): Completion { + // startOffset is the offset at the start of user input since invocation + val invocationStartOffset = requestContext.caretPosition.offset + + val startOffsetSinceUserInput = requestContext.editor.caretModel.offset + val endOffset = invocationStartOffset + recommendation.content().length + + if (startOffsetSinceUserInput > endOffset) return recommendation + + val reformattedReferences = recommendation.references().filter { + val referenceStart = invocationStartOffset + it.recommendationContentSpan().start() + val referenceEnd = invocationStartOffset + it.recommendationContentSpan().end() + referenceStart < endOffset && referenceEnd > startOffsetSinceUserInput + }.map { + val referenceStart = invocationStartOffset + it.recommendationContentSpan().start() + val referenceEnd = invocationStartOffset + it.recommendationContentSpan().end() + val updatedReferenceStart = max(referenceStart, startOffsetSinceUserInput) + val updatedReferenceEnd = min(referenceEnd, endOffset) + it.toBuilder().recommendationContentSpan( + Span.builder() + .start(updatedReferenceStart - invocationStartOffset) + .end(updatedReferenceEnd - invocationStartOffset) + .build() + ).build() + } + + return Completion.builder() + .content(recommendation.content()) + .references(reformattedReferences) + .build() + } + + fun buildRecommendationChunks( + recommendation: String, + matchingSymbols: List> + ): List = matchingSymbols + .dropLast(1) + .mapIndexed { index, (offset, inlayOffset) -> + val end = matchingSymbols[index + 1].first - 1 + RecommendationChunk(recommendation.substring(offset, end), offset, inlayOffset) + } + + fun buildDetailContext( + requestContext: RequestContext, + userInput: String, + recommendations: List, + requestId: String, + ): List { + val seen = mutableSetOf() + return recommendations.map { + val isDiscardedByUserInput = !it.content().startsWith(userInput) || it.content() == userInput + if (isDiscardedByUserInput) { + return@map DetailContext( + requestId, + it, + it, + isDiscarded = true, + isTruncatedOnRight = false, + rightOverlap = "", + getCompletionType(it) + ) + } + + val overlap = findRightContextOverlap(requestContext, it) + val overlapIndex = it.content().lastIndexOf(overlap) + val truncatedContent = + if (overlap.isNotEmpty() && overlapIndex >= 0) { + it.content().substring(0, overlapIndex) + } else { + it.content() + } + val truncated = it.toBuilder() + .content(truncatedContent) + .build() + val isDiscardedByUserInputForTruncated = !truncated.content().startsWith(userInput) || truncated.content() == userInput + if (isDiscardedByUserInputForTruncated) { + return@map DetailContext( + requestId, + it, + truncated, + isDiscarded = true, + isTruncatedOnRight = true, + rightOverlap = overlap, + getCompletionType(it) + ) + } + + val isDiscardedByRightContextTruncationDedupe = !seen.add(truncated.content()) + val isDiscardedByBlankAfterTruncation = truncated.content().isBlank() + if (isDiscardedByRightContextTruncationDedupe || isDiscardedByBlankAfterTruncation) { + return@map DetailContext( + requestId, + it, + truncated, + isDiscarded = true, + truncated.content().length != it.content().length, + overlap, + getCompletionType(it) + ) + } + val reformatted = reformatReference(requestContext, truncated) + DetailContext( + requestId, + it, + reformatted, + isDiscarded = false, + truncated.content().length != it.content().length, + overlap, + getCompletionType(it) + ) + } + } + + fun findRightContextOverlap( + requestContext: RequestContext, + recommendation: Completion + ): String { + val document = requestContext.editor.document + val caret = requestContext.editor.caretModel.primaryCaret + val rightContext = document.charsSequence.subSequence(caret.offset, document.charsSequence.length).toString() + val recommendationContent = recommendation.content() + val rightContextFirstLine = rightContext.substringBefore("\n") + val overlap = + if (rightContextFirstLine.isEmpty()) { + val tempOverlap = overlap(recommendationContent, rightContext) + if (tempOverlap.isEmpty()) overlap(recommendationContent.trimEnd(), rightContext.trimStart()) else tempOverlap + } else { + // this is necessary to prevent display issue if first line of right context is not empty + var tempOverlap = overlap(recommendationContent, rightContext) + if (tempOverlap.isEmpty()) { + tempOverlap = overlap(recommendationContent.trimEnd(), rightContext.trimStart()) + } + if (recommendationContent.substring(0, recommendationContent.length - tempOverlap.length).none { it == '\n' }) { + tempOverlap + } else { + "" + } + } + return overlap + } + + fun overlap(first: String, second: String): String { + for (i in max(0, first.length - second.length) until first.length) { + val suffix = first.substring(i) + if (second.startsWith(suffix)) { + return suffix + } + } + return "" + } + + companion object { + fun getInstance(): CodeWhispererRecommendationManager = service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt new file mode 100644 index 0000000000..839e348f88 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt @@ -0,0 +1,806 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.intellij.codeInsight.CodeInsightSettings +import com.intellij.codeInsight.hint.HintManager +import com.intellij.notification.NotificationAction +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.VisualPosition +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.util.Disposer +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.messages.Topic +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import software.amazon.awssdk.core.exception.SdkServiceException +import software.amazon.awssdk.core.util.DefaultSdkAutoConstructList +import software.amazon.awssdk.services.codewhisperer.model.CodeWhispererException +import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException +import software.amazon.awssdk.services.codewhispererruntime.model.Completion +import software.amazon.awssdk.services.codewhispererruntime.model.FileContext +import software.amazon.awssdk.services.codewhispererruntime.model.GenerateCompletionsRequest +import software.amazon.awssdk.services.codewhispererruntime.model.GenerateCompletionsResponse +import software.amazon.awssdk.services.codewhispererruntime.model.ProgrammingLanguage +import software.amazon.awssdk.services.codewhispererruntime.model.RecommendationsWithReferencesPreference +import software.amazon.awssdk.services.codewhispererruntime.model.ResourceNotFoundException +import software.amazon.awssdk.services.codewhispererruntime.model.SupplementalContext +import software.amazon.awssdk.services.codewhispererruntime.model.ThrottlingException +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorManager +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.checkLeftContextKeywordsForJsonAndYaml +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.getCaretPosition +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererExpired +import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition +import software.aws.toolkits.jetbrains.services.codewhisperer.model.DetailContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.FileContextInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.LatencyContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.RecommendationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SupplementalContextInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.model.TriggerTypeInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.model.WorkerContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererSettings +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererTelemetryService +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CaretMovement +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.SUPPLEMENTAL_CONTEXT_TIMEOUT +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getCompletionType +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getTelemetryOptOutPreference +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.notifyErrorCodeWhispererUsageLimit +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.promptReAuth +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CrossFileStrategy +import software.aws.toolkits.jetbrains.services.codewhisperer.util.FileContextProvider +import software.aws.toolkits.jetbrains.services.codewhisperer.util.UtgStrategy +import software.aws.toolkits.jetbrains.utils.isInjectedText +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodewhispererCompletionType +import software.aws.toolkits.telemetry.CodewhispererSuggestionState +import software.aws.toolkits.telemetry.CodewhispererTriggerType +import java.util.concurrent.TimeUnit + +class CodeWhispererService { + fun showRecommendationsInPopup( + editor: Editor, + triggerTypeInfo: TriggerTypeInfo, + latencyContext: LatencyContext + ) { + val project = editor.project ?: return + if (!isCodeWhispererEnabled(project)) return + + latencyContext.credentialFetchingStart = System.nanoTime() + + if (promptReAuth(project)) return + + if (isCodeWhispererExpired(project)) return + + latencyContext.credentialFetchingEnd = System.nanoTime() + val psiFile = runReadAction { PsiDocumentManager.getInstance(project).getPsiFile(editor.document) } + + if (psiFile == null) { + LOG.debug { "No PSI file for the current document" } + if (triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + showCodeWhispererInfoHint(editor, message("codewhisperer.trigger.document.unsupported")) + } + return + } + val isInjectedFile = runReadAction { psiFile.isInjectedText() } + if (isInjectedFile) return + + val requestContext = try { + getRequestContext(triggerTypeInfo, editor, project, psiFile, latencyContext) + } catch (e: Exception) { + LOG.debug { e.message.toString() } + CodeWhispererTelemetryService.getInstance().sendFailedServiceInvocationEvent(project, e::class.simpleName) + return + } + + val language = requestContext.fileContextInfo.programmingLanguage + val leftContext = requestContext.fileContextInfo.caretContext.leftFileContext + if (!language.isCodeCompletionSupported() || (checkLeftContextKeywordsForJsonAndYaml(leftContext, language.languageId))) { + LOG.debug { "Programming language $language is not supported by CodeWhisperer" } + if (triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + showCodeWhispererInfoHint( + requestContext.editor, + message("codewhisperer.language.error", psiFile.fileType.name) + ) + } + return + } + + LOG.debug { + "Calling CodeWhisperer service, trigger type: ${triggerTypeInfo.triggerType}" + + if (triggerTypeInfo.triggerType == CodewhispererTriggerType.AutoTrigger) { + ", auto-trigger type: ${triggerTypeInfo.automatedTriggerType}" + } else { + "" + } + } + + val invocationStatus = CodeWhispererInvocationStatus.getInstance() + if (invocationStatus.checkExistingInvocationAndSet()) { + return + } + + invokeCodeWhispererInBackground(requestContext) + } + + private fun invokeCodeWhispererInBackground(requestContext: RequestContext) { + val popup = CodeWhispererPopupManager.getInstance().initPopup() + Disposer.register(popup) { CodeWhispererInvocationStatus.getInstance().finishInvocation() } + + val workerContexts = mutableListOf() + // When popup is disposed we will cancel this coroutine. The only places popup can get disposed should be + // from CodeWhispererPopupManager.cancelPopup() and CodeWhispererPopupManager.closePopup(). + // It's possible and ok that coroutine will keep running until the next time we check it's state. + // As long as we don't show to the user extra info we are good. + val coroutineScope = disposableCoroutineScope(popup) + + var states: InvocationContext? = null + var lastRecommendationIndex = -1 + + val responseIterable = CodeWhispererClientAdaptor.getInstance(requestContext.project).generateCompletionsPaginator( + buildCodeWhispererRequest( + requestContext.fileContextInfo, + requestContext.supplementalContext, + requestContext.customizationArn + ) + ) + coroutineScope.launch { + try { + var startTime = System.nanoTime() + requestContext.latencyContext.codewhispererPreprocessingEnd = System.nanoTime() + requestContext.latencyContext.paginationAllCompletionsStart = System.nanoTime() + CodeWhispererInvocationStatus.getInstance().setInvocationStart() + var requestCount = 0 + for (response in responseIterable) { + requestCount++ + val endTime = System.nanoTime() + val latency = TimeUnit.NANOSECONDS.toMillis(endTime - startTime).toDouble() + startTime = endTime + val requestId = response.responseMetadata().requestId() + val sessionId = response.sdkHttpResponse().headers().getOrDefault(KET_SESSION_ID, listOf(requestId))[0] + if (requestCount == 1) { + requestContext.latencyContext.codewhispererPostprocessingStart = System.nanoTime() + requestContext.latencyContext.paginationFirstCompletionTime = latency + requestContext.latencyContext.firstRequestId = requestId + CodeWhispererInvocationStatus.getInstance().setInvocationSessionId(sessionId) + } + if (response.nextToken().isEmpty()) { + requestContext.latencyContext.paginationAllCompletionsEnd = System.nanoTime() + } + val responseContext = ResponseContext(sessionId) + logServiceInvocation(requestId, requestContext, responseContext, response.completions(), latency, null) + lastRecommendationIndex += response.completions().size + ApplicationManager.getApplication().messageBus.syncPublisher(CODEWHISPERER_CODE_COMPLETION_PERFORMED) + .onSuccess(requestContext.fileContextInfo) + CodeWhispererTelemetryService.getInstance().sendServiceInvocationEvent( + requestId, + requestContext, + responseContext, + lastRecommendationIndex, + true, + latency, + null + ) + + val validatedResponse = validateResponse(response) + + runInEdt { + // If delay is not met, add them to the worker queue and process them later. + // On first response, workers queue must be empty. If there's enough delay before showing, + // process CodeWhisperer UI rendering and workers queue will remain empty throughout this + // CodeWhisperer session. If there's not enough delay before showing, the CodeWhisperer UI rendering task + // will be added to the workers queue. + // On subsequent responses, if they see workers queue is not empty, it means the first worker + // task hasn't been finished yet, in this case simply add another task to the queue. If they + // see worker queue is empty, the previous tasks must have been finished before this. In this + // case render CodeWhisperer UI directly. + val workerContext = WorkerContext(requestContext, responseContext, validatedResponse, popup) + if (workerContexts.isNotEmpty()) { + workerContexts.add(workerContext) + } else { + if (states == null && !popup.isDisposed && + !CodeWhispererInvocationStatus.getInstance().hasEnoughDelayToShowCodeWhisperer() + ) { + // It's the first response, and no enough delay before showing + projectCoroutineScope(requestContext.project).launch { + while (!CodeWhispererInvocationStatus.getInstance().hasEnoughDelayToShowCodeWhisperer()) { + delay(CodeWhispererConstants.POPUP_DELAY_CHECK_INTERVAL) + } + runInEdt { + workerContexts.forEach { + states = processCodeWhispererUI(it, states) + } + workerContexts.clear() + } + } + workerContexts.add(workerContext) + } else { + // Have enough delay before showing for the first response, or it's subsequent responses + states = processCodeWhispererUI(workerContext, states) + } + } + } + if (!isActive) { + // If job is cancelled before we do another request, don't bother making + // another API call to save resources + LOG.debug { "Skipping sending remaining requests on CodeWhisperer session exit" } + break + } + } + } catch (e: Exception) { + val requestId: String + val sessionId: String + val displayMessage: String + + if ( + CodeWhispererConstants.Customization.invalidCustomizationExceptionPredicate(e) || + e is ResourceNotFoundException + ) { + (e as CodeWhispererRuntimeException) + + requestId = e.requestId() ?: "" + sessionId = e.awsErrorDetails().sdkHttpResponse().headers().getOrDefault(KET_SESSION_ID, listOf(requestId))[0] + val exceptionType = e::class.simpleName + val responseContext = ResponseContext(sessionId) + + CodeWhispererTelemetryService.getInstance().sendServiceInvocationEvent( + requestId, + requestContext, + responseContext, + lastRecommendationIndex, + false, + 0.0, + exceptionType + ) + + LOG.debug { + "The provided customization ${requestContext.customizationArn} is not found, " + + "will fallback to the default and retry generate completion" + } + logServiceInvocation(requestId, requestContext, responseContext, emptyList(), null, exceptionType) + + notifyInfo( + title = "", + content = message("codewhisperer.notification.custom.not_available"), + project = requestContext.project, + notificationActions = listOf(NotificationAction.create("Got it") { _, notification -> notification.expire() }) + ) + CodeWhispererInvocationStatus.getInstance().finishInvocation() + CodeWhispererInvocationStatus.getInstance().setInvocationComplete() + + requestContext.customizationArn?.let { CodeWhispererModelConfigurator.getInstance().invalidateCustomization(it) } + + projectCoroutineScope(requestContext.project).launch { + showRecommendationsInPopup( + requestContext.editor, + requestContext.triggerTypeInfo, + requestContext.latencyContext + ) + } + return@launch + } else if (e is CodeWhispererException) { + requestId = e.requestId() ?: "" + sessionId = e.awsErrorDetails().sdkHttpResponse().headers().getOrDefault(KET_SESSION_ID, listOf(requestId))[0] + displayMessage = e.awsErrorDetails().errorMessage() ?: message("codewhisperer.trigger.error.server_side") + } else if (e is software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException) { + requestId = e.requestId() ?: "" + sessionId = e.awsErrorDetails().sdkHttpResponse().headers().getOrDefault(KET_SESSION_ID, listOf(requestId))[0] + displayMessage = e.awsErrorDetails().errorMessage() ?: message("codewhisperer.trigger.error.server_side") + } else { + requestId = "" + sessionId = "" + val statusCode = if (e is SdkServiceException) e.statusCode() else 0 + displayMessage = + if (statusCode >= 500) { + message("codewhisperer.trigger.error.server_side") + } else { + message("codewhisperer.trigger.error.client_side") + } + if (statusCode < 500) { + LOG.debug(e) { "Error invoking CodeWhisperer service" } + } + } + val exceptionType = e::class.simpleName + val responseContext = ResponseContext(sessionId) + CodeWhispererInvocationStatus.getInstance().setInvocationSessionId(sessionId) + logServiceInvocation(requestId, requestContext, responseContext, emptyList(), null, exceptionType) + CodeWhispererTelemetryService.getInstance().sendServiceInvocationEvent( + requestId, + requestContext, + responseContext, + lastRecommendationIndex, + false, + 0.0, + exceptionType + ) + + if (e is ThrottlingException && + e.message == CodeWhispererConstants.THROTTLING_MESSAGE + ) { + CodeWhispererExplorerActionManager.getInstance().setSuspended(requestContext.project) + if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + notifyErrorCodeWhispererUsageLimit(requestContext.project) + } + } else { + if (requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + // We should only show error hint when CodeWhisperer popup is not visible, + // and make it silent if CodeWhisperer popup is showing. + runInEdt { + if (!CodeWhispererInvocationStatus.getInstance().isPopupActive()) { + showCodeWhispererErrorHint(requestContext.editor, displayMessage) + } + } + } + } + CodeWhispererInvocationStatus.getInstance().finishInvocation() + runInEdt { + states?.let { + CodeWhispererPopupManager.getInstance().updatePopupPanel( + it, + CodeWhispererPopupManager.getInstance().sessionContext + ) + } + } + } finally { + CodeWhispererInvocationStatus.getInstance().setInvocationComplete() + } + } + } + + @RequiresEdt + private fun processCodeWhispererUI(workerContext: WorkerContext, currStates: InvocationContext?): InvocationContext? { + val requestContext = workerContext.requestContext + val responseContext = workerContext.responseContext + val response = workerContext.response + val popup = workerContext.popup + val requestId = response.responseMetadata().requestId() + + // At this point when we are in EDT, the state of the popup will be thread-safe + // across this thread execution, so if popup is disposed, we will stop here. + // This extra check is needed because there's a time between when we get the response and + // when we enter the EDT. + if (popup.isDisposed) { + LOG.debug { "Stop showing CodeWhisperer recommendations on CodeWhisperer session exit. RequestId: $requestId" } + return null + } + + if (requestContext.editor.isDisposed) { + LOG.debug { "Stop showing CodeWhisperer recommendations since editor is disposed. RequestId: $requestId" } + CodeWhispererPopupManager.getInstance().cancelPopup(popup) + return null + } + + if (response.nextToken().isEmpty()) { + CodeWhispererInvocationStatus.getInstance().finishInvocation() + } + + val caretMovement = CodeWhispererEditorManager.getInstance().getCaretMovement( + requestContext.editor, + requestContext.caretPosition + ) + val isPopupShowing: Boolean + val nextStates: InvocationContext? + if (currStates == null) { + // first response + nextStates = initStates(requestContext, responseContext, response, caretMovement, popup) + isPopupShowing = false + + // receiving a null state means caret has moved backward or there's a conflict with + // Intellisense popup, so we are going to cancel the job + if (nextStates == null) { + LOG.debug { "Cancelling popup and exiting CodeWhisperer session. RequestId: $requestId" } + CodeWhispererPopupManager.getInstance().cancelPopup(popup) + return null + } + } else { + // subsequent responses + nextStates = updateStates(currStates, response) + isPopupShowing = checkRecommendationsValidity(currStates, false) + } + + val hasAtLeastOneValid = checkRecommendationsValidity(nextStates, response.nextToken().isEmpty()) + + // If there are no recommendations at all in this session, we need to manually send the user decision event here + // since it won't be sent automatically later + if (nextStates.recommendationContext.details.isEmpty() && response.nextToken().isEmpty()) { + LOG.debug { "Received just an empty list from this session, requestId: $requestId" } + CodeWhispererTelemetryService.getInstance().sendUserDecisionEvent( + requestContext, + responseContext, + DetailContext( + requestId, + Completion.builder().build(), + Completion.builder().build(), + false, + false, + "", + CodewhispererCompletionType.Line + ), + -1, + CodewhispererSuggestionState.Empty, + nextStates.recommendationContext.details.size + ) + } + if (!hasAtLeastOneValid) { + if (response.nextToken().isEmpty()) { + LOG.debug { "None of the recommendations are valid, exiting CodeWhisperer session" } + CodeWhispererPopupManager.getInstance().cancelPopup(popup) + return null + } + } else { + updateCodeWhisperer(nextStates, isPopupShowing) + } + return nextStates + } + + private fun initStates( + requestContext: RequestContext, + responseContext: ResponseContext, + response: GenerateCompletionsResponse, + caretMovement: CaretMovement, + popup: JBPopup + ): InvocationContext? { + val requestId = response.responseMetadata().requestId() + val recommendations = response.completions() + val visualPosition = requestContext.editor.caretModel.visualPosition + + if (CodeWhispererPopupManager.getInstance().hasConflictingPopups(requestContext.editor)) { + LOG.debug { "Detect conflicting popup window with CodeWhisperer popup, not showing CodeWhisperer popup" } + sendDiscardedUserDecisionEventForAll(requestContext, responseContext, recommendations) + return null + } + if (caretMovement == CaretMovement.MOVE_BACKWARD) { + LOG.debug { "Caret moved backward, discarding all of the recommendations. Request ID: $requestId" } + sendDiscardedUserDecisionEventForAll(requestContext, responseContext, recommendations) + return null + } + val userInputOriginal = CodeWhispererEditorManager.getInstance().getUserInputSinceInvocation( + requestContext.editor, + requestContext.caretPosition.offset + ) + val userInput = + if (caretMovement == CaretMovement.NO_CHANGE) { + LOG.debug { "Caret position not changed since invocation. Request ID: $requestId" } + "" + } else { + userInputOriginal.trimStart().also { + LOG.debug { + "Caret position moved forward since invocation. Request ID: $requestId, " + + "user input since invocation: $userInputOriginal, " + + "user input without leading spaces: $it" + } + } + } + val detailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( + requestContext, + userInput, + recommendations, + requestId + ) + val recommendationContext = RecommendationContext(detailContexts, userInputOriginal, userInput, visualPosition) + return buildInvocationContext(requestContext, responseContext, recommendationContext, popup) + } + + private fun updateStates( + states: InvocationContext, + response: GenerateCompletionsResponse + ): InvocationContext { + val recommendationContext = states.recommendationContext + val details = recommendationContext.details + val newDetailContexts = CodeWhispererRecommendationManager.getInstance().buildDetailContext( + states.requestContext, + recommendationContext.userInputSinceInvocation, + response.completions(), + response.responseMetadata().requestId() + ) + Disposer.dispose(states) + + val updatedStates = states.copy( + recommendationContext = recommendationContext.copy(details = details + newDetailContexts) + ) + Disposer.register(states.popup, updatedStates) + CodeWhispererPopupManager.getInstance().initPopupListener(updatedStates) + return updatedStates + } + + private fun checkRecommendationsValidity(states: InvocationContext, showHint: Boolean): Boolean { + val details = states.recommendationContext.details + + // set to true when at least one is not discarded or empty + val hasAtLeastOneValid = details.any { !it.isDiscarded && it.recommendation.content().isNotEmpty() } + + if (!hasAtLeastOneValid && showHint && states.requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) { + showCodeWhispererInfoHint( + states.requestContext.editor, + message("codewhisperer.popup.no_recommendations") + ) + } + return hasAtLeastOneValid + } + + private fun updateCodeWhisperer(states: InvocationContext, recommendationAdded: Boolean) { + CodeWhispererPopupManager.getInstance().changeStates(states, 0, "", true, recommendationAdded) + } + + private fun sendDiscardedUserDecisionEventForAll( + requestContext: RequestContext, + responseContext: ResponseContext, + recommendations: List + ) { + val detailContexts = recommendations.map { + DetailContext("", it, it, true, false, "", getCompletionType(it)) + } + val recommendationContext = RecommendationContext(detailContexts, "", "", VisualPosition(0, 0)) + + CodeWhispererTelemetryService.getInstance().sendUserDecisionEventForAll( + requestContext, + responseContext, + recommendationContext, + SessionContext(), + false + ) + } + + fun getRequestContext( + triggerTypeInfo: TriggerTypeInfo, + editor: Editor, + project: Project, + psiFile: PsiFile, + latencyContext: LatencyContext + ): RequestContext { + // 1. file context + val fileContext: FileContextInfo = runReadAction { FileContextProvider.getInstance(project).extractFileContext(editor, psiFile) } + + // the upper bound for supplemental context duration is 50ms + // 2. supplemental context + val startFetchingTimestamp = System.currentTimeMillis() + val isTstFile = FileContextProvider.getInstance(project).isTestFile(psiFile) + val supplementalContext = runBlocking { + try { + withTimeout(SUPPLEMENTAL_CONTEXT_TIMEOUT) { + FileContextProvider.getInstance(project).extractSupplementalFileContext(psiFile, fileContext) + } + } catch (e: Exception) { + if (e is TimeoutCancellationException) { + LOG.debug { + "Supplemental context fetch timed out in ${System.currentTimeMillis() - startFetchingTimestamp}ms" + } + SupplementalContextInfo( + isUtg = isTstFile, + contents = emptyList(), + latency = System.currentTimeMillis() - startFetchingTimestamp, + targetFileName = fileContext.filename, + strategy = if (isTstFile) UtgStrategy.Empty else CrossFileStrategy.Empty + ) + } else { + LOG.debug { "Run into unexpected error when fetching supplemental context, error: ${e.message}" } + null + } + } + } + + // 3. caret position + val caretPosition = runReadAction { getCaretPosition(editor) } + + // 4. connection + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance()) + + // 5. customization + val customizationArn = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.arn + + return RequestContext(project, editor, triggerTypeInfo, caretPosition, fileContext, supplementalContext, connection, latencyContext, customizationArn) + } + + fun validateResponse(response: GenerateCompletionsResponse): GenerateCompletionsResponse { + // If contentSpans in reference are not consistent with content(recommendations), + // remove the incorrect references. + val validatedRecommendations = response.completions().map { + val validReferences = it.hasReferences() && it.references().isNotEmpty() && + it.references().none { reference -> + val span = reference.recommendationContentSpan() + span.start() > span.end() || span.start() < 0 || span.end() > it.content().length + } + if (validReferences) { + it + } else { + it.toBuilder().references(DefaultSdkAutoConstructList.getInstance()).build() + } + } + + return response.toBuilder().completions(validatedRecommendations).build() + } + + private fun buildInvocationContext( + requestContext: RequestContext, + responseContext: ResponseContext, + recommendationContext: RecommendationContext, + popup: JBPopup + ): InvocationContext { + addPopupChildDisposables(popup) + // Creating a disposable for managing all listeners lifecycle attached to the popup. + // previously(before pagination) we use popup as the parent disposable. + // After pagination, listeners need to be updated as states are updated, for the same popup, + // so disposable chain becomes popup -> disposable -> listeners updates, and disposable gets replaced on every + // state update. + val states = InvocationContext(requestContext, responseContext, recommendationContext, popup) + Disposer.register(popup, states) + CodeWhispererPopupManager.getInstance().initPopupListener(states) + return states + } + + private fun addPopupChildDisposables(popup: JBPopup) { + val originalTabExitsBracketsAndQuotes = CodeInsightSettings.getInstance().TAB_EXITS_BRACKETS_AND_QUOTES + CodeInsightSettings.getInstance().TAB_EXITS_BRACKETS_AND_QUOTES = false + Disposer.register(popup) { + CodeInsightSettings.getInstance().TAB_EXITS_BRACKETS_AND_QUOTES = originalTabExitsBracketsAndQuotes + } + val originalAutoPopupCompletionLookup = CodeInsightSettings.getInstance().AUTO_POPUP_COMPLETION_LOOKUP + CodeInsightSettings.getInstance().AUTO_POPUP_COMPLETION_LOOKUP = false + Disposer.register(popup) { + CodeInsightSettings.getInstance().AUTO_POPUP_COMPLETION_LOOKUP = originalAutoPopupCompletionLookup + } + Disposer.register(popup) { + CodeWhispererPopupManager.getInstance().reset() + } + } + + private fun logServiceInvocation( + requestId: String, + requestContext: RequestContext, + responseContext: ResponseContext, + recommendations: List, + latency: Double?, + exceptionType: String? + ) { + val recommendationLogs = recommendations.map { it.content().trimEnd() } + .reduceIndexedOrNull { index, acc, recommendation -> "$acc\n[${index + 1}]\n$recommendation" } + LOG.info { + "SessionId: ${responseContext.sessionId}, " + + "RequestId: $requestId, " + + "Jetbrains IDE: ${ApplicationInfo.getInstance().fullApplicationName}, " + + "IDE version: ${ApplicationInfo.getInstance().apiVersion}, " + + "Filename: ${requestContext.fileContextInfo.filename}, " + + "Left context of current line: ${requestContext.fileContextInfo.caretContext.leftContextOnCurrentLine}, " + + "Cursor line: ${requestContext.caretPosition.line}, " + + "Caret offset: ${requestContext.caretPosition.offset}, " + + (latency?.let { "Latency: $latency, " } ?: "") + + (exceptionType?.let { "Exception Type: $it, " } ?: "") + + "Recommendations: \n${recommendationLogs ?: "None"}" + } + } + + fun canDoInvocation(editor: Editor, type: CodewhispererTriggerType): Boolean { + editor.project?.let { + if (!isCodeWhispererEnabled(it)) { + return false + } + } + + if (type == CodewhispererTriggerType.AutoTrigger && !CodeWhispererExplorerActionManager.getInstance().isAutoEnabled()) { + LOG.debug { "CodeWhisperer auto-trigger is disabled, not invoking service" } + return false + } + + if (CodeWhispererPopupManager.getInstance().hasConflictingPopups(editor)) { + LOG.debug { "Find other active popup windows before triggering CodeWhisperer, not invoking service" } + return false + } + + if (CodeWhispererInvocationStatus.getInstance().isPopupActive()) { + LOG.debug { "Find an existing CodeWhisperer popup window before triggering CodeWhisperer, not invoking service" } + return false + } + return true + } + + fun showCodeWhispererInfoHint(editor: Editor, message: String) { + HintManager.getInstance().showInformationHint(editor, message, HintManager.UNDER) + } + + fun showCodeWhispererErrorHint(editor: Editor, message: String) { + HintManager.getInstance().showErrorHint(editor, message, HintManager.UNDER) + } + + companion object { + private val LOG = getLogger() + val CODEWHISPERER_CODE_COMPLETION_PERFORMED: Topic = Topic.create( + "CodeWhisperer code completion service invoked", + CodeWhispererCodeCompletionServiceListener::class.java + ) + + fun getInstance(): CodeWhispererService = service() + const val KET_SESSION_ID = "x-amzn-SessionId" + private var reAuthPromptShown = false + + fun markReAuthPromptShown() { + reAuthPromptShown = true + } + + fun hasReAuthPromptBeenShown() = reAuthPromptShown + + fun buildCodeWhispererRequest( + fileContextInfo: FileContextInfo, + supplementalContext: SupplementalContextInfo?, + customizationArn: String? + ): GenerateCompletionsRequest { + val programmingLanguage = ProgrammingLanguage.builder() + .languageName(fileContextInfo.programmingLanguage.toCodeWhispererRuntimeLanguage().languageId) + .build() + val fileContext = FileContext.builder() + .leftFileContent(fileContextInfo.caretContext.leftFileContext) + .rightFileContent(fileContextInfo.caretContext.rightFileContext) + .filename(fileContextInfo.filename) + .programmingLanguage(programmingLanguage) + .build() + val supplementalContexts = supplementalContext?.contents?.map { + SupplementalContext.builder() + .content(it.content) + .filePath(it.path) + .build() + }.orEmpty() + val includeCodeWithReference = if (CodeWhispererSettings.getInstance().isIncludeCodeWithReference()) { + RecommendationsWithReferencesPreference.ALLOW + } else { + RecommendationsWithReferencesPreference.BLOCK + } + + return GenerateCompletionsRequest.builder() + .fileContext(fileContext) + .supplementalContexts(supplementalContexts) + .referenceTrackerConfiguration { it.recommendationsWithReferences(includeCodeWithReference) } + .customizationArn(customizationArn) + .optOutPreference(getTelemetryOptOutPreference()) + .build() + } + } +} + +data class RequestContext( + val project: Project, + val editor: Editor, + val triggerTypeInfo: TriggerTypeInfo, + val caretPosition: CaretPosition, + val fileContextInfo: FileContextInfo, + val supplementalContext: SupplementalContextInfo?, + val connection: ToolkitConnection?, + val latencyContext: LatencyContext, + val customizationArn: String? +) + +data class ResponseContext( + val sessionId: String, +) + +interface CodeWhispererCodeCompletionServiceListener { + fun onSuccess(fileContextInfo: FileContextInfo) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererUserGroupSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererUserGroupSettings.kt new file mode 100644 index 0000000000..117491434a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererUserGroupSettings.kt @@ -0,0 +1,135 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.service + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.AwsToolkit +import java.util.concurrent.ConcurrentHashMap +import kotlin.reflect.KClass + +/** + * Component controlling codewhisperer user group settings + */ +@State(name = "codewhispererUserGroupSettings", storages = [Storage("aws.xml")]) +class CodeWhispererUserGroupSettings : PersistentStateComponent { + private var version: String? = null + + private val settings = ConcurrentHashMap() + override fun getState() = CodeWhispererUserGroupStates( + version, + settings + ) + + override fun loadState(state: CodeWhispererUserGroupStates) { + version = state.version + + settings.clear() + settings.putAll(state.settings) + } + + inline fun getGroup(): T? = getGroup(T::class) + + fun getGroup(clazz: KClass): T? { + @Suppress("UNCHECKED_CAST") + return when (clazz) { + CodeWhispererUserGroup::class -> { + tryOrNull { + settings[USER_GROUP_KEY]?.let { + CodeWhispererUserGroup.valueOf(it) + } + } as T? + } + CodeWhispererExpThresholdGroup::class -> { + tryOrNull { + settings[EXP_THRESHOLD_KEY]?.let { + CodeWhispererExpThresholdGroup.valueOf(it) + } + } as T? + } + else -> null + } + } + + fun getUserGroup(): CodeWhispererUserGroup { + if (version != AwsToolkit.PLUGIN_VERSION) { + resetGroupSettings() + } + + return getGroup() ?: determineUserGroup() + } + + fun isExpThreshold(): Boolean { + if (version != AwsToolkit.PLUGIN_VERSION) { + resetGroupSettings() + } + + val group = getGroup() + return (group ?: determineThresholdGroup()) == CodeWhispererExpThresholdGroup.Exp + } + + @VisibleForTesting + fun getVersion() = version + + @VisibleForTesting + fun determineUserGroup(): CodeWhispererUserGroup { + val group = CodeWhispererUserGroup.Control + + settings[USER_GROUP_KEY] = group.name + version = AwsToolkit.PLUGIN_VERSION + + return group + } + + private fun determineThresholdGroup(): CodeWhispererExpThresholdGroup { + val randomNum = Math.random() + val group = if (randomNum < 1 / 2.0) { + CodeWhispererExpThresholdGroup.Control + } else { + CodeWhispererExpThresholdGroup.Exp + } + + settings[EXP_THRESHOLD_KEY] = group.name + version = AwsToolkit.PLUGIN_VERSION + + return group + } + + private fun resetGroupSettings() { + version = null + settings.clear() + } + + companion object { + fun getInstance(): CodeWhispererUserGroupSettings = service() + + // TODO: add into CodeWhispererGroup interface + const val USER_GROUP_KEY = "userGroup" + + const val EXP_THRESHOLD_KEY = "expThreshold" + } +} + +data class CodeWhispererUserGroupStates( + var version: String? = null, + var settings: Map = emptyMap() +) + +interface CodeWhispererGroup + +enum class CodeWhispererUserGroup : CodeWhispererGroup { + Control, + CrossFile, + Classifier, + RightContext, +} + +enum class CodeWhispererExpThresholdGroup : CodeWhispererGroup { + Control, + Exp +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt new file mode 100644 index 0000000000..b0519bab0d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt @@ -0,0 +1,107 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.settings + +import com.intellij.icons.AllIcons +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.project.Project +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.resources.message + +// As the connection is project-level, we need to make this project-level too (we have different config for Sono vs SSO users) +class CodeWhispererConfigurable(private val project: Project) : + BoundConfigurable(message("aws.settings.codewhisperer.configurable.title")), + SearchableConfigurable { + private val codeWhispererSettings + get() = CodeWhispererSettings.getInstance() + + private val isSso: Boolean + get() = CodeWhispererExplorerActionManager.getInstance().checkActiveCodeWhispererConnectionType(project) == CodeWhispererLoginType.SSO + + override fun getId() = "aws.codewhisperer" + + override fun createPanel() = panel { + val connect = project.messageBus.connect(disposable ?: error("disposable wasn't initialized by framework")) + val invoke = isCodeWhispererEnabled(project) + + // TODO: can we remove message bus subscribe and solely use visible(boolean) / enabled(boolean), consider multi project cases + row { + label(message("aws.settings.codewhisperer.warning")).apply { + component.icon = AllIcons.General.Warning + }.apply { + visible(!invoke) + connect.subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + visible(!isCodeWhispererEnabled(project)) + } + } + ) + } + } + + row { + checkBox(message("aws.settings.codewhisperer.include_code_with_reference")).apply { + connect.subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + enabled(isCodeWhispererEnabled(project) && !isSso) + } + } + ) + enabled(invoke && !isSso) + bindSelected(codeWhispererSettings::isIncludeCodeWithReference, codeWhispererSettings::toggleIncludeCodeWithReference) + } + }.rowComment(message("aws.settings.codewhisperer.include_code_with_reference.tooltip")) + + row { + checkBox(message("aws.settings.codewhisperer.automatic_import_adder")).apply { + connect.subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + enabled(isCodeWhispererEnabled(project)) + } + } + ) + enabled(invoke) + bindSelected(codeWhispererSettings::isImportAdderEnabled, codeWhispererSettings::toggleImportAdder) + } + }.rowComment(message("aws.settings.codewhisperer.automatic_import_adder.tooltip")) + + row { + checkBox(message("aws.settings.codewhisperer.configurable.opt_out.title")).apply { + connect.subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + enabled(isCodeWhispererEnabled(project) && !isSso) + } + } + ) + + enabled(invoke && !isSso) + + if (isSso) { + bindSelected({ false }, {}) + } else { + bindSelected(codeWhispererSettings::isMetricOptIn, codeWhispererSettings::toggleMetricOptIn) + } + } + }.rowComment(message("aws.settings.codewhisperer.configurable.opt_out.tooltip")) + + row { + comment(message("aws.settings.codewhisperer.configurable.iam_identity_center.warning")) + }.visible(isSso) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererSettings.kt new file mode 100644 index 0000000000..0b0e97b2b9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererSettings.kt @@ -0,0 +1,65 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.settings + +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.util.xmlb.annotations.Property + +@State(name = "codewhispererSettings", storages = [Storage("aws.xml")]) +class CodeWhispererSettings : PersistentStateComponent { + private val state = CodeWhispererConfiguration() + + fun toggleIncludeCodeWithReference(value: Boolean) { + state.value[CodeWhispererConfigurationType.IsIncludeCodeWithReference] = value + } + + fun isIncludeCodeWithReference() = state.value.getOrDefault( + CodeWhispererConfigurationType.IsIncludeCodeWithReference, + false + ) + + fun toggleImportAdder(value: Boolean) { + state.value[CodeWhispererConfigurationType.IsImportAdderEnabled] = value + } + + fun isImportAdderEnabled() = state.value.getOrDefault( + CodeWhispererConfigurationType.IsImportAdderEnabled, + true + ) + + fun toggleMetricOptIn(value: Boolean) { + state.value[CodeWhispererConfigurationType.OptInSendingMetric] = value + } + + fun isMetricOptIn() = state.value.getOrDefault( + CodeWhispererConfigurationType.OptInSendingMetric, + true + ) + + companion object { + fun getInstance(): CodeWhispererSettings = service() + } + + override fun getState(): CodeWhispererConfiguration = CodeWhispererConfiguration().apply { value.putAll(state.value) } + + override fun loadState(state: CodeWhispererConfiguration) { + this.state.value.clear() + this.state.value.putAll(state.value) + } +} + +class CodeWhispererConfiguration : BaseState() { + @get:Property + val value by map() +} + +enum class CodeWhispererConfigurationType { + IsIncludeCodeWithReference, + OptInSendingMetric, + IsImportAdderEnabled +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/startup/CodeWhispererIntelliSenseAutoTriggerListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/startup/CodeWhispererIntelliSenseAutoTriggerListener.kt new file mode 100644 index 0000000000..3f2c06fce8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/startup/CodeWhispererIntelliSenseAutoTriggerListener.kt @@ -0,0 +1,39 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.startup + +import com.intellij.codeInsight.lookup.Lookup +import com.intellij.codeInsight.lookup.LookupEvent +import com.intellij.codeInsight.lookup.LookupListener +import com.intellij.codeInsight.lookup.LookupManagerListener +import com.intellij.codeInsight.lookup.impl.LookupImpl +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutoTriggerService +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutomatedTriggerType + +object CodeWhispererIntelliSenseAutoTriggerListener : LookupManagerListener { + override fun activeLookupChanged(oldLookup: Lookup?, newLookup: Lookup?) { + if (oldLookup != null || newLookup == null) return + + newLookup.addLookupListener(object : LookupListener { + override fun itemSelected(event: LookupEvent) { + val editor = event.lookup.editor + if (!(event.lookup as LookupImpl).isShown) { + cleanup() + return + } + + // Classifier + CodeWhispererAutoTriggerService.getInstance().tryInvokeAutoTrigger(editor, CodeWhispererAutomatedTriggerType.IntelliSense()) + cleanup() + } + override fun lookupCanceled(event: LookupEvent) { + cleanup() + } + + private fun cleanup() { + newLookup.removeLookupListener(this) + } + }) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/startup/CodeWhispererProjectStartupActivity.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/startup/CodeWhispererProjectStartupActivity.kt new file mode 100644 index 0000000000..abed5caa79 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/startup/CodeWhispererProjectStartupActivity.kt @@ -0,0 +1,131 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.startup + +import com.intellij.codeInsight.lookup.LookupManagerListener +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererExpired +import software.aws.toolkits.jetbrains.services.codewhisperer.importadder.CodeWhispererImportAdderListener +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager.Companion.CODEWHISPERER_USER_ACTION_PERFORMED +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererFeatureConfigService +import software.aws.toolkits.jetbrains.services.codewhisperer.status.CodeWhispererStatusBarManager +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.FEATURE_CONFIG_POLL_INTERVAL_IN_MS +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.notifyErrorAccountless +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.notifyWarnAccountless +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.promptReAuth +import java.time.LocalDateTime +import java.util.Date +import java.util.Timer +import kotlin.concurrent.schedule + +// TODO: add logics to check if we want to remove recommendation suspension date when user open the IDE +class CodeWhispererProjectStartupActivity : StartupActivity.DumbAware { + private var runOnce = false + + /** + * Should be invoked when + * (1) new users accept CodeWhisperer ToS (have to be triggered manually)) + * (2) existing users open the IDE (automatically triggered) + */ + override fun runActivity(project: Project) { + if (!ApplicationManager.getApplication().isUnitTestMode) { + CodeWhispererStatusBarManager.getInstance(project).updateWidget() + } + if (!isCodeWhispererEnabled(project)) return + if (runOnce) return + + // Reconnect CodeWhisperer on startup + promptReAuth(project, isPluginStarting = true) + if (isCodeWhispererExpired(project)) return + + // Init featureConfig job + initFeatureConfigPollingJob(project) + + // install intellsense autotrigger listener, this only need to be executed once + project.messageBus.connect().subscribe(LookupManagerListener.TOPIC, CodeWhispererIntelliSenseAutoTriggerListener) + project.messageBus.connect().subscribe(CODEWHISPERER_USER_ACTION_PERFORMED, CodeWhispererImportAdderListener) + + // show notification to accountless users + showAccountlessNotificationIfNeeded(project) + + runOnce = true + } + + private fun showAccountlessNotificationIfNeeded(project: Project) { + if (CodeWhispererExplorerActionManager.getInstance().checkActiveCodeWhispererConnectionType(project) == CodeWhispererLoginType.Accountless) { + // simply show a notification when user login with Accountless, and it's still supported by CodeWhisperer + if (!isExpired()) { + // don't show warn notification if user selected Don't show again or if notification was shown less than a week ago + if (!timeToShowAccessTokenWarn() || CodeWhispererExplorerActionManager.getInstance().getDoNotShowAgainWarn()) { + return + } + notifyWarnAccountless() + CodeWhispererExplorerActionManager.getInstance().setAccountlessNotificationWarnTimestamp() + + // to handle the case when user open the IDE when Accountless not yet expired but expire soon e.g. 30min etc. + Timer().schedule(CodeWhispererConstants.EXPIRE_DATE) { notifyErrorAndDisableAccountless(project) } + } else { + if (!timeToShowAccessTokenError() || CodeWhispererExplorerActionManager.getInstance().getDoNotShowAgainError()) { + return + } + CodeWhispererExplorerActionManager.getInstance().setAccountlessNotificationErrorTimestamp() + notifyErrorAndDisableAccountless(project) + } + } else if (CodeWhispererExplorerActionManager.getInstance().getAccountlessNullified()) { + if (!timeToShowAccessTokenError() || CodeWhispererExplorerActionManager.getInstance().getDoNotShowAgainError()) { + return + } + CodeWhispererExplorerActionManager.getInstance().setAccountlessNotificationErrorTimestamp() + notifyErrorAndDisableAccountless(project) + } + } + + private fun notifyErrorAndDisableAccountless(project: Project) { + // show an error and deactivate CW when user login with Accountless, and it already expired + notifyErrorAccountless() + CodeWhispererExplorerActionManager.getInstance().nullifyAccountlessCredentialIfNeeded() + invokeLater { project.refreshCwQTree() } + } + + private fun timeToShowAccessTokenWarn(): Boolean { + val lastShown = CodeWhispererExplorerActionManager.getInstance().getAccountlessWarnNotificationTimestamp() + return lastShown?.let { + val parsedLastShown = LocalDateTime.parse(lastShown, CodeWhispererConstants.TIMESTAMP_FORMATTER) + parsedLastShown.plusDays(7) <= LocalDateTime.now() + } ?: true + } + + private fun timeToShowAccessTokenError(): Boolean { + val lastShown = CodeWhispererExplorerActionManager.getInstance().getAccountlessErrorNotificationTimestamp() + return lastShown?.let { + val parsedLastShown = LocalDateTime.parse(lastShown, CodeWhispererConstants.TIMESTAMP_FORMATTER) + parsedLastShown.plusDays(7) <= LocalDateTime.now() + } ?: true + } + + // Start a job that runs every 30 mins + private fun initFeatureConfigPollingJob(project: Project) { + projectCoroutineScope(project).launch { + while (isActive) { + CodeWhispererFeatureConfigService.getInstance().fetchFeatureConfigs(project) + delay(FEATURE_CONFIG_POLL_INTERVAL_IN_MS) + } + } + } +} + +// TODO: do we have time zone issue with Date? +private fun isExpired() = CodeWhispererConstants.EXPIRE_DATE.before(Date()) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/startup/CodeWhispererProjectStartupSettingsListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/startup/CodeWhispererProjectStartupSettingsListener.kt new file mode 100644 index 0000000000..882f91ee9b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/startup/CodeWhispererProjectStartupSettingsListener.kt @@ -0,0 +1,82 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.startup + +import com.intellij.analysis.problemsView.toolWindow.ProblemsView +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ex.ToolWindowManagerListener +import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetsManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.isSono +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.core.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanManager +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomizationListener +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererActivationChangedListener +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererSettings +import software.aws.toolkits.jetbrains.services.codewhisperer.status.CodeWhispererStatusBarWidgetFactory +import software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow.CodeWhispererCodeReferenceManager + +class CodeWhispererProjectStartupSettingsListener(private val project: Project) : + CodeWhispererActivationChangedListener, + ToolWindowManagerListener, + ToolkitConnectionManagerListener, + BearerTokenProviderListener, + CodeWhispererCustomizationListener { + override fun activationChanged(value: Boolean) { + project.service().updateWidget(CodeWhispererStatusBarWidgetFactory::class.java) + CodeWhispererCodeReferenceManager.getInstance(project).toolWindow?.isAvailable = value + if (value) { + CodeWhispererSettings.getInstance().toggleIncludeCodeWithReference(true) + CodeWhispererCodeScanManager.getInstance(project).addCodeScanUI() + } else { + CodeWhispererCodeScanManager.getInstance(project).removeCodeScanUI() + } + } + + override fun toolWindowShown(toolWindow: ToolWindow) { + super.toolWindowShown(toolWindow) + if (toolWindow.id != ProblemsView.ID) return + if (!isCodeWhispererEnabled(project)) return + CodeWhispererCodeScanManager.getInstance(project).addCodeScanUI() + } + + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + // For now we have the assumption that any connection change will include CW + // will need to change if we separate CW connections and CodeCatalyst connections + runInEdt { + CodeWhispererCodeReferenceManager.getInstance(project).toolWindow?.isAvailable = newConnection != null + } + if (newConnection != null) { + CodeWhispererCodeScanManager.getInstance(project).addCodeScanUI() + } else { + CodeWhispererCodeScanManager.getInstance(project).removeCodeScanUI() + } + + ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance())?.let { + // re-check the allowlist status + CodeWhispererModelConfigurator.getInstance().shouldDisplayCustomNode(project, forceUpdate = true) + } + + project.refreshCwQTree() + } + + override fun refreshUi() { + ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance())?.let { curConnection -> + if (curConnection.isSono()) { + return + } + + project.refreshCwQTree() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarManager.kt new file mode 100644 index 0000000000..aa910b8081 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarManager.kt @@ -0,0 +1,65 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.status + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.StatusBarWidgetFactory +import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetSettings +import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetsManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.ConnectionPinningManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.FeatureWithPinnedConnection +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled + +/** + * Manager visibility of CodeWhisperer status bar widget, only display it when CodeWhisperer is connected + */ +@Service(Service.Level.PROJECT) +class CodeWhispererStatusBarManager(private val project: Project) : Disposable { + private val widgetsManager = project.getService(StatusBarWidgetsManager::class.java) + private val settings = ApplicationManager.getApplication().getService(StatusBarWidgetSettings::class.java) + + init { + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + updateWidget() + } + } + ) + + project.messageBus.connect().subscribe( + ConnectionPinningManagerListener.TOPIC, + object : ConnectionPinningManagerListener { + override fun pinnedConnectionChanged(feature: FeatureWithPinnedConnection, newConnection: ToolkitConnection?) { + if (feature !is CodeWhispererConnection) return + updateWidget() + } + } + ) + } + + fun updateWidget() { + ExtensionPointName("com.intellij.statusBarWidgetFactory").extensionList.find { + it.id == CodeWhispererStatusBarWidgetFactory.ID + }?.let { + settings.setEnabled(it, isCodeWhispererEnabled(project)) + widgetsManager.updateWidget(it) + } + } + + override fun dispose() {} + + companion object { + fun getInstance(project: Project): CodeWhispererStatusBarManager = project.service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidget.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidget.kt new file mode 100644 index 0000000000..500ead4a19 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidget.kt @@ -0,0 +1,114 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.status + +import com.intellij.icons.AllIcons +import com.intellij.ide.DataManager +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.ListPopup +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.impl.status.EditorBasedWidget +import com.intellij.ui.AnimatedIcon +import com.intellij.util.Consumer +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomizationListener +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererNodeActionGroup +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererExpired +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStateChangeListener +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStatus +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.reconnectCodeWhisperer +import software.aws.toolkits.resources.message +import java.awt.event.MouseEvent +import javax.swing.Icon + +class CodeWhispererStatusBarWidget(project: Project) : + EditorBasedWidget(project), + StatusBarWidget.MultipleTextValuesPresentation { + + override fun install(statusBar: StatusBar) { + super.install(statusBar) + project.messageBus.connect(this).subscribe( + CodeWhispererInvocationStatus.CODEWHISPERER_INVOCATION_STATE_CHANGED, + object : CodeWhispererInvocationStateChangeListener { + override fun invocationStateChanged(value: Boolean) { + statusBar.updateWidget(ID) + } + } + ) + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun onChange(providerId: String) { + statusBar.updateWidget(ID) + } + } + ) + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + CodeWhispererCustomizationListener.TOPIC, + object : CodeWhispererCustomizationListener { + override fun refreshUi() { + statusBar.updateWidget(ID) + } + } + ) + + ApplicationManager.getApplication().messageBus.connect(this).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + statusBar.updateWidget(ID) + } + } + ) + } + + override fun ID(): String = ID + + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this + + override fun getTooltipText(): String = message("codewhisperer.statusbar.tooltip") + + override fun getClickConsumer(): Consumer? = null + + override fun getPopupStep(): ListPopup? = + if (isCodeWhispererExpired(project)) { + JBPopupFactory.getInstance().createConfirmation(message("codewhisperer.statusbar.popup.title"), { reconnectCodeWhisperer(project) }, 0) + } else { + JBPopupFactory.getInstance().createActionGroupPopup( + "CodeWhisperer", + CodeWhispererNodeActionGroup(), + DataManager.getInstance().getDataContext(myStatusBar?.component), + JBPopupFactory.ActionSelectionAid.MNEMONICS, + false + ) + } + + override fun getSelectedValue(): String = CodeWhispererModelConfigurator.getInstance().activeCustomization(project).let { + if (it == null) { + message("codewhisperer.statusbar.display_name") + } else { + "${message("codewhisperer.statusbar.display_name")} | ${it.name}" + } + } + + override fun getIcon(): Icon = + if (isCodeWhispererExpired(project)) { + AllIcons.General.BalloonWarning + } else if (CodeWhispererInvocationStatus.getInstance().hasExistingInvocation()) { + AnimatedIcon.Default() + } else { + AllIcons.General.InspectionsOK + } + + companion object { + const val ID = "aws.codewhisperer.statusWidget" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidgetFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidgetFactory.kt new file mode 100644 index 0000000000..97f6ac418a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/status/CodeWhispererStatusBarWidgetFactory.kt @@ -0,0 +1,34 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.status + +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.impl.status.widget.StatusBarEditorBasedWidgetFactory +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend +import software.aws.toolkits.resources.message + +class CodeWhispererStatusBarWidgetFactory : StatusBarEditorBasedWidgetFactory() { + override fun getId(): String = ID + + override fun getDisplayName(): String = message("codewhisperer.statusbar.display_name") + + override fun isAvailable(project: Project): Boolean = + !isRunningOnRemoteBackend() && isCodeWhispererEnabled(project) + + override fun createWidget(project: Project): StatusBarWidget = CodeWhispererStatusBarWidget(project) + + override fun disposeWidget(widget: StatusBarWidget) { + Disposer.dispose(widget) + } + + override fun canBeEnabledOn(statusBar: StatusBar) = true + + companion object { + const val ID = "aws.codewhisperer" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererCodeCoverageTracker.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererCodeCoverageTracker.kt new file mode 100644 index 0000000000..d248193042 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererCodeCoverageTracker.kt @@ -0,0 +1,300 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.telemetry + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.RangeMarker +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.refactoring.suggested.range +import com.intellij.util.Alarm +import com.intellij.util.AlarmFactory +import info.debatty.java.stringsimilarity.Levenshtein +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.model.FileContextInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererPopupManager +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererUserActionListener +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererCodeCompletionServiceListener +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererService +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererUserGroupSettings +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.TOTAL_SECONDS_IN_MINUTE +import software.aws.toolkits.jetbrains.services.codewhisperer.util.runIfIdcConnectionOrTelemetryEnabled +import software.aws.toolkits.telemetry.CodewhispererTelemetry +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.roundToInt + +// TODO: reset code coverage calculator on logging out connection? +abstract class CodeWhispererCodeCoverageTracker( + private val project: Project, + private val timeWindowInSec: Long, + private val language: CodeWhispererProgrammingLanguage, + private val rangeMarkers: MutableList, + private val fileToTokens: MutableMap, + private val myServiceInvocationCount: AtomicInteger +) : Disposable { + val percentage: Int? + get() = if (totalTokensSize != 0) calculatePercentage(acceptedTokensSize, totalTokensSize) else null + val acceptedTokensSize: Int + get() = fileToTokens.map { + it.value.acceptedTokens.get() + }.fold(0) { acc, next -> + acc + next + } + val totalTokensSize: Int + get() = fileToTokens.map { + it.value.totalTokens.get() + }.fold(0) { acc, next -> + acc + next + } + val acceptedRecommendationsCount: Int + get() = rangeMarkers.size + val serviceInvocationCount: Int + get() = myServiceInvocationCount.get() + private val isActive: AtomicBoolean = AtomicBoolean(false) + private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this) + private val isShuttingDown = AtomicBoolean(false) + private var startTime: Instant = Instant.now() + + @Synchronized + fun activateTrackerIfNotActive() { + // tracker will only be activated if and only if IsTelemetryEnabled = true && isActive = false + if (!isTelemetryEnabled() || isActive.getAndSet(true)) return + + val conn = ApplicationManager.getApplication().messageBus.connect() + conn.subscribe( + CodeWhispererPopupManager.CODEWHISPERER_USER_ACTION_PERFORMED, + object : CodeWhispererUserActionListener { + override fun afterAccept(states: InvocationContext, sessionContext: SessionContext, rangeMarker: RangeMarker) { + if (states.requestContext.fileContextInfo.programmingLanguage != language) return + rangeMarkers.add(rangeMarker) + val originalRecommendation = extractRangeMarkerString(rangeMarker) ?: return + rangeMarker.putUserData(KEY_REMAINING_RECOMMENDATION, originalRecommendation) + } + } + ) + + conn.subscribe( + CodeWhispererService.CODEWHISPERER_CODE_COMPLETION_PERFORMED, + object : CodeWhispererCodeCompletionServiceListener { + override fun onSuccess(fileContextInfo: FileContextInfo) { + if (language == fileContextInfo.programmingLanguage) { + myServiceInvocationCount.getAndIncrement() + } + } + } + ) + startTime = Instant.now() + scheduleCodeWhispererCodeCoverageTracker() + } + + fun isTrackerActive() = isActive.get() + + internal fun documentChanged(event: DocumentEvent) { + // When open a file for the first time, IDE will also emit DocumentEvent for loading with `isWholeTextReplaced = true` + // Added this condition to filter out those events + if (event.isWholeTextReplaced) { + LOG.debug { "event with isWholeTextReplaced flag: $event" } + if (event.oldTimeStamp == 0L) return + } + // This case capture IDE reformatting the document, which will be blank string + if (isDocumentEventFromReformatting(event)) return + + // Don't capture deletion events + if (event.newLength <= event.oldLength) return + incrementTotalTokens(event.document, event.newLength - event.oldLength) + } + + internal fun extractRangeMarkerString(rangeMarker: RangeMarker): String? = runReadAction { + rangeMarker.range?.let { myRange -> rangeMarker.document.getText(myRange) } + } + + // With edit distance, complicate usermodification can be considered as simple edit(add, delete, replace), + // and thus the unmodified part of recommendation length can be deducted/approximated + // ex. (modified > original): originalRecom: foo -> modifiedRecom: fobarbarbaro, distance = 9, delta = 12 - 9 = 3 + // ex. (modified == original): originalRecom: helloworld -> modifiedRecom: HelloWorld, distance = 2, delta = 10 - 2 = 8 + // ex. (modified < original): originalRecom: CodeWhisperer -> modifiedRecom: CODE, distance = 12, delta = 13 - 12 = 1 + internal fun getAcceptedTokensDelta(originalRecommendation: String, modifiedRecommendation: String): Int { + val editDistance = getEditDistance(modifiedRecommendation, originalRecommendation).toInt() + return maxOf(originalRecommendation.length, modifiedRecommendation.length) - editDistance + } + + protected open fun getEditDistance(modifiedString: String, originalString: String): Double = + levenshteinChecker.distance(modifiedString, originalString) + + private fun flush() { + try { + if (isTelemetryEnabled()) emitCodeWhispererCodeContribution() + } finally { + reset() + scheduleCodeWhispererCodeCoverageTracker() + } + } + + private fun scheduleCodeWhispererCodeCoverageTracker() { + if (!alarm.isDisposed && !isShuttingDown.get()) { + alarm.addRequest({ flush() }, Duration.ofSeconds(timeWindowInSec).toMillis()) + } + } + + private fun incrementAcceptedTokens(document: Document, delta: Int) { + var tokens = fileToTokens[document] + if (tokens == null) { + tokens = CodeCoverageTokens() + fileToTokens[document] = tokens + } + tokens.acceptedTokens.addAndGet(delta) + } + + private fun incrementTotalTokens(document: Document, delta: Int) { + var tokens = fileToTokens[document] + if (tokens == null) { + tokens = CodeCoverageTokens() + fileToTokens[document] = tokens + } + tokens.apply { + totalTokens.addAndGet(delta) + if (totalTokens.get() < 0) totalTokens.set(0) + } + } + + private fun isDocumentEventFromReformatting(event: DocumentEvent): Boolean = + (event.newFragment.toString().isBlank() && event.oldFragment.toString().isBlank()) && (event.oldLength == 0 || event.newLength == 0) + + private fun reset() { + startTime = Instant.now() + rangeMarkers.clear() + fileToTokens.clear() + myServiceInvocationCount.set(0) + } + + internal fun emitCodeWhispererCodeContribution() { + // If the user is inactive, don't emit the telemetry + if (percentage == null) return + rangeMarkers.forEach { rangeMarker -> + if (!rangeMarker.isValid) return@forEach + // if users add more code upon the recommendation generated from CodeWhisperer, we consider those added part as userToken but not CwsprTokens + val originalRecommendation = rangeMarker.getUserData(KEY_REMAINING_RECOMMENDATION) + val modifiedRecommendation = extractRangeMarkerString(rangeMarker) + if (originalRecommendation == null || modifiedRecommendation == null) { + LOG.debug { + "failed to get accepted recommendation. " + + "OriginalRecommendation is null: ${originalRecommendation == null}; " + + "ModifiedRecommendation is null: ${modifiedRecommendation == null}" + } + return@forEach + } + val delta = getAcceptedTokensDelta(originalRecommendation, modifiedRecommendation) + runReadAction { + incrementAcceptedTokens(rangeMarker.document, delta) + } + } + val customizationArn: String? = CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.arn + + runIfIdcConnectionOrTelemetryEnabled(project) { + try { + val response = CodeWhispererClientAdaptor.getInstance(project).sendCodePercentageTelemetry( + language, + customizationArn, + acceptedTokensSize, + totalTokensSize + ) + LOG.debug { "Successfully sent code percentage telemetry. RequestId: ${response.responseMetadata().requestId()}" } + } catch (e: Exception) { + val requestId = if (e is CodeWhispererRuntimeException) e.requestId() else null + LOG.debug { + "Failed to send code percentage telemetry. RequestId: $requestId, ErrorMessage: ${e.message}" + } + } + } + + // percentage == null means totalTokens == 0 and users are not editing the document, thus we shouldn't emit telemetry for this + percentage?.let { percentage -> + CodewhispererTelemetry.codePercentage( + project = null, + acceptedTokensSize, + language.toTelemetryType(), + percentage, + totalTokensSize, + successCount = myServiceInvocationCount.get(), + codewhispererCustomizationArn = customizationArn, + codewhispererUserGroup = CodeWhispererUserGroupSettings.getInstance().getUserGroup().name + ) + } + } + + @TestOnly + fun forceTrackerFlush() { + alarm.drainRequestsInTest() + } + + @TestOnly + fun activeRequestCount() = alarm.activeRequestCount + + override fun dispose() { + if (isShuttingDown.getAndSet(true)) { + return + } + flush() + } + + companion object { + @JvmStatic + protected val levenshteinChecker = Levenshtein() + private const val REMAINING_RECOMMENDATION = "remainingRecommendation" + private val KEY_REMAINING_RECOMMENDATION = Key(REMAINING_RECOMMENDATION) + private val LOG = getLogger() + private val instances: MutableMap = mutableMapOf() + + fun calculatePercentage(acceptedTokens: Int, totalTokens: Int): Int = ((acceptedTokens.toDouble() * 100) / totalTokens).roundToInt() + fun getInstance(project: Project, language: CodeWhispererProgrammingLanguage): CodeWhispererCodeCoverageTracker = + when (val instance = instances[language]) { + null -> { + val newTracker = DefaultCodeWhispererCodeCoverageTracker(project, language) + instances[language] = newTracker + newTracker + } + else -> instance + } + + @TestOnly + fun getInstancesMap(): MutableMap { + assert(ApplicationManager.getApplication().isUnitTestMode) + return instances + } + } +} + +class DefaultCodeWhispererCodeCoverageTracker(project: Project, language: CodeWhispererProgrammingLanguage) : CodeWhispererCodeCoverageTracker( + project, + 5 * TOTAL_SECONDS_IN_MINUTE, + language, + mutableListOf(), + mutableMapOf(), + AtomicInteger(0) +) + +class CodeCoverageTokens(totalTokens: Int = 0, acceptedTokens: Int = 0) { + val totalTokens: AtomicInteger + val acceptedTokens: AtomicInteger + + init { + this.totalTokens = AtomicInteger(totalTokens) + this.acceptedTokens = AtomicInteger(acceptedTokens) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryService.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryService.kt new file mode 100644 index 0000000000..475768c1b5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryService.kt @@ -0,0 +1,534 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.telemetry + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.RangeMarker +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import kotlinx.coroutines.launch +import org.apache.commons.collections4.queue.CircularFifoQueue +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.services.codewhisperer.codescan.CodeWhispererCodeScanIssue +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CodeScanTelemetryEvent +import software.aws.toolkits.jetbrains.services.codewhisperer.model.DetailContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.RecommendationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContext +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutoTriggerService +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererAutomatedTriggerType +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererFeatureConfigService +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererInvocationStatus +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererUserGroupSettings +import software.aws.toolkits.jetbrains.services.codewhisperer.service.RequestContext +import software.aws.toolkits.jetbrains.services.codewhisperer.service.ResponseContext +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererSettings +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getConnectionStartUrl +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getGettingStartedTaskType +import software.aws.toolkits.jetbrains.services.codewhisperer.util.runIfIdcConnectionOrTelemetryEnabled +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.telemetry.CodewhispererCompletionType +import software.aws.toolkits.telemetry.CodewhispererGettingStartedTask +import software.aws.toolkits.telemetry.CodewhispererLanguage +import software.aws.toolkits.telemetry.CodewhispererPreviousSuggestionState +import software.aws.toolkits.telemetry.CodewhispererSuggestionState +import software.aws.toolkits.telemetry.CodewhispererTelemetry +import software.aws.toolkits.telemetry.CodewhispererTriggerType +import software.aws.toolkits.telemetry.Component +import software.aws.toolkits.telemetry.Result +import java.time.Duration +import java.time.Instant +import java.util.Queue + +class CodeWhispererTelemetryService { + // store previous 5 userTrigger decisions + private val previousUserTriggerDecisions = CircularFifoQueue(5) + + private var previousUserTriggerDecisionTimestamp: Instant? = null + + private val codewhispererTimeSinceLastUserDecision: Double? + get() { + return previousUserTriggerDecisionTimestamp?.let { + Duration.between(it, Instant.now()).toMillis().toDouble() + } + } + + val previousUserTriggerDecision: CodewhispererPreviousSuggestionState? + get() = if (previousUserTriggerDecisions.isNotEmpty()) previousUserTriggerDecisions.last() else null + + companion object { + fun getInstance(): CodeWhispererTelemetryService = service() + val LOG = getLogger() + const val NO_ACCEPTED_INDEX = -1 + } + + fun sendFailedServiceInvocationEvent(project: Project, exceptionType: String?) { + CodewhispererTelemetry.serviceInvocation( + project = project, + codewhispererCursorOffset = 0, + codewhispererLanguage = CodewhispererLanguage.Unknown, + codewhispererLastSuggestionIndex = -1, + codewhispererLineNumber = 0, + codewhispererTriggerType = CodewhispererTriggerType.Unknown, + duration = 0.0, + reason = exceptionType, + success = false, + codewhispererUserGroup = CodeWhispererUserGroupSettings.getInstance().getUserGroup().name + ) + } + + fun sendServiceInvocationEvent( + requestId: String, + requestContext: RequestContext, + responseContext: ResponseContext, + lastRecommendationIndex: Int, + invocationSuccess: Boolean, + latency: Double, + exceptionType: String? + ) { + val (triggerType, automatedTriggerType) = requestContext.triggerTypeInfo + val (offset, line) = requestContext.caretPosition + + // since python now only supports UTG but not cross file context + val supContext = if (requestContext.fileContextInfo.programmingLanguage.isUTGSupported() && + requestContext.supplementalContext?.isUtg == true + ) { + requestContext.supplementalContext + } else if (requestContext.fileContextInfo.programmingLanguage.isSupplementalContextSupported() && + requestContext.supplementalContext?.isUtg == false + ) { + requestContext.supplementalContext + } else { + null + } + + val codewhispererLanguage = requestContext.fileContextInfo.programmingLanguage.toTelemetryType() + val startUrl = getConnectionStartUrl(requestContext.connection) + CodewhispererTelemetry.serviceInvocation( + project = requestContext.project, + codewhispererAutomatedTriggerType = automatedTriggerType.telemetryType, + codewhispererCompletionType = CodewhispererCompletionType.Line, + codewhispererCursorOffset = offset, + codewhispererGettingStartedTask = getGettingStartedTaskType(requestContext.editor), + codewhispererLanguage = codewhispererLanguage, + codewhispererLastSuggestionIndex = lastRecommendationIndex, + codewhispererLineNumber = line, + codewhispererRequestId = requestId, + codewhispererSessionId = responseContext.sessionId, + codewhispererTriggerType = triggerType, + duration = latency, + reason = exceptionType, + success = invocationSuccess, + credentialStartUrl = startUrl, + codewhispererImportRecommendationEnabled = CodeWhispererSettings.getInstance().isImportAdderEnabled(), + codewhispererSupplementalContextTimeout = supContext?.isProcessTimeout, + codewhispererSupplementalContextIsUtg = supContext?.isUtg, + codewhispererSupplementalContextLatency = supContext?.latency?.toDouble(), + codewhispererSupplementalContextLength = supContext?.contentLength, + codewhispererCustomizationArn = requestContext.customizationArn, + codewhispererUserGroup = CodeWhispererUserGroupSettings.getInstance().getUserGroup().name + ) + } + + fun sendUserDecisionEvent( + requestContext: RequestContext, + responseContext: ResponseContext, + detailContext: DetailContext, + index: Int, + suggestionState: CodewhispererSuggestionState, + numOfRecommendations: Int + ) { + val requestId = detailContext.requestId + val recommendation = detailContext.recommendation + val (project, _, triggerTypeInfo) = requestContext + val codewhispererLanguage = requestContext.fileContextInfo.programmingLanguage.toTelemetryType() + val supplementalContext = requestContext.supplementalContext + + LOG.debug { + "Recording user decisions of recommendation. " + + "Index: $index, " + + "State: $suggestionState, " + + "Request ID: $requestId, " + + "Recommendation: ${recommendation.content()}" + } + val startUrl = getConnectionStartUrl(requestContext.connection) + val importEnabled = CodeWhispererSettings.getInstance().isImportAdderEnabled() + CodewhispererTelemetry.userDecision( + project = project, + codewhispererCompletionType = detailContext.completionType, + codewhispererGettingStartedTask = getGettingStartedTaskType(requestContext.editor), + codewhispererLanguage = codewhispererLanguage, + codewhispererPaginationProgress = numOfRecommendations, + codewhispererRequestId = requestId, + codewhispererSessionId = responseContext.sessionId, + codewhispererSuggestionIndex = index, + codewhispererSuggestionReferenceCount = recommendation.references().size, + codewhispererSuggestionReferences = jacksonObjectMapper().writeValueAsString(recommendation.references().map { it.licenseName() }.toSet().toList()), + codewhispererSuggestionImportCount = if (importEnabled) recommendation.mostRelevantMissingImports().size else null, + codewhispererSuggestionState = suggestionState, + codewhispererTriggerType = triggerTypeInfo.triggerType, + credentialStartUrl = startUrl, + codewhispererSupplementalContextIsUtg = supplementalContext?.isUtg, + codewhispererSupplementalContextLength = supplementalContext?.contentLength, + codewhispererSupplementalContextTimeout = supplementalContext?.isProcessTimeout, + codewhispererUserGroup = CodeWhispererUserGroupSettings.getInstance().getUserGroup().name + ) + } + + fun sendUserTriggerDecisionEvent( + requestContext: RequestContext, + responseContext: ResponseContext, + recommendationContext: RecommendationContext, + suggestionState: CodewhispererSuggestionState, + popupShownTime: Duration?, + suggestionReferenceCount: Int, + generatedLineCount: Int, + acceptedCharCount: Int + ) { + val project = requestContext.project + val totalImportCount = recommendationContext.details.fold(0) { grandTotal, detail -> + grandTotal + detail.recommendation.mostRelevantMissingImports().size + } + + val automatedTriggerType = requestContext.triggerTypeInfo.automatedTriggerType + val triggerChar = if (automatedTriggerType is CodeWhispererAutomatedTriggerType.SpecialChar) { + automatedTriggerType.specialChar.toString() + } else { + null + } + + val language = requestContext.fileContextInfo.programmingLanguage + + val classifierResult = requestContext.triggerTypeInfo.automatedTriggerType.calculationResult + + val classifierThreshold = CodeWhispererAutoTriggerService.getThreshold() + + val supplementalContext = requestContext.supplementalContext + val completionType = if (recommendationContext.details.isEmpty()) CodewhispererCompletionType.Line else recommendationContext.details[0].completionType + + // only send if it's a pro tier user + projectCoroutineScope(project).launch { + runIfIdcConnectionOrTelemetryEnabled(project) { + try { + val response = CodeWhispererClientAdaptor.getInstance(project) + .sendUserTriggerDecisionTelemetry( + requestContext, + responseContext, + completionType, + suggestionState, + suggestionReferenceCount, + generatedLineCount, + recommendationContext.details.size + ) + LOG.debug { + "Successfully sent user trigger decision telemetry. RequestId: ${response.responseMetadata().requestId()}" + } + } catch (e: Exception) { + val requestId = if (e is CodeWhispererRuntimeException) e.requestId() else null + LOG.debug { + "Failed to send user trigger decision telemetry. RequestId: $requestId, ErrorMessage: ${e.message}" + } + } + } + } + + CodewhispererTelemetry.userTriggerDecision( + project = project, + codewhispererSessionId = responseContext.sessionId, + codewhispererFirstRequestId = requestContext.latencyContext.firstRequestId, + credentialStartUrl = getConnectionStartUrl(requestContext.connection), + codewhispererIsPartialAcceptance = null, + codewhispererPartialAcceptanceCount = null, + codewhispererCharactersAccepted = acceptedCharCount, + codewhispererCharactersRecommended = null, + codewhispererCompletionType = completionType, + codewhispererLanguage = language.toTelemetryType(), + codewhispererTriggerType = requestContext.triggerTypeInfo.triggerType, + codewhispererAutomatedTriggerType = automatedTriggerType.telemetryType, + codewhispererLineNumber = requestContext.caretPosition.line, + codewhispererCursorOffset = requestContext.caretPosition.offset, + codewhispererSuggestionCount = recommendationContext.details.size, + codewhispererSuggestionImportCount = totalImportCount, + codewhispererTotalShownTime = popupShownTime?.toMillis()?.toDouble(), + codewhispererTriggerCharacter = triggerChar, + codewhispererTypeaheadLength = recommendationContext.userInputSinceInvocation.length, + codewhispererTimeSinceLastDocumentChange = CodeWhispererInvocationStatus.getInstance().getTimeSinceDocumentChanged(), + codewhispererTimeSinceLastUserDecision = codewhispererTimeSinceLastUserDecision, + codewhispererTimeToFirstRecommendation = requestContext.latencyContext.paginationFirstCompletionTime, + codewhispererPreviousSuggestionState = previousUserTriggerDecision, + codewhispererSuggestionState = suggestionState, + codewhispererClassifierResult = classifierResult, + codewhispererClassifierThreshold = classifierThreshold, + codewhispererCustomizationArn = requestContext.customizationArn, + codewhispererSupplementalContextIsUtg = supplementalContext?.isUtg, + codewhispererSupplementalContextLength = supplementalContext?.contentLength, + codewhispererSupplementalContextTimeout = supplementalContext?.isProcessTimeout, + codewhispererSupplementalContextStrategyId = supplementalContext?.strategy.toString(), + codewhispererUserGroup = CodeWhispererUserGroupSettings.getInstance().getUserGroup().name, + codewhispererGettingStartedTask = getGettingStartedTaskType(requestContext.editor), + codewhispererFeatureEvaluations = CodeWhispererFeatureConfigService.getInstance().getFeatureConfigsTelemetry() + ) + } + + fun sendSecurityScanEvent(codeScanEvent: CodeScanTelemetryEvent, project: Project? = null) { + val payloadContext = codeScanEvent.codeScanResponseContext.payloadContext + val serviceInvocationContext = codeScanEvent.codeScanResponseContext.serviceInvocationContext + val codeScanJobId = codeScanEvent.codeScanResponseContext.codeScanJobId + val totalIssues = codeScanEvent.codeScanResponseContext.codeScanTotalIssues + val issuesWithFixes = codeScanEvent.codeScanResponseContext.codeScanIssuesWithFixes + val reason = codeScanEvent.codeScanResponseContext.reason + val startUrl = getConnectionStartUrl(codeScanEvent.connection) + + LOG.debug { + "Recording code security scan event. \n" + + "Total number of security scan issues found: $totalIssues, \n" + + "Number of security scan issues with fixes: $issuesWithFixes, \n" + + "Language: ${payloadContext.language}, \n" + + "Uncompressed source payload size in bytes: ${payloadContext.srcPayloadSize}, \n" + + "Uncompressed build payload size in bytes: ${payloadContext.buildPayloadSize}, \n" + + "Compressed source zip file size in bytes: ${payloadContext.srcZipFileSize}, \n" + + "Total project size in bytes: ${codeScanEvent.totalProjectSizeInBytes}, \n" + + "Total duration of the security scan job in milliseconds: ${codeScanEvent.duration}, \n" + + "Context truncation duration in milliseconds: ${payloadContext.totalTimeInMilliseconds}, \n" + + "Artifacts upload duration in milliseconds: ${serviceInvocationContext.artifactsUploadDuration}, \n" + + "Service invocation duration in milliseconds: ${serviceInvocationContext.serviceInvocationDuration}, \n" + + "Total number of lines scanned: ${payloadContext.totalLines}, \n" + + "Reason: $reason \n" + } + CodewhispererTelemetry.securityScan( + project = project, + codewhispererCodeScanLines = payloadContext.totalLines.toInt(), + codewhispererCodeScanJobId = codeScanJobId, + codewhispererCodeScanProjectBytes = codeScanEvent.totalProjectSizeInBytes, + codewhispererCodeScanSrcPayloadBytes = payloadContext.srcPayloadSize.toInt(), + codewhispererCodeScanBuildPayloadBytes = payloadContext.buildPayloadSize?.toInt(), + codewhispererCodeScanSrcZipFileBytes = payloadContext.srcZipFileSize.toInt(), + codewhispererCodeScanTotalIssues = totalIssues, + codewhispererCodeScanIssuesWithFixes = issuesWithFixes, + codewhispererLanguage = payloadContext.language, + duration = codeScanEvent.duration, + contextTruncationDuration = payloadContext.totalTimeInMilliseconds.toInt(), + artifactsUploadDuration = serviceInvocationContext.artifactsUploadDuration.toInt(), + codeScanServiceInvocationsDuration = serviceInvocationContext.serviceInvocationDuration.toInt(), + reason = reason, + result = codeScanEvent.result, + credentialStartUrl = startUrl + ) + } + + fun sendCodeScanIssueHoverEvent(issue: CodeWhispererCodeScanIssue) { + CodewhispererTelemetry.codeScanIssueHover( + findingId = issue.findingId, + detectorId = issue.detectorId, + ruleId = issue.ruleId + ) + } + + fun sendCodeScanIssueApplyFixEvent(issue: CodeWhispererCodeScanIssue, result: Result, reason: String? = null) { + CodewhispererTelemetry.codeScanIssueApplyFix( + findingId = issue.findingId, + detectorId = issue.detectorId, + ruleId = issue.ruleId, + component = Component.Hover, + result = result, + reason = reason + ) + } + + fun enqueueAcceptedSuggestionEntry( + requestId: String, + requestContext: RequestContext, + responseContext: ResponseContext, + time: Instant, + vFile: VirtualFile?, + range: RangeMarker, + suggestion: String, + selectedIndex: Int, + completionType: CodewhispererCompletionType + ) { + val codewhispererLanguage = requestContext.fileContextInfo.programmingLanguage.toTelemetryType() + CodeWhispererUserModificationTracker.getInstance(requestContext.project).enqueue( + AcceptedSuggestionEntry( + time, vFile, range, suggestion, responseContext.sessionId, requestId, selectedIndex, + requestContext.triggerTypeInfo.triggerType, completionType, + codewhispererLanguage, null, null, + requestContext.connection + ) + ) + } + + fun sendUserDecisionEventForAll( + requestContext: RequestContext, + responseContext: ResponseContext, + recommendationContext: RecommendationContext, + sessionContext: SessionContext, + hasUserAccepted: Boolean, + popupShownTime: Duration? = null + ) { + val detailContexts = recommendationContext.details + val decisions = mutableListOf() + + detailContexts.forEachIndexed { index, detailContext -> + val suggestionState = recordSuggestionState( + index, + sessionContext.selectedIndex, + sessionContext.seen.contains(index), + hasUserAccepted, + detailContext.isDiscarded, + detailContext.recommendation.content().isEmpty() + ) + sendUserDecisionEvent(requestContext, responseContext, detailContext, index, suggestionState, detailContexts.size) + + decisions.add(suggestionState) + } + + with(aggregateUserDecision(decisions)) { + // the order of the following matters + // step 1, send out current decision + previousUserTriggerDecisionTimestamp = Instant.now() + + val referenceCount = if (hasUserAccepted && detailContexts[sessionContext.selectedIndex].recommendation.hasReferences()) 1 else 0 + val acceptedContent = + if (hasUserAccepted) { + detailContexts[sessionContext.selectedIndex].recommendation.content() + } else { + "" + } + val generatedLineCount = if (acceptedContent.isEmpty()) 0 else acceptedContent.split("\n").size + val acceptedCharCount = acceptedContent.length + sendUserTriggerDecisionEvent( + requestContext, + responseContext, + recommendationContext, + CodewhispererSuggestionState.from(this.toString()), + popupShownTime, + referenceCount, + generatedLineCount, + acceptedCharCount + ) + + // step 2, put current decision into queue for later reference + previousUserTriggerDecisions.add(this) + // we need this as well because AutotriggerService will reset the queue periodically + CodeWhispererAutoTriggerService.getInstance().addPreviousDecision(this) + } + } + + /** + * Aggregate recommendation level user decision to trigger level user decision based on the following rule + * - Accept if there is an Accept + * - Reject if there is a Reject + * - Empty if all decisions are Empty + * - Record the accepted suggestion index + * - Discard otherwise + */ + fun aggregateUserDecision(decisions: List): CodewhispererPreviousSuggestionState { + var isEmpty = true + + for (decision in decisions) { + if (decision == CodewhispererSuggestionState.Accept) { + return CodewhispererPreviousSuggestionState.Accept + } else if (decision == CodewhispererSuggestionState.Reject) { + return CodewhispererPreviousSuggestionState.Reject + } else if (decision != CodewhispererSuggestionState.Empty) { + isEmpty = false + } + } + + return if (isEmpty) { + CodewhispererPreviousSuggestionState.Empty + } else CodewhispererPreviousSuggestionState.Discard + } + + fun sendPerceivedLatencyEvent( + requestId: String, + requestContext: RequestContext, + responseContext: ResponseContext, + latency: Double, + ) { + val (project, _, triggerTypeInfo) = requestContext + val codewhispererLanguage = requestContext.fileContextInfo.programmingLanguage.toTelemetryType() + val startUrl = getConnectionStartUrl(requestContext.connection) + CodewhispererTelemetry.perceivedLatency( + project = project, + codewhispererCompletionType = CodewhispererCompletionType.Line, + codewhispererLanguage = codewhispererLanguage, + codewhispererRequestId = requestId, + codewhispererSessionId = responseContext.sessionId, + codewhispererTriggerType = triggerTypeInfo.triggerType, + duration = latency, + passive = true, + credentialStartUrl = startUrl, + codewhispererUserGroup = CodeWhispererUserGroupSettings.getInstance().getUserGroup().name + ) + } + + fun sendClientComponentLatencyEvent(states: InvocationContext) { + val requestContext = states.requestContext + val responseContext = states.responseContext + val codewhispererLanguage = requestContext.fileContextInfo.programmingLanguage.toTelemetryType() + val startUrl = getConnectionStartUrl(requestContext.connection) + CodewhispererTelemetry.clientComponentLatency( + project = requestContext.project, + codewhispererSessionId = responseContext.sessionId, + codewhispererRequestId = requestContext.latencyContext.firstRequestId, + codewhispererFirstCompletionLatency = requestContext.latencyContext.paginationFirstCompletionTime, + codewhispererPreprocessingLatency = requestContext.latencyContext.getCodeWhispererPreprocessingLatency(), + codewhispererEndToEndLatency = requestContext.latencyContext.getCodeWhispererEndToEndLatency(), + codewhispererAllCompletionsLatency = requestContext.latencyContext.getCodeWhispererAllCompletionsLatency(), + codewhispererPostprocessingLatency = requestContext.latencyContext.getCodeWhispererPostprocessingLatency(), + codewhispererCredentialFetchingLatency = requestContext.latencyContext.getCodeWhispererCredentialFetchingLatency(), + codewhispererTriggerType = requestContext.triggerTypeInfo.triggerType, + codewhispererCompletionType = CodewhispererCompletionType.Line, + codewhispererLanguage = codewhispererLanguage, + credentialStartUrl = startUrl, + codewhispererUserGroup = CodeWhispererUserGroupSettings.getInstance().getUserGroup().name + ) + } + + fun sendOnboardingClickEvent(language: CodeWhispererProgrammingLanguage, taskType: CodewhispererGettingStartedTask) { + // Project instance is not needed. We look at these metrics for each clientId. + CodewhispererTelemetry.onboardingClick(project = null, language.toTelemetryType(), taskType) + } + + fun recordSuggestionState( + index: Int, + selectedIndex: Int, + hasSeen: Boolean, + hasUserAccepted: Boolean, + isDiscarded: Boolean, + isEmpty: Boolean + ): CodewhispererSuggestionState = + if (isEmpty) { + CodewhispererSuggestionState.Empty + } else if (isDiscarded) { + CodewhispererSuggestionState.Discard + } else if (!hasSeen) { + CodewhispererSuggestionState.Unseen + } else if (hasUserAccepted) { + if (selectedIndex == index) { + CodewhispererSuggestionState.Accept + } else { + CodewhispererSuggestionState.Ignore + } + } else { + CodewhispererSuggestionState.Reject + } + + @TestOnly + fun previousDecisions(): Queue { + assert(ApplicationManager.getApplication().isUnitTestMode) + return this.previousUserTriggerDecisions + } +} + +fun isTelemetryEnabled(): Boolean = AwsSettings.getInstance().isTelemetryEnabled diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererUserModificationTracker.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererUserModificationTracker.kt new file mode 100644 index 0000000000..40a1d71b31 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererUserModificationTracker.kt @@ -0,0 +1,272 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.telemetry + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.RangeMarker +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.Alarm +import com.intellij.util.AlarmFactory +import info.debatty.java.stringsimilarity.Levenshtein +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererUserGroupSettings +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.getConnectionStartUrl +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.InsertedCodeModificationEntry +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.telemetry.AmazonqTelemetry +import software.aws.toolkits.telemetry.CodewhispererCompletionType +import software.aws.toolkits.telemetry.CodewhispererLanguage +import software.aws.toolkits.telemetry.CodewhispererRuntime +import software.aws.toolkits.telemetry.CodewhispererTelemetry +import software.aws.toolkits.telemetry.CodewhispererTriggerType +import java.time.Duration +import java.time.Instant +import java.util.concurrent.LinkedBlockingDeque +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.min + +interface UserModificationTrackingEntry { + val time: Instant +} + +data class AcceptedSuggestionEntry( + override val time: Instant, + val vFile: VirtualFile?, + val range: RangeMarker, + val suggestion: String, + val sessionId: String, + val requestId: String, + val index: Int, + val triggerType: CodewhispererTriggerType, + val completionType: CodewhispererCompletionType, + val codewhispererLanguage: CodewhispererLanguage, + val codewhispererRuntime: CodewhispererRuntime?, + val codewhispererRuntimeSource: String?, + val connection: ToolkitConnection? +) : UserModificationTrackingEntry + +class CodeWhispererUserModificationTracker(private val project: Project) : Disposable { + private val acceptedSuggestions = LinkedBlockingDeque(DEFAULT_MAX_QUEUE_SIZE) + private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this) + + private val isShuttingDown = AtomicBoolean(false) + + init { + scheduleCodeWhispererTracker() + } + + private fun scheduleCodeWhispererTracker() { + if (!alarm.isDisposed && !isShuttingDown.get()) { + alarm.addRequest({ flush() }, DEFAULT_CHECK_INTERVAL.toMillis()) + } + } + + private fun isTelemetryEnabled(): Boolean = AwsSettings.getInstance().isTelemetryEnabled + + fun enqueue(event: UserModificationTrackingEntry) { + if (!isTelemetryEnabled()) { + return + } + + acceptedSuggestions.add(event) + LOG.debug { "Enqueue Accepted Suggestion on line $event.lineNumber in $event.filePath" } + } + + private fun flush() { + try { + if (!isTelemetryEnabled()) { + acceptedSuggestions.clear() + return + } + + val copyList = LinkedBlockingDeque() + + val currentTime = Instant.now() + for (acceptedSuggestion in acceptedSuggestions) { + if (Duration.between(acceptedSuggestion.time, currentTime).seconds > DEFAULT_MODIFICATION_INTERVAL_IN_SECONDS) { + LOG.debug { "Passed $DEFAULT_MODIFICATION_INTERVAL_IN_SECONDS for $acceptedSuggestion" } + when (acceptedSuggestion) { + is AcceptedSuggestionEntry -> emitTelemetryOnSuggestion(acceptedSuggestion) + is InsertedCodeModificationEntry -> emitTelemetryOnChatCodeInsert(acceptedSuggestion) + else -> {} + } + } else { + copyList.add(acceptedSuggestion) + } + } + + acceptedSuggestions.clear() + acceptedSuggestions.addAll(copyList) + } finally { + scheduleCodeWhispererTracker() + } + } + + private fun emitTelemetryOnChatCodeInsert(insertedCode: InsertedCodeModificationEntry) { + try { + val file = insertedCode.vFile + if (file == null || (!file.isValid)) throw Exception("Record OnChatCodeInsert - invalid file") + + val document = runReadAction { + FileDocumentManager.getInstance().getDocument(file) + } + val currentString = document?.getText( + TextRange(insertedCode.range.startOffset, insertedCode.range.endOffset) + ) + val modificationPercentage = checkDiff(currentString?.trim(), insertedCode.originalString.trim()) + sendModificationWithChatTelemetry(insertedCode, modificationPercentage) + } catch (e: Exception) { + sendModificationWithChatTelemetry(insertedCode, 1.0) + } + } + + private fun emitTelemetryOnSuggestion(acceptedSuggestion: AcceptedSuggestionEntry) { + val file = acceptedSuggestion.vFile + + if (file == null || (!file.isValid)) { + sendModificationTelemetry(acceptedSuggestion, 1.0) + // temp remove event sent as further discussion needed for metric calculation + // sendUserModificationTelemetryToServiceAPI(acceptedSuggestion, 1.0) + } else { + try { + /** + * this try-catch is to check if the offsets are valid since the method does not return null + */ + val document = runReadAction { + FileDocumentManager.getInstance().getDocument(file) + } + val currentString = document?.getText( + TextRange(acceptedSuggestion.range.startOffset, acceptedSuggestion.range.endOffset) + ) + val modificationPercentage = checkDiff(currentString?.trim(), acceptedSuggestion.suggestion.trim()) + sendModificationTelemetry(acceptedSuggestion, modificationPercentage) + // temp remove event sent as further discussion needed for metric calculation + // sendUserModificationTelemetryToServiceAPI(acceptedSuggestion, modificationPercentage) + } catch (e: Exception) { + sendModificationTelemetry(acceptedSuggestion, 1.0) + // temp remove event sent as further discussion needed for metric calculation + // sendUserModificationTelemetryToServiceAPI(acceptedSuggestion, 1.0) + } + } + } + + /** + * Use Levenshtein distance to check how + * Levenshtein distance was preferred over Jaro–Winkler distance for simplicity + */ + private fun checkDiff(currString: String?, acceptedString: String?): Double { + if (currString == null || acceptedString == null || acceptedString.isEmpty() || currString.isEmpty()) { + return 1.0 + } + + val diff = checker.distance(currString, acceptedString) + val percentage = diff / acceptedString.length + + return min(1.0, percentage) + } + + private fun sendModificationTelemetry(suggestion: AcceptedSuggestionEntry, percentage: Double) { + LOG.debug { "Sending user modification telemetry. Request Id: ${suggestion.requestId}" } + val startUrl = getConnectionStartUrl(suggestion.connection) + CodewhispererTelemetry.userModification( + project = project, + codewhispererCompletionType = suggestion.completionType, + codewhispererLanguage = suggestion.codewhispererLanguage, + codewhispererModificationPercentage = percentage, + codewhispererRequestId = suggestion.requestId, + codewhispererRuntime = suggestion.codewhispererRuntime, + codewhispererRuntimeSource = suggestion.codewhispererRuntimeSource, + codewhispererSessionId = suggestion.sessionId, + codewhispererSuggestionIndex = suggestion.index, + codewhispererTriggerType = suggestion.triggerType, + credentialStartUrl = startUrl, + codewhispererUserGroup = CodeWhispererUserGroupSettings.getInstance().getUserGroup().name + ) + } + + private fun sendModificationWithChatTelemetry(insertedCode: InsertedCodeModificationEntry, percentage: Double) { + AmazonqTelemetry.modifyCode( + cwsprChatConversationId = insertedCode.conversationId, + cwsprChatMessageId = insertedCode.messageId, + cwsprChatModificationPercentage = percentage + ) + + val metadata: Map = mapOf( + "cwsprChatConversationId" to insertedCode.conversationId, + "cwsprChatMessageId" to insertedCode.messageId, + "cwsprChatModificationPercentage" to percentage + ) + CodeWhispererClientAdaptor.getInstance(project).sendMetricDataTelemetry("amazonq_modifyCode", metadata) + } + +// temp disable user modfication event for further discussion on metric calculation +// private fun sendUserModificationTelemetryToServiceAPI( +// suggestion: AcceptedSuggestionEntry, +// modificationPercentage: Double +// ) { +// calculateIfIamIdentityCenterConnection(project) { +// val response = try { +// CodeWhispererClientAdaptor.getInstance(project) +// .sendUserModificationTelemetry( +// suggestion.sessionId, +// suggestion.requestId, +// suggestion.vFile?.let { CodeWhispererLanguageManager.getInstance().getLanguage(suggestion.vFile) }, +// CodeWhispererModelConfigurator.getInstance().activeCustomization(project)?.arn.orEmpty(), +// modificationPercentage +// ) +// } catch (e: Exception) { +// when (e) { +// is CodeWhispererRuntimeException -> { +// LOG.info(e) { +// "Failed to send code scan telemetry with Code Whisperer Runtime Exception" +// } +// } +// +// is CodeWhispererException -> { +// LOG.info(e) { +// "Failed to send code scan telemetry with Code Whisperer Exception" +// } +// } +// +// else -> { +// LOG.info(e) { "Failed to send user modification telemetry." } +// } +// } +// null +// } +// +// response?.let { +// LOG.debug { "Successfully sent user modification telemetry. RequestId: ${it.responseMetadata().requestId()}" } +// } +// } +// } + + companion object { + private val DEFAULT_CHECK_INTERVAL = Duration.ofMinutes(1) + private const val DEFAULT_MAX_QUEUE_SIZE = 10000 + private const val DEFAULT_MODIFICATION_INTERVAL_IN_SECONDS = 300 // 5 minutes + + private val checker = Levenshtein() + + private val LOG = getLogger() + + fun getInstance(project: Project) = project.service() + } + + override fun dispose() { + if (isShuttingDown.getAndSet(true)) { + return + } + + flush() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceActionListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceActionListener.kt new file mode 100644 index 0000000000..9576c002e0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceActionListener.kt @@ -0,0 +1,18 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow + +import com.intellij.openapi.editor.RangeMarker +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SessionContext +import software.aws.toolkits.jetbrains.services.codewhisperer.popup.CodeWhispererUserActionListener + +class CodeWhispererCodeReferenceActionListener : CodeWhispererUserActionListener { + override fun afterAccept(states: InvocationContext, sessionContext: SessionContext, rangeMarker: RangeMarker) { + val (project, editor) = states.requestContext + val manager = CodeWhispererCodeReferenceManager.getInstance(project) + manager.insertCodeReference(states, sessionContext.selectedIndex) + manager.addListeners(editor) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceComponents.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceComponents.kt new file mode 100644 index 0000000000..087095f3ae --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceComponents.kt @@ -0,0 +1,157 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.ui.components.ActionLink +import software.amazon.awssdk.services.codewhispererruntime.model.Reference +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.addHorizontalGlue +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.addVerticalGlue +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.horizontalPanelConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.inlineLabelConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererLicenseInfoManager +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererConfigurable +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.TOOLWINDOW_BACKGROUND +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.TOOLWINDOW_CODE +import software.aws.toolkits.resources.message +import java.awt.Font +import java.awt.GridBagLayout +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import javax.swing.BorderFactory +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel + +class CodeWhispererCodeReferenceComponents(private val project: Project) { + private val settingsLabelPrefixText = JLabel().apply { + text = message("codewhisperer.toolwindow.settings.prefix") + }.asCodeReferencePanelFont() + + private val settingsLabelLink = ActionLink().apply { + text = message("codewhisperer.toolwindow.settings") + addActionListener { + ShowSettingsUtil.getInstance().showSettingsDialog(project, CodeWhispererConfigurable::class.java) + } + }.asCodeReferencePanelFont() + + private val settingsPanel = JPanel(GridBagLayout()).apply { + background = TOOLWINDOW_BACKGROUND + border = BorderFactory.createEmptyBorder(0, 0, 17, 0) + add(settingsLabelPrefixText, inlineLabelConstraints) + add(settingsLabelLink, inlineLabelConstraints) + add(JLabel("."), inlineLabelConstraints) + addHorizontalGlue() + } + val contentPanel = JPanel(GridBagLayout()).apply { + background = TOOLWINDOW_BACKGROUND + border = BorderFactory.createEmptyBorder(7, 14, 0, 0) + add(settingsPanel, horizontalPanelConstraints) + addVerticalGlue() + } + + private val codeReferenceTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") + private val acceptRecommendationPrefixText + get() = JLabel().apply { + text = message("codewhisperer.toolwindow.entry.prefix", LocalTime.now().format(codeReferenceTimeFormatter)) + }.asCodeReferencePanelFont() + + init { + repaint(project) + + // set the reference panel text different for SSO users vs AWS Builder ID / Accless users + project.messageBus.connect().subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + repaint(project) + } + } + ) + } + + // TODO: figure out how to have a different view for SSO user in a cleaner way, maybe have 2 sets of components stored in [ReferenceManager]? + private fun repaint(project: Project) { + val loginType = CodeWhispererExplorerActionManager.getInstance().checkActiveCodeWhispererConnectionType(project) + settingsLabelPrefixText as JLabel + settingsLabelLink as ActionLink + if (loginType == CodeWhispererLoginType.SSO) { + settingsLabelPrefixText.text = message("codewhisperer.toolwindow.settings.prefix_sso") + settingsLabelLink.isVisible = false + } else { + settingsLabelPrefixText.text = message("codewhisperer.toolwindow.settings.prefix") + settingsLabelLink.isVisible = true + } + } + + private fun licenseNameLink(licenseName: String) = ActionLink(licenseName) { + BrowserUtil.browse(CodeWhispererLicenseInfoManager.getInstance().getLicenseLink(licenseName)) + }.asCodeReferencePanelFont() + + private fun repoNameLink(repo: String, url: String) = ActionLink(repo) { + BrowserUtil.browse(url) + }.asCodeReferencePanelFont() + + private fun acceptRecommendationSuffixText(path: String?, line: String) = JLabel().apply { + val choice = if (path != null) 1 else 0 + text = message("codewhisperer.toolwindow.entry.suffix", path ?: "", choice, line) + }.asCodeReferencePanelFont() + + fun codeReferenceRecordPanel(ref: Reference, relativePath: String?, lineNums: String) = JPanel(GridBagLayout()).apply { + background = EditorColorsManager.getInstance().globalScheme.defaultBackground + border = BorderFactory.createEmptyBorder(5, 0, 0, 0) + add(acceptRecommendationPrefixText, inlineLabelConstraints) + + // if url to source package/repo is missing, the UX remains the same as we have for now + // if url to source package/repo is present, the url pointing to the source will be present and remove the hyperlink to SPDX + if (ref.url().isNullOrEmpty()) { + add( + licenseNameLink(ref.licenseName()).apply { + font = font.deriveFont(Font.ITALIC + Font.BOLD) + }, + inlineLabelConstraints + ) + add(JLabel(" from ").asCodeReferencePanelFont(), inlineLabelConstraints) + add(JLabel(ref.repository()), inlineLabelConstraints) + } else { + add( + JLabel(ref.licenseName()).apply { + font = font.deriveFont(Font.ITALIC + Font.BOLD) + }, + inlineLabelConstraints + ) + add(JLabel(" from ").asCodeReferencePanelFont(), inlineLabelConstraints) + add(repoNameLink(ref.repository(), ref.url()), inlineLabelConstraints) + } + + add(acceptRecommendationSuffixText(relativePath, lineNums), inlineLabelConstraints) + addHorizontalGlue() + } + + fun codeContentLine(line: String) = JLabel(line).apply { + foreground = TOOLWINDOW_CODE + }.asCodeReferencePanelFont() + + fun codeContentPanel(line: String) = JPanel(GridBagLayout()).apply { + background = EditorColorsManager.getInstance().globalScheme.defaultBackground + if (line == "") { + add(codeContentLine(" "), inlineLabelConstraints) + } else { + add(codeContentLine(line), inlineLabelConstraints) + } + addHorizontalGlue() + } + + private fun JComponent.asCodeReferencePanelFont(): JComponent { + font = Font("JetBrains mono", font.style, font.size) + return this + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceManager.kt new file mode 100644 index 0000000000..5c3a82a917 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceManager.kt @@ -0,0 +1,258 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow + +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.colors.EditorColorsListener +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.event.EditorMouseEvent +import com.intellij.openapi.editor.event.EditorMouseEventArea +import com.intellij.openapi.editor.event.EditorMouseMotionListener +import com.intellij.openapi.editor.ex.RangeHighlighterEx +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.awt.RelativePoint +import software.amazon.awssdk.services.codewhispererruntime.model.Completion +import software.amazon.awssdk.services.codewhispererruntime.model.Reference +import software.amazon.awssdk.services.codewhispererruntime.model.Span +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.getPopupPositionAboveText +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil.getRelativePathToContentRoot +import software.aws.toolkits.jetbrains.services.codewhisperer.layout.CodeWhispererLayoutConfig.horizontalPanelConstraints +import software.aws.toolkits.jetbrains.services.codewhisperer.model.CaretPosition +import software.aws.toolkits.jetbrains.services.codewhisperer.model.InvocationContext +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererColorUtil.EDITOR_CODE_REFERENCE_HOVER +import software.aws.toolkits.resources.message +import javax.swing.JLabel +import javax.swing.JPanel + +class CodeWhispererCodeReferenceManager(private val project: Project) { + val codeReferenceComponents = CodeWhispererCodeReferenceComponents(project) + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(CodeWhispererCodeReferenceToolWindowFactory.id) + val highlighters = mutableListOf() + var currentHighLightPopupContext: CodeReferenceHighLightPopupContext? = null + private val referenceTextAttribute = TextAttributes().apply { + effectColor = EDITOR_CODE_REFERENCE_HOVER + } + + init { + // Listen for global scheme changes + project.messageBus.connect().subscribe( + EditorColorsManager.TOPIC, + EditorColorsListener { scheme -> + if (scheme == null) return@EditorColorsListener + codeReferenceComponents.apply { + contentPanel.background = scheme.defaultBackground + contentPanel.components.forEach { + it.background = scheme.defaultBackground + } + } + } + ) + } + + fun showCodeReferencePanel() { + toolWindow?.show() + } + + fun insertCodeReference(originalCode: String, references: List, editor: Editor, caretPosition: CaretPosition, detail: Completion?) { + val startOffset = caretPosition.offset + val relativePath = getRelativePathToContentRoot(editor) + references.forEachIndexed { i, reference -> + val start = startOffset + reference.recommendationContentSpan().start() + val end = startOffset + reference.recommendationContentSpan().end() + val lineNums = getReferenceLineNums(editor, start, end) + + // There is an unformatted recommendation(directly from response) and reformatted one. We want to get + // the line number, start/end offset of the reformatted one because it's the one inserted to the editor. + // However, the one that shows in the tool window record should show the original recommendation, as below. + val originalContentLines = if (detail != null) { + getOriginalContentLines(detail, i) + } else { + getOriginalContentLines(originalCode, reference.recommendationContentSpan()) + } + + codeReferenceComponents.contentPanel.apply { + add( + codeReferenceComponents.codeReferenceRecordPanel(reference, relativePath, lineNums), + horizontalPanelConstraints, + components.size - 1 + ) + + // add each line of the original reference a JPanel to the tool window content panel + originalContentLines.forEach { line -> + if (line.isEmpty()) return@forEach + add( + codeReferenceComponents.codeContentPanel(line), + horizontalPanelConstraints, + components.size - 1 + ) + } + } + + insertHighLightContext(editor, start, end, reference) + } + } + + fun insertCodeReference(states: InvocationContext, selectedIndex: Int) { + val (requestContext, _, recommendationContext) = states + val (_, editor, _, caretPosition) = requestContext + val (_, detail, reformattedDetail) = recommendationContext.details[selectedIndex] + insertCodeReference(detail.content(), reformattedDetail.references(), editor, caretPosition, detail) + } + + fun getReferenceLineNums(editor: Editor, start: Int, end: Int): String { + val startLine = editor.document.getLineNumber(start) + val endLine = editor.document.getLineNumber(end) + val lineNums = if (startLine == endLine) { + (startLine + 1).toString() + } else { + "${startLine + 1} to ${endLine + 1}" + } + return lineNums + } + + fun getOriginalContentLines(detail: Completion, i: Int): List { + val originalSpan = detail.references()[i].recommendationContentSpan() + return getOriginalContentLines(detail.content(), originalSpan) + } + + private fun getOriginalContentLines(originalCode: String, originalSpan: Span): List = + originalCode + .substring(originalSpan.start(), originalSpan.end()) + .split("\n") + + private fun insertHighLightContext(editor: Editor, start: Int, end: Int, reference: Reference) { + val codeContent = editor.document.getText(TextRange.create(start, end)) + val referenceContent = message( + "codewhisperer.toolwindow.popup.text", + reference.licenseName(), + reference.repository() + ) + val highlighter = editor.markupModel.addRangeHighlighter( + start, + end, + HighlighterLayer.LAST + 1, + null, + HighlighterTargetArea.EXACT_RANGE + ) as RangeHighlighterEx + highlighters.add(CodeReferenceHighLightContext(editor, highlighter, codeContent, referenceContent)) + } + + private fun showPopup() { + currentHighLightPopupContext?.let { + val (context, popup) = it + val (editor, highlighter) = context + val point = getPopupPositionAboveText(editor, popup, highlighter.startOffset) + popup.show(RelativePoint(point)) + highlighter.textAttributes = referenceTextAttribute + } + } + + private fun hidePopup() { + currentHighLightPopupContext?.let { + val (context, popup) = it + popup.cancel() + context.highlighter.textAttributes = null + } + currentHighLightPopupContext = null + } + + fun addListeners(editor: Editor) { + // If there already is a listener attached to the editor, don't add a duplicated one + if (editor.getUserData(listenerDisposable) != null) { + return + } + editor.addEditorMouseMotionListener( + object : EditorMouseMotionListener { + override fun mouseMoved(e: EditorMouseEvent) { + // If not hover on text, hide any highlight if applicable and exit + if (e.area != EditorMouseEventArea.EDITING_AREA || !e.isOverText) { + hidePopup() + return + } + currentHighLightPopupContext?.let { + // There's an active highlight in the editor + val (localEditor, highlighter, codeContent) = it.context + + // Check if mouse is in the range and the content matches the original one, else hide + // this highlight + if (highlighter.contains(e.offset)) { + if (!isHighlighterRangeMatchCodeContent(localEditor, highlighter, codeContent)) { + hidePopup() + } + return + } + hidePopup() + } + + // Remove invalid highlighters when we are about to iterate them + val toRemove = highlighters.filter { !it.highlighter.isValid } + toRemove.forEach { + highlighters.remove(it) + } + + // No highlight should be visible at this point, we still need to find if mouse is hover on any text + // with references + highlighters.forEach { + val (localEditor, highlighter, codeContent, referenceContent) = it + if (highlighter.contains(e.offset)) { + if (!isHighlighterRangeMatchCodeContent(localEditor, highlighter, codeContent)) { + return + } + + // find a valid range to highlight, show the highlight and popup, no need to check others + val popup = JBPopupFactory.getInstance().createComponentPopupBuilder( + JPanel().apply { + add(JLabel(referenceContent)) + }, + null + ).createPopup() + currentHighLightPopupContext = CodeReferenceHighLightPopupContext(it, popup) + showPopup() + return + } + } + } + }, + (editor as EditorImpl).disposable + ) + editor.putUserData(listenerDisposable, true) + } + + private fun RangeHighlighterEx.contains(offset: Int) = this.startOffset <= offset && offset < this.endOffset + + private fun isHighlighterRangeMatchCodeContent( + editor: Editor, + highlighter: RangeHighlighterEx, + codeContent: String + ): Boolean = + highlighter.isValid && highlighter.endOffset <= editor.document.textLength && + editor.document.getText(TextRange.create(highlighter.startOffset, highlighter.endOffset)) == codeContent + + companion object { + fun getInstance(project: Project): CodeWhispererCodeReferenceManager = project.service() + private val listenerDisposable = Key.create("codewhisperer.reference.listener.disposable") + } +} + +data class CodeReferenceHighLightContext( + val editor: Editor, + val highlighter: RangeHighlighterEx, + val codeContent: String, + val referenceContent: String +) + +data class CodeReferenceHighLightPopupContext( + val context: CodeReferenceHighLightContext, + val popup: JBPopup +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceToolWindowFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceToolWindowFactory.kt new file mode 100644 index 0000000000..a0a8d59ee2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/toolwindow/CodeWhispererCodeReferenceToolWindowFactory.kt @@ -0,0 +1,31 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow + +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.ui.components.JBScrollPane +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend + +class CodeWhispererCodeReferenceToolWindowFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val toolWindowContent = toolWindow.contentManager.factory.createContent( + JBScrollPane(CodeWhispererCodeReferenceManager.getInstance(project).codeReferenceComponents.contentPanel, 20, 30), + null, + false + ) + + toolWindowContent.isCloseable = false + toolWindow.contentManager.addContent(toolWindowContent) + } + + override fun shouldBeAvailable(project: Project): Boolean = isCodeWhispererEnabled(project) && !isRunningOnRemoteBackend() + + companion object { + const val id = "aws.codewhisperer.codereference" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/BM25.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/BM25.kt new file mode 100644 index 0000000000..ee2572b4b8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/BM25.kt @@ -0,0 +1,131 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import kotlin.math.ln + +private val WORD_REGEX = """\w+""".toRegex() + +// TODO: we still need NLTK tokenizer instead of this naive one +// tokenize given string and filter out non-english word +/** + * Equivalent to the following: + * String.tokenize(): List { + * val s = this.split(" ") + * val res = mutableListOf() + * + * s.forEach { + * val temp = WORD_REGEX.findAll(it) + * res.addAll(temp.map { it.value }) + * } + * + * return res + * } + */ +private fun String.tokenize(): List = this.split(" ").map { str -> + WORD_REGEX.findAll(str) + .map { it.value } + .toList() +}.flatten() + +data class BM25Result( + val docString: String, + val score: Double +) : Comparable { + override fun compareTo(other: BM25Result): Int = compareValuesBy(other, this) { it.score } +} + +// Kotlin implementation based on python library rank_bm25, refer to https://github.com/dorianbrown/rank_bm25 for more detail +abstract class BM25(val corpus: List, tokenizer: (String) -> List) { + abstract val k1: Double + abstract val b: Double + abstract val epsilon: Double + + protected val corpusSize: Int = corpus.size + protected val avgdl: Double + protected val idf = mutableMapOf() + protected val docLen = mutableListOf() + protected val docFreqs = mutableListOf>() + + protected val nd = mutableMapOf() + + protected val tokenize: (String) -> List = tokenizer + + init { + var numDoc = 0 + corpus + .map { tokenize(it) } + .forEach { document -> + docLen.add(document.size) + numDoc += document.size + + val frequencies = mutableMapOf() + document.forEach { word -> + frequencies[word] = 1 + (frequencies[word] ?: 0) + } + docFreqs.add(frequencies) + + frequencies.forEach { (word, freq) -> + nd[word] = 1 + (nd[word] ?: 0) + } + } + + avgdl = numDoc.toDouble() / corpusSize + + calIdf(nd) + } + + abstract fun calIdf(nd: Map) + + abstract fun score(query: String): List + + fun topN(query: String, n: Int = 3): List { + val notSorted = score(query) + val sorted = notSorted.sorted() + + return sorted.take(n) + } +} + +class BM250kapi(documentSets: List, tokenizer: (String) -> List = String::tokenize) : BM25(documentSets, tokenizer) { + override val k1: Double + get() = 1.5 + override val b: Double + get() = 0.75 + override val epsilon: Double + get() = 0.25 + + override fun calIdf(nd: Map) { + // collect idf sum to calculate an average idf for epsilon value + var idfSum = 0.0 + // collect words with negative idf to set them a special epsilon value + // idf can be negative if word is contained in mroe than half of documents + val negativeIdfs = nd.mapNotNull { (word, freq) -> + val idf = ln(this.corpusSize - freq + 0.5) - ln(freq + 0.5) + this.idf[word] = idf + idfSum += idf + + return@mapNotNull if (idf < 0) { + word + } else { + null + } + } + + val averageIdf = idfSum / this.idf.size + val eps = this.epsilon * averageIdf + negativeIdfs.forEach { word -> + this.idf[word] = eps + } + } + + override fun score(query: String): List = this.docFreqs.mapIndexed { index, docFreq -> + val score = tokenize(query).fold(0.0) { currScore, queryWord -> + val queryWordFreqForDocument = docFreq[queryWord] ?: 0 + currScore + (idf[queryWord] ?: 0.0) * queryWordFreqForDocument * (k1 + 1) / (queryWordFreqForDocument + k1 * (1 - b + b * docLen[index] / avgdl)) + } + + BM25Result(corpus[index], score) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererColorUtil.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererColorUtil.kt new file mode 100644 index 0000000000..a4847345d9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererColorUtil.kt @@ -0,0 +1,26 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.ui.Gray +import com.intellij.ui.JBColor +import com.intellij.util.ui.UIUtil +import java.awt.Color + +object CodeWhispererColorUtil { + val POPUP_HOVER = JBColor(Gray.xC0, Gray.xFF) + val POPUP_BUTTON_BORDER = JBColor(Gray.x32, Gray.x64) + val POPUP_PANEL_SEPARATOR = JBColor.border() + val POPUP_DIM_HEX = JBColor.GRAY.getHexString() + val POPUP_REF_NOTICE_HEX = JBColor(0x2097F6, 0x2097F6).getHexString() + val POPUP_REF_INFO = Gray.x8C + val TOOLWINDOW_BACKGROUND = EditorColorsManager.getInstance().globalScheme.defaultBackground + val TOOLWINDOW_CODE = JBColor(0x629623, 0x629623) + val EDITOR_CODE_REFERENCE_HOVER = JBColor(0x4B4D4D, 0x4B4D4D) + val INACTIVE_TEXT_COLOR = UIUtil.getInactiveTextColor().getHexString() + val TRY_EXAMPLE_EVEN_ROW_COLOR = JBColor(0xCACACA, 0x252525) + + fun Color.getHexString() = String.format("#%02x%02x%02x", this.red, this.green, this.blue) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererConstants.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererConstants.kt new file mode 100644 index 0000000000..8738ede61d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererConstants.kt @@ -0,0 +1,210 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.editor.markup.EffectType +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.ui.JBColor +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.codewhispererruntime.model.AccessDeniedException +import software.amazon.awssdk.services.codewhispererruntime.model.CodeWhispererRuntimeException +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJava +import software.aws.toolkits.telemetry.CodewhispererGettingStartedTask +import java.awt.Font +import java.text.SimpleDateFormat +import java.time.format.DateTimeFormatter + +object CodeWhispererConstants { + const val CHARACTERS_LIMIT = 10240 + const val BEGINNING_OF_FILE = 0 + const val FILENAME_CHARS_LIMIT = 1024 + const val INVOCATION_KEY_INTERVAL_THRESHOLD = 15 + val SPECIAL_CHARACTERS_LIST = listOf("{", "[", "(", ":") + val PAIRED_BRACKETS = mapOf('{' to '}', '(' to ')', '[' to ']', '<' to '>') + val PAIRED_QUOTES = setOf('"', '\'', '`') + const val INVOCATION_TIME_INTERVAL_THRESHOLD = 2 + const val LEFT_CONTEXT_ON_CURRENT_LINE = 50 + const val POPUP_INFO_TEXT_SIZE = 11f + const val POPUP_BUTTON_TEXT_SIZE = 12f + const val POPUP_DELAY: Long = 250 + const val POPUP_DELAY_CHECK_INTERVAL: Long = 25 + const val IDLE_TIME_CHECK_INTERVAL: Long = 25 + const val SUPPLEMENTAL_CONTEXT_TIMEOUT = 50L + const val FEATURE_EVALUATION_PRODUCT_NAME = "CodeWhisperer" + + val AWSTemplateKeyWordsRegex = Regex("(AWSTemplateFormatVersion|Resources|AWS::|Description)") + val AWSTemplateCaseInsensitiveKeyWordsRegex = Regex("(cloudformation|cfn|template|description)") + + // TODO: this is currently set to 2050 to account for the server side 0.5 TPS and and extra 50 ms buffer to + // avoid ThrottlingException as much as possible. + const val INVOCATION_INTERVAL: Long = 2050 + + const val CODEWHISPERER_LEARN_MORE_URI = "https://aws.amazon.com/codewhisperer" + const val CODEWHISPERER_SSO_LEARN_MORE_URI = "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/codewhisperer-auth.html" + const val CODEWHISPERER_LOGIN_LEARN_MORE_URI = "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/codewhisper-setup-general.html" + const val CODEWHISPERER_LOGIN_HELP_URI = "https://docs.aws.amazon.com/toolkit-for-jetbrains/latest/userguide/setup-credentials.html" + const val CODEWHISPERER_CUSTOM_LEARN_MORE_URI = "https://docs.aws.amazon.com/codewhisperer/latest/userguide/customizations.html" + const val CODEWHISPERER_WORKSHOP_URI = + "https://catalog.us-east-1.prod.workshops.aws/workshops/6838a1a5-4516-4153-90ce-ac49ca8e1357/03-getting-started/03-02-prompts" + const val CODEWHISPERER_SUPPORTED_LANG_URI = "https://docs.aws.amazon.com/codewhisperer/latest/userguide/language-ide-support.html" + const val CODEWHISPERER_CODE_SCAN_LEARN_MORE_URI = "https://docs.aws.amazon.com/codewhisperer/latest/userguide/security-scans.html" + const val CODEWHISPERER_ONBOARDING_DOCUMENTATION_URI = "https://docs.aws.amazon.com/codewhisperer/latest/userguide/features.html" + + const val THROTTLING_MESSAGE = "Maximum recommendation count reached for this month." + + // Code scan feature constants + val ISSUE_HIGHLIGHT_TEXT_ATTRIBUTES = TextAttributes(null, null, JBColor.YELLOW, EffectType.WAVE_UNDERSCORE, Font.PLAIN) + const val JAVA_CODE_SCAN_TIMEOUT_IN_SECONDS: Long = 60 + const val JAVA_PAYLOAD_LIMIT_IN_BYTES = 1024 * 1024 // 1MB + const val CSHARP_CODE_SCAN_TIMEOUT_IN_SECONDS: Long = 60 + const val CSHARP_PAYLOAD_LIMIT_IN_BYTES = 1024 * 1024 // 1MB + const val RUBY_CODE_SCAN_TIMEOUT_IN_SECONDS: Long = 60 + const val RUBY_PAYLOAD_LIMIT_IN_BYTES = 1024 * 200 // 200KB + const val CLOUDFORMATION_CODE_SCAN_TIMEOUT_IN_SECONDS: Long = 60 + const val CLOUDFORMATION_PAYLOAD_LIMIT_IN_BYTES = 1024 * 200 // 200KB + const val TERRAFORM_CODE_SCAN_TIMEOUT_IN_SECONDS: Long = 60 + const val TERRAFORM_PAYLOAD_LIMIT_IN_BYTES = 1024 * 200 // 200KB + const val PYTHON_CODE_SCAN_TIMEOUT_IN_SECONDS: Long = 60 + const val PYTHON_PAYLOAD_LIMIT_IN_BYTES = 1024 * 200 // 200KB + const val JS_CODE_SCAN_TIMEOUT_IN_SECONDS: Long = 60 + const val JS_PAYLOAD_LIMIT_IN_BYTES = 1024 * 200 // 200KB + const val GO_CODE_SCAN_TIMEOUT_IN_SECONDS: Long = 60 + const val GO_PAYLOAD_LIMIT_IN_BYTES = 1024 * 200 // 200KB + const val CODE_SCAN_POLLING_INTERVAL_IN_SECONDS: Long = 1 + const val CODE_SCAN_CREATE_PAYLOAD_TIMEOUT_IN_SECONDS: Long = 10 + const val TOTAL_BYTES_IN_KB = 1024 + const val TOTAL_BYTES_IN_MB = 1024 * 1024 + const val TOTAL_MILLIS_IN_SECOND = 1000 + const val TOTAL_SECONDS_IN_MINUTE: Long = 60L + const val ACCOUNTLESS_START_URL = "accountless" + const val FEATURE_CONFIG_POLL_INTERVAL_IN_MS: Long = 30 * 60 * 1000L // 30 mins + const val USING: String = "using" + const val GLOBAL_USING: String = "global using" + const val STATIC: String = "static" + const val REQUIRE: String = "require" + const val REQUIRE_RELATIVE: String = "require_relative" + const val LOAD: String = "load" + const val INCLUDE: String = "include" + const val EXTEND: String = "extend" + const val AS: String = " as " + + // Date when Accountless is not supported + val EXPIRE_DATE = SimpleDateFormat("yyyy-MM-dd").parse("2023-01-31") + + // Formatter for timestamp on accountless warn notification + val TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + + object AutoSuggestion { + const val SETTING_ID = "codewhisperer_autoSuggestionActivation" + const val ACTIVATED = "Activated" + const val DEACTIVATED = "Deactivated" + } + + object Config { + const val CODEWHISPERER_ENDPOINT = "https://codewhisperer.us-east-1.amazonaws.com/" + const val CODEWHISPERER_IDPOOL_ID = "us-east-1:70717e99-906f-4add-908c-bd9074a2f5b9" + + val Sigv4ClientRegion = Region.US_EAST_1 + val BearerClientRegion = Region.US_EAST_1 + } + + object Customization { + private const val noAccessToCustomizationMessage = "Your account is not authorized to use CodeWhisperer Enterprise." + private const val invalidCustomizationMessage = "You are not authorized to access" + + val noAccessToCustomizationExceptionPredicate: (e: Exception) -> Boolean = { e -> + if (e !is CodeWhispererRuntimeException) { + false + } else { + e is AccessDeniedException && (e.message?.contains(noAccessToCustomizationMessage, ignoreCase = true) ?: false) + } + } + + val invalidCustomizationExceptionPredicate: (e: Exception) -> Boolean = { e -> + if (e !is CodeWhispererRuntimeException) { + false + } else { + e is AccessDeniedException && (e.message?.contains(invalidCustomizationMessage, ignoreCase = true) ?: false) + } + } + } + object CrossFile { + const val CHUNK_SIZE = 60 + } + + object Utg { + const val UTG_SEGMENT_SIZE = 10200 + const val UTG_PREFIX = "UTG\n" + } + + object TryExampleFileContent { + + private const val AUTO_TRIGGER_CONTENT_JAVA = +"""import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Main { + public static void main(String[] args) { + // TODO: place your cursor at the end of line 18 and press Enter to generate a suggestion. + // Tip: press tab to accept the suggestion. + + List> fakeUsers = new ArrayList<>(); + Map user1 = new HashMap<>(); + user1.put("name", "User 1"); + user1.put("id", "user1"); + user1.put("city", "San Francisco"); + user1.put("state", "CA"); + fakeUsers.add(user1); + + } +}""" + + private const val MANUAL_TRIGGER_CONTENT_JAVA = +"""// TODO: Press either Option + C on MacOS or Alt + C on Windows on a new line. + +public class S3Uploader { + + // Function to upload a file to an S3 bucket. + public static void uploadFile(String filePath, String bucketName) { + + } +}""" + + private const val UNIT_TEST_CONTENT_JAVA = +"""// TODO: Ask CodeWhisperer to write unit tests. + +// Write a test case for the sum function. + +import junit.framework.Test; + +public class SumFunction { + + /** + * Function to sum two numbers. + * + * @param a First number. + * @param b Second number. + * @return Sum of the two numbers. + */ + public static int sum(int a, int b) { + return a + b; + } + +}""" + + val tryExampleFileContexts = mapOf( + CodewhispererGettingStartedTask.AutoTrigger to mapOf( + CodeWhispererJava.INSTANCE to (AUTO_TRIGGER_CONTENT_JAVA to AUTO_TRIGGER_CONTENT_JAVA.length - 8) + ), + CodewhispererGettingStartedTask.ManualTrigger to mapOf( + CodeWhispererJava.INSTANCE to (MANUAL_TRIGGER_CONTENT_JAVA to MANUAL_TRIGGER_CONTENT_JAVA.length - 8) + ), + CodewhispererGettingStartedTask.UnitTest to mapOf( + CodeWhispererJava.INSTANCE to (UNIT_TEST_CONTENT_JAVA to UNIT_TEST_CONTENT_JAVA.length - 2) + ) + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererEndpointCustomizer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererEndpointCustomizer.kt new file mode 100644 index 0000000000..c0f2b07999 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererEndpointCustomizer.kt @@ -0,0 +1,104 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.application.ApplicationManager +import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider +import software.amazon.awssdk.awscore.client.builder.AwsClientBuilder +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.core.interceptor.Context +import software.amazon.awssdk.core.interceptor.ExecutionAttributes +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor +import software.amazon.awssdk.core.retry.RetryPolicy +import software.amazon.awssdk.http.SdkHttpRequest +import software.amazon.awssdk.services.codewhisperer.CodeWhispererClientBuilder +import software.amazon.awssdk.services.codewhispererruntime.CodeWhispererRuntimeClientBuilder +import software.amazon.awssdk.services.codewhispererstreaming.CodeWhispererStreamingAsyncClientBuilder +import software.amazon.awssdk.services.cognitoidentity.CognitoIdentityClient +import software.aws.toolkits.core.ToolkitClientCustomizer +import software.aws.toolkits.jetbrains.core.AwsSdkClient +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererSettings +import software.aws.toolkits.jetbrains.services.telemetry.AwsCognitoCredentialsProvider +import java.net.URI + +// TODO: move this file to package /client +class CodeWhispererEndpointCustomizer : ToolkitClientCustomizer { + + override fun customize( + credentialProvider: AwsCredentialsProvider?, + tokenProvider: SdkTokenProvider?, + regionId: String, + builder: AwsClientBuilder<*, *>, + clientOverrideConfiguration: ClientOverrideConfiguration.Builder + ) { + if (builder is CodeWhispererRuntimeClientBuilder || builder is CodeWhispererStreamingAsyncClientBuilder) { + builder + .endpointOverride(URI.create(CodeWhispererConstants.Config.CODEWHISPERER_ENDPOINT)) + .region(CodeWhispererConstants.Config.BearerClientRegion) + clientOverrideConfiguration.retryPolicy(RetryPolicy.none()) + clientOverrideConfiguration.addExecutionInterceptor( + object : ExecutionInterceptor { + override fun modifyHttpRequest(context: Context.ModifyHttpRequest, executionAttributes: ExecutionAttributes): SdkHttpRequest { + val requestBuilder = context.httpRequest().toBuilder() + executionAttributes.attributes.forEach { (k, v) -> + if (k.toString() != "OperationName") return@forEach + val isMetricOptIn = CodeWhispererSettings.getInstance().isMetricOptIn() + if (v == "GenerateCompletions") { + requestBuilder.putHeader(OPTOUT_KEY_NAME, (!isMetricOptIn).toString()) + } + return requestBuilder.build() + } + return context.httpRequest() + } + } + ) + } else if (builder is CodeWhispererClientBuilder) { + clientOverrideConfiguration.addExecutionInterceptor( + object : ExecutionInterceptor { + override fun modifyHttpRequest(context: Context.ModifyHttpRequest, executionAttributes: ExecutionAttributes): SdkHttpRequest { + val requestBuilder = context.httpRequest().toBuilder() + executionAttributes.attributes.forEach { (k, v) -> + if (k.toString() != "OperationName") return@forEach + if (v == "GetAccessToken") return requestBuilder.build() + val token = CodeWhispererExplorerActionManager.getInstance().resolveAccessToken() ?: return requestBuilder.build() + requestBuilder.putHeader(TOKEN_KEY_NAME, token) + + val isMetricOptIn = CodeWhispererSettings.getInstance().isMetricOptIn() + if (v == "ListRecommendations") { + requestBuilder.putHeader(OPTOUT_KEY_NAME, (!isMetricOptIn).toString()) + } + return requestBuilder.build() + } + return context.httpRequest() + } + } + ) + + builder + .region(CodeWhispererConstants.Config.Sigv4ClientRegion) + + if (!ApplicationManager.getApplication().isUnitTestMode) { + builder + .credentialsProvider( + AwsCognitoCredentialsProvider( + CodeWhispererConstants.Config.CODEWHISPERER_IDPOOL_ID, + CognitoIdentityClient.builder() + .credentialsProvider(AnonymousCredentialsProvider.create()) + .region(CodeWhispererConstants.Config.Sigv4ClientRegion) + .httpClient(AwsSdkClient.getInstance().sharedSdkClient()) + .build() + ) + ) + } + } + } + + companion object { + internal const val TOKEN_KEY_NAME = "x-amzn-codewhisperer-token" + internal const val OPTOUT_KEY_NAME = "x-amzn-codewhisperer-optout" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererFileContextProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererFileContextProvider.kt new file mode 100644 index 0000000000..27911fccfd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererFileContextProvider.kt @@ -0,0 +1,319 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.ide.actions.CopyContentRootPathProvider +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import com.intellij.util.gist.GistManager +import com.intellij.util.io.DataExternalizer +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil +import software.aws.toolkits.jetbrains.services.codewhisperer.language.CodeWhispererProgrammingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJava +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJavaScript +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererJsx +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererPython +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererTsx +import software.aws.toolkits.jetbrains.services.codewhisperer.language.languages.CodeWhispererTypeScript +import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmingLanguage +import software.aws.toolkits.jetbrains.services.codewhisperer.model.Chunk +import software.aws.toolkits.jetbrains.services.codewhisperer.model.FileContextInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.model.SupplementalContextInfo +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererUserGroup +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererUserGroupSettings +import java.io.DataInput +import java.io.DataOutput +import java.util.Collections + +private val contentRootPathProvider = CopyContentRootPathProvider() + +private val codewhispererCodeChunksIndex = GistManager.getInstance() + .newPsiFileGist("psi to code chunk index", 0, CodeWhispererCodeChunkExternalizer) { psiFile -> + runBlocking { + val fileCrawler = psiFile.programmingLanguage().fileCrawler + val fileProducers = listOf List> { psiFile -> fileCrawler.listCrossFileCandidate(psiFile) } + FileContextProvider.getInstance(psiFile.project).extractCodeChunksFromFiles(psiFile, fileProducers) + } + } + +private object CodeWhispererCodeChunkExternalizer : DataExternalizer> { + override fun save(out: DataOutput, value: List) { + out.writeInt(value.size) + value.forEach { chunk -> + out.writeUTF(chunk.path) + out.writeUTF(chunk.content) + out.writeUTF(chunk.nextChunk) + } + } + + override fun read(`in`: DataInput): List { + val result = mutableListOf() + val size = `in`.readInt() + repeat(size) { + result.add( + Chunk( + path = `in`.readUTF(), + content = `in`.readUTF(), + nextChunk = `in`.readUTF() + ) + ) + } + + return result + } +} + +/** + * [extractFileContext] will extract the context from a psi file provided + * [extractSupplementalFileContext] supplemental means file context extracted from files other than the provided one + */ +interface FileContextProvider { + fun extractFileContext(editor: Editor, psiFile: PsiFile): FileContextInfo + + suspend fun extractSupplementalFileContext(psiFile: PsiFile, fileContext: FileContextInfo): SupplementalContextInfo? + + suspend fun extractCodeChunksFromFiles(psiFile: PsiFile, fileProducers: List List>): List + + /** + * It will actually delegate to invoke corresponding [CodeWhispererFileCrawler.isTestFile] for each language + * as different languages have their own naming conventions. + */ + fun isTestFile(psiFile: PsiFile): Boolean + + companion object { + fun getInstance(project: Project): FileContextProvider = project.service() + } +} + +class DefaultCodeWhispererFileContextProvider(private val project: Project) : FileContextProvider { + override fun extractFileContext(editor: Editor, psiFile: PsiFile): FileContextInfo = CodeWhispererEditorUtil.getFileContextInfo(editor, psiFile) + + /** + * codewhisperer extract the supplemental context with 2 different approaches depending on what type of file the target file is. + * 1. source file -> explore files/classes imported from the target file + files within the same project root + * 2. test file -> explore "focal file" if applicable, otherwise fall back to most "relevant" file. + * for focal files, e.g. "MainTest.java" -> "Main.java", "test_main.py" -> "main.py" + * for the most relevant file -> we extract "keywords" from files opened in editor then get the one with the highest similarity with target file + */ + override suspend fun extractSupplementalFileContext(psiFile: PsiFile, targetContext: FileContextInfo): SupplementalContextInfo? { + val startFetchingTimestamp = System.currentTimeMillis() + val isTst = isTestFile(psiFile) + val language = targetContext.programmingLanguage + val group = CodeWhispererUserGroupSettings.getInstance().getUserGroup() + + val supplementalContext = if (isTst) { + when (shouldFetchUtgContext(language, group)) { + true -> extractSupplementalFileContextForTst(psiFile, targetContext) + false -> SupplementalContextInfo.emptyUtgFileContextInfo(targetContext.filename) + null -> { + LOG.debug { "UTG is not supporting ${targetContext.programmingLanguage.languageId}" } + null + } + } + } else { + when (shouldFetchCrossfileContext(language, group)) { + true -> extractSupplementalFileContextForSrc(psiFile, targetContext) + false -> SupplementalContextInfo.emptyCrossFileContextInfo(targetContext.filename) + null -> { + LOG.debug { "Crossfile is not supporting ${targetContext.programmingLanguage.languageId}" } + null + } + } + } + + return supplementalContext?.let { + if (it.contents.isNotEmpty()) { + LOG.info { "Successfully fetched supplemental context." } + it.contents.forEachIndexed { index, chunk -> + LOG.info { + """ + |--------------------------------------------------------------- + | Chunk $index: + | path = ${chunk.path}, + | score = ${chunk.score}, + | content = ${chunk.content} + |---------------------------------------------------------------- + """.trimMargin() + } + } + } else { + LOG.warn { "Failed to fetch supplemental context, empty list." } + } + + it.copy(latency = System.currentTimeMillis() - startFetchingTimestamp) + } + } + + override suspend fun extractCodeChunksFromFiles(psiFile: PsiFile, fileProducers: List List>): List { + val parseFilesStart = System.currentTimeMillis() + val hasUsed = Collections.synchronizedSet(mutableSetOf()) + val chunks = mutableListOf() + + for (fileProducer in fileProducers) { + yield() + val files = fileProducer(psiFile) + files.forEach { file -> + yield() + if (hasUsed.contains(file)) { + return@forEach + } + val relativePath = runReadAction { contentRootPathProvider.getPathToElement(project, file, null) ?: file.path } + chunks.addAll(file.toCodeChunk(relativePath)) + hasUsed.add(file) + if (chunks.size > CodeWhispererConstants.CrossFile.CHUNK_SIZE) { + LOG.debug { "finish fetching ${CodeWhispererConstants.CrossFile.CHUNK_SIZE} chunks in ${System.currentTimeMillis() - parseFilesStart} ms" } + return chunks.take(CodeWhispererConstants.CrossFile.CHUNK_SIZE) + } + } + } + + LOG.debug { "finish fetching ${CodeWhispererConstants.CrossFile.CHUNK_SIZE} chunks in ${System.currentTimeMillis() - parseFilesStart} ms" } + return chunks.take(CodeWhispererConstants.CrossFile.CHUNK_SIZE) + } + + override fun isTestFile(psiFile: PsiFile) = psiFile.programmingLanguage().fileCrawler.isTestFile(psiFile.virtualFile, psiFile.project) + + @VisibleForTesting + suspend fun extractSupplementalFileContextForSrc(psiFile: PsiFile, targetContext: FileContextInfo): SupplementalContextInfo { + if (!targetContext.programmingLanguage.isSupplementalContextSupported()) { + return SupplementalContextInfo.emptyCrossFileContextInfo(targetContext.filename) + } + + // takeLast(11) will extract 10 lines (exclusing current line) of left context as the query parameter + val query = targetContext.caretContext.leftFileContext.split("\n").takeLast(11).joinToString("\n") + + // step 1: prepare data + val first60Chunks: List = try { + runReadAction { codewhispererCodeChunksIndex.getFileData(psiFile) } + } catch (e: TimeoutCancellationException) { + throw e + } + + yield() + + if (first60Chunks.isEmpty()) { + LOG.warn { + "0 chunks was found for supplemental context, fileName=${targetContext.filename}, " + + "programmingLanaugage: ${targetContext.programmingLanguage}" + } + return SupplementalContextInfo.emptyCrossFileContextInfo(targetContext.filename) + } + + // we need to keep the reference to Chunk object because we will need to get "nextChunk" later after calculation + val contentToChunk = first60Chunks.associateBy { it.content } + + // BM250 only take list of string as argument + // step 2: bm25 calculation + val timeBeforeBm25 = System.currentTimeMillis() + val top3Chunks: List = BM250kapi(first60Chunks.map { it.content }).topN(query) + LOG.info { "Time ellapsed for BM25 algorithm: ${System.currentTimeMillis() - timeBeforeBm25} ms" } + + yield() + + // we use nextChunk as supplemental context + val crossfileContext = top3Chunks.mapNotNull { bm25Result -> + contentToChunk[bm25Result.docString]?.let { + if (it.nextChunk.isNotBlank()) { + Chunk(content = it.nextChunk, path = it.path, score = bm25Result.score) + } else { + null + } + } + } + + return SupplementalContextInfo( + isUtg = false, + contents = crossfileContext, + targetFileName = targetContext.filename, + strategy = CrossFileStrategy.OpenTabsBM25 + ) + } + + @VisibleForTesting + fun extractSupplementalFileContextForTst(psiFile: PsiFile, targetContext: FileContextInfo): SupplementalContextInfo { + if (!targetContext.programmingLanguage.isUTGSupported()) { + return SupplementalContextInfo.emptyUtgFileContextInfo(targetContext.filename) + } + + val utgCandidateResult = targetContext.programmingLanguage.fileCrawler.listUtgCandidate(psiFile) + val focalFile = utgCandidateResult.vfile + val strategy = utgCandidateResult.strategy + + return focalFile?.let { file -> + runReadAction { + val relativePath = contentRootPathProvider.getPathToElement(project, file, null) ?: file.path + val content = file.content() + + val utgContext = if (content.isBlank()) { + emptyList() + } else { + listOf( + Chunk( + content = CodeWhispererConstants.Utg.UTG_PREFIX + file.content().let { + it.substring( + 0, + minOf(it.length, CodeWhispererConstants.Utg.UTG_SEGMENT_SIZE) + ) + }, + path = relativePath + ) + ) + } + + SupplementalContextInfo( + isUtg = true, + contents = utgContext, + targetFileName = targetContext.filename, + strategy = strategy + ) + } + } ?: run { + return SupplementalContextInfo.emptyUtgFileContextInfo(targetContext.filename) + } + } + + companion object { + private val LOG = getLogger() + + fun shouldFetchUtgContext(language: CodeWhispererProgrammingLanguage, userGroup: CodeWhispererUserGroup): Boolean? { + if (!language.isUTGSupported()) { + return null + } + + return when (language) { + is CodeWhispererJava -> true + else -> userGroup == CodeWhispererUserGroup.CrossFile + } + } + + fun shouldFetchCrossfileContext(language: CodeWhispererProgrammingLanguage, userGroup: CodeWhispererUserGroup): Boolean? { + if (!language.isSupplementalContextSupported()) { + return null + } + + return when (language) { + is CodeWhispererJava, + is CodeWhispererPython, + is CodeWhispererJavaScript, + is CodeWhispererTypeScript, + is CodeWhispererJsx, + is CodeWhispererTsx -> true + + else -> userGroup == CodeWhispererUserGroup.CrossFile + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererFileCrawler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererFileCrawler.kt new file mode 100644 index 0000000000..4ce957bb8d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererFileCrawler.kt @@ -0,0 +1,231 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.roots.TestSourcesFilter +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.services.codewhisperer.model.ListUtgCandidateResult + +/** + * An interface define how do we parse and fetch files provided a psi file or project + * since different language has its own way importing other files or its own naming style for test file + */ +interface FileCrawler { + /** + * parse the import statements provided a file + * @param psiFile of the file we are search with + * @return list of file reference from the import statements + */ + suspend fun listFilesImported(psiFile: PsiFile): List + + fun listFilesUnderProjectRoot(project: Project): List + + /** + * @param psiFile the file we are searching with, aka target file + * @return Files under the same package as the given file and exclude the given file + */ + fun listFilesWithinSamePackage(psiFile: PsiFile): List + + /** + * should be invoked at test files e.g. MainTest.java, or test_main.py + * @param target psi of the test file we are searching with, e.g. MainTest.java + * @return its source file e.g. Main.java, main.py or most relevant file if any + */ + fun listUtgCandidate(target: PsiFile): ListUtgCandidateResult + + /** + * List files opened in the editors and sorted by file distance @see [CodeWhispererFileCrawler.getFileDistance] + * @return opened files and satisfy the following conditions + * (1) not the input file + * (2) with the same file extension as the input file has + * (3) non-test file which will be determined by [FileCrawler.isTestFile] + * (4) writable file + */ + fun listCrossFileCandidate(target: PsiFile): List + + /** + * Determine if the file given is test file or not based on its path and file name + */ + fun isTestFile(target: VirtualFile, project: Project): Boolean +} + +class NoOpFileCrawler : FileCrawler { + override suspend fun listFilesImported(psiFile: PsiFile): List = emptyList() + + override fun listFilesUnderProjectRoot(project: Project): List = emptyList() + override fun listUtgCandidate(target: PsiFile) = ListUtgCandidateResult(null, UtgStrategy.Empty) + + override fun listFilesWithinSamePackage(psiFile: PsiFile): List = emptyList() + + override fun listCrossFileCandidate(target: PsiFile): List = emptyList() + + override fun isTestFile(target: VirtualFile, project: Project): Boolean = false +} + +abstract class CodeWhispererFileCrawler : FileCrawler { + abstract val fileExtension: String + abstract val dialects: Set + abstract val testFileNamingPatterns: List + + override fun isTestFile(target: VirtualFile, project: Project): Boolean { + val filePath = target.path + + // if file path itself explicitly explains the file is under test sources + if (TestSourcesFilter.isTestSources(target, project) || + filePath.contains("""test/""", ignoreCase = true) || + filePath.contains("""tst/""", ignoreCase = true) || + filePath.contains("""tests/""", ignoreCase = true) + ) { + return true + } + + // no explicit clue from the file path, use regexes based on naming conventions + return testFileNamingPatterns.any { it.matches(target.name) } + } + + override fun listFilesUnderProjectRoot(project: Project): List = project.guessProjectDir()?.let { rootDir -> + VfsUtil.collectChildrenRecursively(rootDir).filter { + // TODO: need to handle cases js vs. jsx, ts vs. tsx when we enable js/ts utg since we likely have different file extensions + it.path.endsWith(fileExtension) + } + }.orEmpty() + + override fun listFilesWithinSamePackage(psiFile: PsiFile): List = runReadAction { + psiFile.containingDirectory?.files?.mapNotNull { + // exclude target file + if (it != psiFile) { + it.virtualFile + } else { + null + } + }.orEmpty() + } + + override fun listCrossFileCandidate(target: PsiFile): List { + val targetFile = target.virtualFile + + val openedFiles = runReadAction { + FileEditorManager.getInstance(target.project).openFiles.toList().filter { + it.name != target.virtualFile.name && + isSameDialect(it.extension) && + !isTestFile(it, target.project) + } + } + + val fileToFileDistanceList = runReadAction { + openedFiles.map { + return@map it to CodeWhispererFileCrawler.getFileDistance(targetFile = targetFile, candidateFile = it) + } + } + + return fileToFileDistanceList.sortedBy { it.second }.map { it.first } + } + + override fun listUtgCandidate(target: PsiFile): ListUtgCandidateResult { + val byName = findSourceFileByName(target) + if (byName != null) { + return ListUtgCandidateResult(byName, UtgStrategy.ByName) + } + + val byContent = findSourceFileByContent(target) + if (byContent != null) { + return ListUtgCandidateResult(byContent, UtgStrategy.ByContent) + } + + return ListUtgCandidateResult(null, UtgStrategy.Empty) + } + + abstract fun findSourceFileByName(target: PsiFile): VirtualFile? + + abstract fun findSourceFileByContent(target: PsiFile): VirtualFile? + + // TODO: may need to update when we enable JS/TS UTG, since we have to factor in .jsx/.tsx combinations + fun guessSourceFileName(tstFileName: String): String? { + val srcFileName = tryOrNull { + testFileNamingPatterns.firstNotNullOf { regex -> + regex.find(tstFileName)?.groupValues?.let { groupValues -> + groupValues.get(1) + groupValues.get(2) + } + } + } + + return srcFileName + } + + private fun isSameDialect(fileExt: String?): Boolean = fileExt?.let { + dialects.contains(fileExt) + } ?: false + + companion object { + // TODO: move to CodeWhispererUtils.kt + /** + * @param target will be the source of keywords + * @param keywordProducer defines how we generate keywords from the target + * @return return the file with the highest substring matching from all opened files with the same file extension + */ + fun searchRelevantFileInEditors(target: PsiFile, keywordProducer: (psiFile: PsiFile) -> List): VirtualFile? { + val project = target.project + val targetElements = keywordProducer(target) + + return runReadAction { + FileEditorManager.getInstance(project).openFiles + .filter { openedFile -> + openedFile.name != target.virtualFile.name && openedFile.extension == target.virtualFile.extension + } + .mapNotNull { openedFile -> PsiManager.getInstance(project).findFile(openedFile) } + .maxByOrNull { + val elementsToCheck = keywordProducer(it) + countSubstringMatches(targetElements, elementsToCheck) + }?.virtualFile + } + } + + // TODO: move to CodeWhispererUtils.kt + /** + * how many elements in elementsToCheck is contained (as substring) in targetElements + */ + fun countSubstringMatches(targetElements: List, elementsToCheck: List): Int = elementsToCheck.fold(0) { acc, elementToCheck -> + val hasTarget = targetElements.any { it.contains(elementToCheck, ignoreCase = true) } + if (hasTarget) { + acc + 1 + } else { + acc + } + } + + // TODO: move to CodeWhispererUtils.kt + /** + * For [LocalFileSystem](implementation of virtual file system), the path will be an absolute file path with file separator characters replaced + * by forward slash "/" + * @see [VirtualFile.getPath] + */ + fun getFileDistance(targetFile: VirtualFile, candidateFile: VirtualFile): Int { + val targetFilePaths = targetFile.path.split("/").dropLast(1) + val candidateFilePaths = candidateFile.path.split("/").dropLast(1) + + var i = 0 + while (i < minOf(targetFilePaths.size, candidateFilePaths.size)) { + val dir1 = targetFilePaths[i] + val dir2 = candidateFilePaths[i] + + if (dir1 != dir2) { + break + } + + i++ + } + + return targetFilePaths.subList(fromIndex = i, toIndex = targetFilePaths.size).size + + candidateFilePaths.subList(fromIndex = i, toIndex = candidateFilePaths.size).size + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererMetadata.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererMetadata.kt new file mode 100644 index 0000000000..b8d42d85d0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererMetadata.kt @@ -0,0 +1,8 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +data class CodeWhispererMetadata( + var insertEnd: Int = -1 +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererSdkMapperUtil.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererSdkMapperUtil.kt new file mode 100644 index 0000000000..bfad0ddd67 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererSdkMapperUtil.kt @@ -0,0 +1,91 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import software.amazon.awssdk.services.codewhisperer.model.ArtifactType +import software.amazon.awssdk.services.codewhisperer.model.CodeScanFindingsSchema +import software.amazon.awssdk.services.codewhisperer.model.CodeScanStatus +import software.amazon.awssdk.services.codewhisperer.model.CodeWhispererResponseMetadata +import software.amazon.awssdk.services.codewhisperer.model.CreateCodeScanRequest +import software.amazon.awssdk.services.codewhisperer.model.CreateCodeScanResponse +import software.amazon.awssdk.services.codewhisperer.model.CreateCodeScanUploadUrlRequest +import software.amazon.awssdk.services.codewhisperer.model.GetCodeScanRequest +import software.amazon.awssdk.services.codewhisperer.model.GetCodeScanResponse +import software.amazon.awssdk.services.codewhisperer.model.ListCodeScanFindingsRequest +import software.amazon.awssdk.services.codewhisperer.model.ListCodeScanFindingsResponse +import software.amazon.awssdk.services.codewhisperer.model.ProgrammingLanguage +import software.amazon.awssdk.services.codewhispererruntime.model.CreateUploadUrlRequest +import software.amazon.awssdk.services.codewhispererruntime.model.GetCodeAnalysisRequest +import software.amazon.awssdk.services.codewhispererruntime.model.GetCodeAnalysisResponse +import software.amazon.awssdk.services.codewhispererruntime.model.ListCodeAnalysisFindingsRequest +import software.amazon.awssdk.services.codewhispererruntime.model.ListCodeAnalysisFindingsResponse +import software.amazon.awssdk.services.codewhispererruntime.model.StartCodeAnalysisRequest +import software.amazon.awssdk.services.codewhispererruntime.model.StartCodeAnalysisResponse + +// TODO: rename to be a more imformative name +private fun ProgrammingLanguage.transform(): software.amazon.awssdk.services.codewhispererruntime.model.ProgrammingLanguage = + software.amazon.awssdk.services.codewhispererruntime.model.ProgrammingLanguage.builder() + .languageName(this.languageName()) + .build() + +private fun ArtifactType.transform(): software.amazon.awssdk.services.codewhispererruntime.model.ArtifactType = + software.amazon.awssdk.services.codewhispererruntime.model.ArtifactType.fromValue(this.toString()) + +private fun CodeScanFindingsSchema.transform(): software.amazon.awssdk.services.codewhispererruntime.model.CodeAnalysisFindingsSchema = + software.amazon.awssdk.services.codewhispererruntime.model.CodeAnalysisFindingsSchema.fromValue( + this.toString().replace("scan", "analysis") + ) + +private fun software.amazon.awssdk.services.codewhispererruntime.model.CodeAnalysisStatus.transform(): CodeScanStatus = + CodeScanStatus.fromValue(this.toString()) + +fun CreateCodeScanUploadUrlRequest.transform(): CreateUploadUrlRequest = + CreateUploadUrlRequest.builder() + .contentMd5(this.contentMd5()) + .artifactType(this.artifactType().transform()) + .build() + +fun CreateCodeScanRequest.transform(): StartCodeAnalysisRequest = + StartCodeAnalysisRequest.builder() + .artifacts(this.artifacts().entries.map { it.key.transform() to it.value }.toMap()) + .programmingLanguage(this.programmingLanguage().transform()) + .clientToken(this.clientToken()) + .build() + +fun StartCodeAnalysisResponse.transform(): CreateCodeScanResponse = + CreateCodeScanResponse.builder() + .jobId(this.jobId()) + .status(this.status().transform()) + .errorMessage(this.errorMessage()) + .responseMetadata(CodeWhispererResponseMetadata.create(this.responseMetadata())) + .sdkHttpResponse(this.sdkHttpResponse()) + .build() as CreateCodeScanResponse + +fun GetCodeScanRequest.transform(): GetCodeAnalysisRequest = + GetCodeAnalysisRequest.builder() + .jobId(this.jobId()) + .build() + +fun GetCodeAnalysisResponse.transform(): GetCodeScanResponse = + GetCodeScanResponse.builder() + .status(this.status().transform()) + .errorMessage(this.errorMessage()) + .responseMetadata(CodeWhispererResponseMetadata.create(this.responseMetadata())) + .sdkHttpResponse(this.sdkHttpResponse()) + .build() as GetCodeScanResponse + +fun ListCodeScanFindingsRequest.transform(): ListCodeAnalysisFindingsRequest = + ListCodeAnalysisFindingsRequest.builder() + .jobId(this.jobId()) + .nextToken(this.nextToken()) + .codeAnalysisFindingsSchema(this.codeScanFindingsSchema().transform()) + .build() + +fun ListCodeAnalysisFindingsResponse.transform(): ListCodeScanFindingsResponse = ListCodeScanFindingsResponse + .builder() + .codeScanFindings(this.codeAnalysisFindings()) + .nextToken(this.nextToken()) + .responseMetadata(CodeWhispererResponseMetadata.create(this.responseMetadata())) + .sdkHttpResponse(this.sdkHttpResponse()) + .build() as ListCodeScanFindingsResponse diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtil.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtil.kt new file mode 100644 index 0000000000..6e78eac155 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtil.kt @@ -0,0 +1,290 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.notification.NotificationAction +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import kotlinx.coroutines.yield +import software.amazon.awssdk.services.codewhispererruntime.model.Completion +import software.amazon.awssdk.services.codewhispererruntime.model.OptOutPreference +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.maybeReauthProviderIfNeeded +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection +import software.aws.toolkits.jetbrains.core.credentials.reauthConnectionIfNeeded +import software.aws.toolkits.jetbrains.core.credentials.sono.isSono +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.core.explorer.refreshCwQTree +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererLoginLearnMoreAction +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererSsoLearnMoreAction +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.ConnectWithAwsToContinueActionError +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.ConnectWithAwsToContinueActionWarn +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.DoNotShowAgainActionError +import software.aws.toolkits.jetbrains.services.codewhisperer.actions.DoNotShowAgainActionWarn +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager +import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererExpired +import software.aws.toolkits.jetbrains.services.codewhisperer.learn.LearnCodeWhispererManager.Companion.taskTypeToFilename +import software.aws.toolkits.jetbrains.services.codewhisperer.model.Chunk +import software.aws.toolkits.jetbrains.services.codewhisperer.service.CodeWhispererService +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.isTelemetryEnabled +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.jetbrains.utils.notifyWarn +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodewhispererCompletionType +import software.aws.toolkits.telemetry.CodewhispererGettingStartedTask + +fun calculateIfIamIdentityCenterConnection(project: Project, calculationTask: (connection: ToolkitConnection) -> T): T? = + ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance())?.let { + calculateIfIamIdentityCenterConnection(it, calculationTask) + } + +fun calculateIfIamIdentityCenterConnection(connection: ToolkitConnection, calculationTask: (connection: ToolkitConnection) -> T): T? = + if (connection.isSono()) { + null + } else { + calculationTask(connection) + } + +// Controls the condition to send telemetry event to CodeWhisperer service, currently: +// 1. It will be sent for Builder ID users, only if they have optin telemetry sharing. +// 2. It will be sent for IdC users, regardless of telemetry optout status. +fun runIfIdcConnectionOrTelemetryEnabled(project: Project, callback: (connection: ToolkitConnection) -> Unit) = + ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance())?.let { + runIfIdcConnectionOrTelemetryEnabled(it, callback) + } + +fun runIfIdcConnectionOrTelemetryEnabled(connection: ToolkitConnection, callback: (connection: ToolkitConnection) -> Unit) { + if (connection.isSono() && !isTelemetryEnabled()) return + callback(connection) +} + +fun VirtualFile.content(): String = VfsUtil.loadText(this) + +// we call it a chunk every 10 lines of code +// [[L1, L2, ...L10], [L11, L12, ...L20]...] +// use VirtualFile.toCodeChunk instead +suspend fun String.toCodeChunk(path: String): List { + val chunks = this.trimEnd() + + var chunksOfStringsPreprocessed = chunks + .split("\n") + .chunked(10) + .map { chunkContent -> + yield() + chunkContent.joinToString(separator = "\n").trimEnd() + } + + // special process for edge case: first since first chunk is never referenced by other chunk, we define first 3 lines of its content referencing the first + chunksOfStringsPreprocessed = listOf( + chunksOfStringsPreprocessed + .first() + .split("\n") + .take(3) + .joinToString(separator = "\n").trimEnd() + ) + chunksOfStringsPreprocessed + + return chunksOfStringsPreprocessed.mapIndexed { index, chunkContent -> + yield() + val nextChunkContent = if (index == chunksOfStringsPreprocessed.size - 1) { + chunkContent + } else { + chunksOfStringsPreprocessed[index + 1] + } + Chunk( + content = chunkContent, + path = path, + nextChunk = nextChunkContent + ) + } +} + +// we refer 10 lines of code as "Code Chunk" +// [[L1, L2, ...L10], [L11, L12, ...L20]...] +// use VirtualFile.toCodeChunk +// TODO: path as param is weird +fun VirtualFile.toCodeChunk(path: String): Sequence = sequence { + var prevChunk: String? = null + inputStream.bufferedReader(Charsets.UTF_8).useLines { + val iter = it.chunked(10).iterator() + while (iter.hasNext()) { + val currentChunk = iter.next().joinToString("\n").trimEnd() + + // chunk[0] + if (prevChunk == null) { + val first3Lines = currentChunk.split("\n").take(3).joinToString("\n").trimEnd() + yield(Chunk(content = first3Lines, path = path, nextChunk = currentChunk)) + } else { + // chunk[1]...chunk[n-1] + prevChunk?.let { chunk -> + yield(Chunk(content = chunk, path = path, nextChunk = currentChunk)) + } + } + + prevChunk = currentChunk + } + + prevChunk?.let { lastChunk -> + // chunk[n] + yield(Chunk(content = lastChunk, path = path, nextChunk = lastChunk)) + } + } +} + +object CodeWhispererUtil { + fun getCompletionType(completion: Completion): CodewhispererCompletionType { + val content = completion.content() + val nonBlankLines = content.split("\n").count { it.isNotBlank() } + + return when { + content.isEmpty() -> CodewhispererCompletionType.Line + nonBlankLines > 1 -> CodewhispererCompletionType.Block + else -> CodewhispererCompletionType.Line + } + } + + fun notifyWarnCodeWhispererUsageLimit(project: Project? = null) { + notifyWarn( + message("codewhisperer.notification.usage_limit.warn.title"), + message("codewhisperer.notification.usage_limit.codesuggestion.warn.content"), + project, + ) + } + + fun notifyErrorCodeWhispererUsageLimit(project: Project? = null, isCodeScan: Boolean = false) { + notifyError( + "", + if (!isCodeScan) { + message("codewhisperer.notification.usage_limit.codesuggestion.warn.content") + } else { + message("codewhisperer.notification.usage_limit.codescan.warn.content") + }, + project, + ) + } + + // show when user login with Accountless + fun notifyWarnAccountless() = notifyWarn( + "", + message("codewhisperer.notification.accountless.warn.message"), + null, + listOf(CodeWhispererSsoLearnMoreAction(), ConnectWithAwsToContinueActionWarn(), DoNotShowAgainActionWarn()) + ) + + // show after user selects Don't Show Again in Accountless login message + fun notifyInfoAccountless() = notifyInfo( + "", + message("codewhisperer.notification.accountless.info.dont.show.again.message"), + null, + listOf(CodeWhispererLoginLearnMoreAction()) + ) + + // show when user login with Accountless and Accountless is not supported by CW + fun notifyErrorAccountless() = notifyError( + "", + message("codewhisperer.notification.accountless.error.message"), + null, + listOf(CodeWhispererSsoLearnMoreAction(), ConnectWithAwsToContinueActionError(), DoNotShowAgainActionError()) + ) + + // This will be called only when there's a CW connection, but it has expired(either accessToken or refreshToken) + // 1. If connection is expired, try to refresh + // 2. If not able to refresh, requesting re-login by showing a notification + // 3. The notification will be shown + // 3.1 At most once per IDE restarts. + // 3.2 At most once after IDE restarts, + // for example, when user performs security scan or fetch code completion for the first time + // Return true if need to re-auth, false otherwise + fun promptReAuth(project: Project, isPluginStarting: Boolean = false): Boolean { + if (!isCodeWhispererExpired(project)) return false + val tokenProvider = tokenProvider(project) ?: return false + return maybeReauthProviderIfNeeded(project, tokenProvider, isBuilderId = tokenConnection(project).isSono()) { + runInEdt { + project.refreshCwQTree() + if (!CodeWhispererService.hasReAuthPromptBeenShown()) { + notifyConnectionExpiredRequestReauth(project) + } + if (!isPluginStarting) { + CodeWhispererService.markReAuthPromptShown() + } + } + } + } + + private fun notifyConnectionExpiredRequestReauth(project: Project) { + if (CodeWhispererExplorerActionManager.getInstance().getConnectionExpiredDoNotShowAgain()) { + return + } + notifyError( + message("toolkit.sso_expire.dialog.title"), + message("toolkit.sso_expire.dialog_message"), + project, + listOf( + NotificationAction.create(message("toolkit.sso_expire.dialog.yes_button")) { _, notification -> + reconnectCodeWhisperer(project) + notification.expire() + }, + NotificationAction.create(message("toolkit.sso_expire.dialog.no_button")) { _, notification -> + CodeWhispererExplorerActionManager.getInstance().setConnectionExpiredDoNotShowAgain(true) + notification.expire() + } + ) + ) + } + + fun getConnectionStartUrl(connection: ToolkitConnection?): String? { + connection ?: return null + if (connection !is ManagedBearerSsoConnection) return null + return connection.startUrl + } + + private fun tokenConnection(project: Project) = ( + ToolkitConnectionManager + .getInstance(project) + .activeConnectionForFeature(CodeWhispererConnection.getInstance()) as? AwsBearerTokenConnection + ) + + private fun tokenProvider(project: Project) = + tokenConnection(project) + ?.getConnectionSettings() + ?.tokenProvider + ?.delegate as? BearerTokenProvider + + fun reconnectCodeWhisperer(project: Project) { + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(CodeWhispererConnection.getInstance()) + if (connection !is ManagedBearerSsoConnection) return + ApplicationManager.getApplication().executeOnPooledThread { + reauthConnectionIfNeeded(project, connection) + } + } + + // We want to know if a specific trigger happens in the Getting Started page examples files. + // We use the current file name to know this info. If file name doesn't match any of the below, we will assume + // that it's coming from a normal file and return null. + fun getGettingStartedTaskType(editor: Editor): CodewhispererGettingStartedTask? { + if (ApplicationManager.getApplication().isUnitTestMode) return null + val filename = (editor as EditorImpl).virtualFile?.name ?: return null + return taskTypeToFilename.filter { filename.startsWith(it.value) }.keys.firstOrNull() + } + + fun getTelemetryOptOutPreference() = + if (AwsSettings.getInstance().isTelemetryEnabled) { + OptOutPreference.OPTIN + } else { + OptOutPreference.OPTOUT + } +} + +enum class CaretMovement { + NO_CHANGE, MOVE_FORWARD, MOVE_BACKWARD +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/JavaCodeWhispererFileCrawler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/JavaCodeWhispererFileCrawler.kt new file mode 100644 index 0000000000..c2c8d07189 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/JavaCodeWhispererFileCrawler.kt @@ -0,0 +1,97 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.module.ModuleUtilCore +import com.intellij.openapi.project.rootManager +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiPackage +import com.intellij.psi.search.GlobalSearchScope +import kotlinx.coroutines.yield +import org.jetbrains.jps.model.java.JavaModuleSourceRootTypes +import software.aws.toolkits.jetbrains.services.codewhisperer.language.classresolver.ClassResolverKey +import software.aws.toolkits.jetbrains.services.codewhisperer.language.classresolver.CodeWhispereJavaClassResolver +import software.aws.toolkits.jetbrains.services.codewhisperer.language.classresolver.CodeWhispererClassResolver + +object JavaCodeWhispererFileCrawler : CodeWhispererFileCrawler() { + override val fileExtension: String = "java" + override val dialects: Set = setOf("java") + override val testFileNamingPatterns = listOf( + Regex("""^(.+)Test(\.java)$"""), + Regex("""^(.+)Tests(\.java)$""") + ) + + override suspend fun listFilesImported(psiFile: PsiFile): List { + if (psiFile !is PsiJavaFile) return emptyList() + val result = mutableListOf() + val imports = runReadAction { psiFile.importList?.allImportStatements } + val activeFiles = FileEditorManager.getInstance(psiFile.project).openFiles.toSet() + + // only consider imported files which belong users' own package, thus [isInLocalFileSystem && isWritable] + val fileHandleLambda = { virtualFile: VirtualFile -> + if (virtualFile.isInLocalFileSystem && virtualFile.isWritable) { + // prioritize active files on users' editor + if (activeFiles.contains(virtualFile)) { + result.add(0, virtualFile) + } else { + result.add(virtualFile) + } + } + } + + imports?.forEach { + yield() + runReadAction { it.resolve() }?.let { psiElement -> + // case like import javax.swing.*; + if (psiElement is PsiPackage) { + val filesInPackage = psiElement.getFiles(GlobalSearchScope.allScope(psiFile.project)).mapNotNull { it.virtualFile } + filesInPackage.forEach { file -> + fileHandleLambda(file) + } + } else { + // single file import + runReadAction { + psiElement.containingFile.virtualFile?.let { virtualFile -> + // file within users' project + fileHandleLambda(virtualFile) + } + } + } + } + } + + return result + } + + // psiFile = "MainTest.java", targetFileName = "Main.java" + override fun findSourceFileByName(target: PsiFile): VirtualFile? = + guessSourceFileName(target.virtualFile.name)?.let { srcName -> + val module = ModuleUtilCore.findModuleForFile(target) + + module?.rootManager?.getSourceRoots(JavaModuleSourceRootTypes.PRODUCTION)?.let { srcRoot -> + srcRoot + .map { root -> VfsUtil.collectChildrenRecursively(root) } + .flatten() + .find { !it.isDirectory && it.isWritable && it.name == srcName } + } + } + + /** + * check files in editors and pick one which has most substring matches to the target + */ + override fun findSourceFileByContent(target: PsiFile): VirtualFile? = searchRelevantFileInEditors(target) { myPsiFile -> + CodeWhispererClassResolver.EP_NAME.findFirstSafe { it is CodeWhispereJavaClassResolver }?.let { + val classAndMethods = it.resolveClassAndMembers(myPsiFile) + val clazz = classAndMethods[ClassResolverKey.ClassName].orEmpty() + val methods = classAndMethods[ClassResolverKey.MethodName].orEmpty() + + clazz + methods + }.orEmpty() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/JavascriptCodeWhispererFileCrawler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/JavascriptCodeWhispererFileCrawler.kt new file mode 100644 index 0000000000..00217f8894 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/JavascriptCodeWhispererFileCrawler.kt @@ -0,0 +1,22 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile + +object JavascriptCodeWhispererFileCrawler : CodeWhispererFileCrawler() { + override val fileExtension: String = "js" + override val dialects: Set = setOf("js", "jsx") + override val testFileNamingPatterns: List = listOf( + Regex("""^(.+)\.(?i:t)est(\.js|\.jsx)$"""), + Regex("""^(.+)\.(?i:s)pec(\.js|\.jsx)$""") + ) + + override suspend fun listFilesImported(psiFile: PsiFile): List = emptyList() + + override fun findSourceFileByName(target: PsiFile): VirtualFile? = null + + override fun findSourceFileByContent(target: PsiFile): VirtualFile? = null +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/PythonCodeWhispererFileCrawler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/PythonCodeWhispererFileCrawler.kt new file mode 100644 index 0000000000..83216b7640 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/PythonCodeWhispererFileCrawler.kt @@ -0,0 +1,42 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import software.aws.toolkits.jetbrains.services.codewhisperer.language.classresolver.ClassResolverKey +import software.aws.toolkits.jetbrains.services.codewhisperer.language.classresolver.CodeWhispererClassResolver +import software.aws.toolkits.jetbrains.services.codewhisperer.language.classresolver.CodeWhispererPythonClassResolver + +object PythonCodeWhispererFileCrawler : CodeWhispererFileCrawler() { + override val fileExtension: String = "py" + override val dialects: Set = setOf("py") + override val testFileNamingPatterns: List = listOf( + Regex("""^test_(.+)(\.py)$"""), + Regex("""^(.+)_test(\.py)$""") + ) + + override suspend fun listFilesImported(psiFile: PsiFile): List = emptyList() + + override fun findSourceFileByName(target: PsiFile): VirtualFile? = super.listFilesUnderProjectRoot(target.project).find { + !it.isDirectory && + it.isWritable && + it.name != target.virtualFile.name && + it.name == guessSourceFileName(target.name) + } + + /** + * check files in editors and pick one which has most substring matches to the target + */ + override fun findSourceFileByContent(target: PsiFile): VirtualFile? = searchRelevantFileInEditors(target) { myPsiFile -> + CodeWhispererClassResolver.EP_NAME.findFirstSafe { it is CodeWhispererPythonClassResolver }?.let { + val classAndMethos = it.resolveClassAndMembers(myPsiFile) + val clazz = classAndMethos[ClassResolverKey.ClassName].orEmpty() + val methods = classAndMethos[ClassResolverKey.MethodName].orEmpty() + val func = it.resolveTopLevelFunction(myPsiFile) + + clazz + methods + func + }.orEmpty() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/SupplementalContextStrategy.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/SupplementalContextStrategy.kt new file mode 100644 index 0000000000..c7ee9de79b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/SupplementalContextStrategy.kt @@ -0,0 +1,28 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +interface SupplementalContextStrategy + +enum class UtgStrategy : SupplementalContextStrategy { + ByName, + ByContent, + Empty; + + override fun toString() = when (this) { + ByName -> "ByName" + ByContent -> "ByContent" + Empty -> "Empty" + } +} + +enum class CrossFileStrategy : SupplementalContextStrategy { + OpenTabsBM25, + Empty; + + override fun toString() = when (this) { + OpenTabsBM25 -> "OpenTabs_BM25" + Empty -> "Empty" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/TypescriptCodeWhispererFileCrawler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/TypescriptCodeWhispererFileCrawler.kt new file mode 100644 index 0000000000..462a16501d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/util/TypescriptCodeWhispererFileCrawler.kt @@ -0,0 +1,22 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile + +object TypescriptCodeWhispererFileCrawler : CodeWhispererFileCrawler() { + override val fileExtension: String = "ts" + override val dialects: Set = setOf("ts", "tsx") + override val testFileNamingPatterns: List = listOf( + Regex("""^(.+)\.(?i:t)est(\.ts|\.tsx)$"""), + Regex("""^(.+)\.(?i:s)pec(\.ts|\.tsx)$""") + ) + + override suspend fun listFilesImported(psiFile: PsiFile): List = emptyList() + + override fun findSourceFileByName(target: PsiFile): VirtualFile? = null + + override fun findSourceFileByContent(target: PsiFile): VirtualFile? = null +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/App.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/App.kt new file mode 100644 index 0000000000..32481d07e7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/App.kt @@ -0,0 +1,87 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc + +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQApp +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteraction +import software.aws.toolkits.jetbrains.services.cwc.commands.ActionRegistrar +import software.aws.toolkits.jetbrains.services.cwc.commands.ContextMenuActionMessage +import software.aws.toolkits.jetbrains.services.cwc.controller.ChatController +import software.aws.toolkits.jetbrains.services.cwc.messages.IncomingCwcMessage + +class App : AmazonQApp { + + private val scope = disposableCoroutineScope(this) + + override val tabTypes = listOf("cwc") + + override fun init(context: AmazonQAppInitContext) { + // Create CWC chat controller + val inboundAppMessagesHandler: InboundAppMessagesHandler = + ChatController(context) + + context.messageTypeRegistry.register( + "clear" to IncomingCwcMessage.ClearChat::class, + "help" to IncomingCwcMessage.Help::class, + "chat-prompt" to IncomingCwcMessage.ChatPrompt::class, + "tab-was-removed" to IncomingCwcMessage.TabRemoved::class, + "tab-was-changed" to IncomingCwcMessage.TabChanged::class, + "follow-up-was-clicked" to IncomingCwcMessage.FollowupClicked::class, + "code_was_copied_to_clipboard" to IncomingCwcMessage.CopyCodeToClipboard::class, + "insert_code_at_cursor_position" to IncomingCwcMessage.InsertCodeAtCursorPosition::class, + "trigger-tabID-received" to IncomingCwcMessage.TriggerTabIdReceived::class, + "stop-response" to IncomingCwcMessage.StopResponse::class, + "chat-item-voted" to IncomingCwcMessage.ChatItemVoted::class, + "chat-item-feedback" to IncomingCwcMessage.ChatItemFeedback::class, + "ui-focus" to IncomingCwcMessage.UIFocus::class, + "auth-follow-up-was-clicked" to IncomingCwcMessage.AuthFollowUpWasClicked::class, + + // JB specific (not in vscode) + "transform" to IncomingCwcMessage.Transform::class, + "source-link-click" to IncomingCwcMessage.ClickedLink::class, + "response-body-link-click" to IncomingCwcMessage.ClickedLink::class, + "footer-info-link-click" to IncomingCwcMessage.ClickedLink::class, + ) + + scope.launch { + merge(ActionRegistrar.instance.flow, context.messagesFromUiToApp.flow).collect { message -> + // Launch a new coroutine to handle each message + scope.launch { handleMessage(message, inboundAppMessagesHandler) } + } + } + } + + private suspend fun handleMessage(message: AmazonQMessage, inboundAppMessagesHandler: InboundAppMessagesHandler) { + when (message) { + is IncomingCwcMessage.ClearChat -> inboundAppMessagesHandler.processClearQuickAction(message) + is IncomingCwcMessage.Help -> inboundAppMessagesHandler.processHelpQuickAction(message) + is IncomingCwcMessage.Transform -> inboundAppMessagesHandler.processTransformQuickAction(message) + is IncomingCwcMessage.ChatPrompt -> inboundAppMessagesHandler.processPromptChatMessage(message) + is IncomingCwcMessage.TabRemoved -> inboundAppMessagesHandler.processTabWasRemoved(message) + is IncomingCwcMessage.TabChanged -> inboundAppMessagesHandler.processTabChanged(message) + is IncomingCwcMessage.FollowupClicked -> inboundAppMessagesHandler.processFollowUpClick(message) + is IncomingCwcMessage.CopyCodeToClipboard -> inboundAppMessagesHandler.processCodeWasCopiedToClipboard(message) + is IncomingCwcMessage.InsertCodeAtCursorPosition -> inboundAppMessagesHandler.processInsertCodeAtCursorPosition(message) + is IncomingCwcMessage.StopResponse -> inboundAppMessagesHandler.processStopResponseMessage(message) + is IncomingCwcMessage.ChatItemVoted -> inboundAppMessagesHandler.processChatItemVoted(message) + is IncomingCwcMessage.ChatItemFeedback -> inboundAppMessagesHandler.processChatItemFeedback(message) + is IncomingCwcMessage.UIFocus -> inboundAppMessagesHandler.processUIFocus(message) + is IncomingCwcMessage.AuthFollowUpWasClicked -> inboundAppMessagesHandler.processAuthFollowUpClick(message) + is OnboardingPageInteraction -> inboundAppMessagesHandler.processOnboardingPageInteraction(message) + + // JB specific (not in vscode) + is ContextMenuActionMessage -> inboundAppMessagesHandler.processContextMenuCommand(message) + is IncomingCwcMessage.ClickedLink -> inboundAppMessagesHandler.processLinkClick(message) + } + } + + override fun dispose() { + // nothing to do + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/AppFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/AppFactory.kt new file mode 100644 index 0000000000..afdca162db --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/AppFactory.kt @@ -0,0 +1,11 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppFactory + +class AppFactory : AmazonQAppFactory { + override fun createApp(project: Project) = App() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/ChatConstants.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/ChatConstants.kt new file mode 100644 index 0000000000..384978ac29 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/ChatConstants.kt @@ -0,0 +1,14 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc + +object ChatConstants { + const val REQUEST_TIMEOUT_MS = 60_000 // 60 seconds + + // API Constraints + const val FILE_PATH_SIZE_LIMIT = 4_000 // Maximum length of file paths in characters (actual API limit: 4096) + const val CUSTOMER_MESSAGE_SIZE_LIMIT = 4_000 // Maximum size of the prompt message in characters (actual API limit: 4096) + const val FQN_SIZE_MIN = 1 // Minimum length of fully qualified name in characters (inclusive) + const val FQN_SIZE_LIMIT = 256 // Maximum length of fully qualified name in characters (exclusive, actual API limit: 256) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/InboundAppMessagesHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/InboundAppMessagesHandler.kt new file mode 100644 index 0000000000..a5175d76c6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/InboundAppMessagesHandler.kt @@ -0,0 +1,35 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc + +import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteraction +import software.aws.toolkits.jetbrains.services.cwc.commands.ContextMenuActionMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.IncomingCwcMessage + +/* +(TODO): Messages to listen to (from vscode) + processTriggerTabIDReceived + */ +interface InboundAppMessagesHandler { + suspend fun processPromptChatMessage(message: IncomingCwcMessage.ChatPrompt) + suspend fun processTabWasRemoved(message: IncomingCwcMessage.TabRemoved) + suspend fun processTabChanged(message: IncomingCwcMessage.TabChanged) + suspend fun processFollowUpClick(message: IncomingCwcMessage.FollowupClicked) + suspend fun processCodeWasCopiedToClipboard(message: IncomingCwcMessage.CopyCodeToClipboard) + suspend fun processInsertCodeAtCursorPosition(message: IncomingCwcMessage.InsertCodeAtCursorPosition) + suspend fun processStopResponseMessage(message: IncomingCwcMessage.StopResponse) + suspend fun processChatItemVoted(message: IncomingCwcMessage.ChatItemVoted) + suspend fun processChatItemFeedback(message: IncomingCwcMessage.ChatItemFeedback) + suspend fun processUIFocus(message: IncomingCwcMessage.UIFocus) + suspend fun processAuthFollowUpClick(message: IncomingCwcMessage.AuthFollowUpWasClicked) + suspend fun processOnboardingPageInteraction(message: OnboardingPageInteraction) + + // JB specific (not in vscode) + suspend fun processClearQuickAction(message: IncomingCwcMessage.ClearChat) + suspend fun processHelpQuickAction(message: IncomingCwcMessage.Help) + suspend fun processTransformQuickAction(message: IncomingCwcMessage.Transform) + suspend fun processContextMenuCommand(message: ContextMenuActionMessage) + + suspend fun processLinkClick(message: IncomingCwcMessage.ClickedLink) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/auth/AuthController.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/auth/AuthController.kt new file mode 100644 index 0000000000..711cce0089 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/auth/AuthController.kt @@ -0,0 +1,79 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.auth + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnection +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.checkBearerConnectionValidity +import software.aws.toolkits.jetbrains.core.gettingstarted.reauthenticateWithQ +import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsForQ +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.TelemetryHelper +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CwsprChatCommandType +import software.aws.toolkits.telemetry.UiTelemetry + +class AuthController { + /** + * Check the state of the Q connection. If the connection is valid then null is returned, otherwise it returns a [AuthNeededState] + * holding a message indicating the problem and what type of authentication is needed to resolve. + */ + fun getAuthNeededState(project: Project): AuthNeededState? { + val connectionState = checkBearerConnectionValidity(project, BearerTokenFeatureSet.Q) + val codeWhispererState = checkBearerConnectionValidity(project, BearerTokenFeatureSet.CODEWHISPERER) + return when (connectionState) { + ActiveConnection.NotConnected -> { + if (codeWhispererState == ActiveConnection.NotConnected) { + AuthNeededState( + message = message("q.connection.disconnected"), + authType = AuthFollowUpType.FullAuth, + ) + } else { + // There is a connection for codewhisperer, but it's not valid for Q + AuthNeededState( + message = message("q.connection.need_scopes"), + authType = AuthFollowUpType.MissingScopes, + ) + } + } + + is ActiveConnection.ValidBearer -> null + is ActiveConnection.ExpiredBearer -> AuthNeededState( + message = message("q.connection.expired"), + authType = AuthFollowUpType.ReAuth, + ) + // Not a bearer connection. This should not happen, but if it does, we treat it as a full-auth scenario + else -> { + logger.warn { "Received non-bearer connection for Q" } + AuthNeededState( + message = message("q.connection.invalid"), + authType = AuthFollowUpType.FullAuth, + ) + } + } + } + + fun handleAuth(project: Project, type: AuthFollowUpType) { + when (type) { + AuthFollowUpType.MissingScopes, + AuthFollowUpType.FullAuth -> runInEdt { + UiTelemetry.click(project, "amazonq_chatAuthenticate") + requestCredentialsForQ(project, connectionInitiatedFromQChatPanel = true) + } + + AuthFollowUpType.ReAuth, + -> runInEdt { + reauthenticateWithQ(project) + } + } + TelemetryHelper.recordTelemetryChatRunCommand(CwsprChatCommandType.Auth, type.name) + } + + companion object { + private val logger = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/auth/AuthFollowUpType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/auth/AuthFollowUpType.kt new file mode 100644 index 0000000000..c70cb48f0e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/auth/AuthFollowUpType.kt @@ -0,0 +1,14 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.auth + +import com.fasterxml.jackson.annotation.JsonValue + +enum class AuthFollowUpType( + @field:JsonValue val json: String, +) { + FullAuth("full-auth"), + ReAuth("re-auth"), + MissingScopes("missing_scopes"), +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/auth/AuthNeededState.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/auth/AuthNeededState.kt new file mode 100644 index 0000000000..16351d4ca5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/auth/AuthNeededState.kt @@ -0,0 +1,9 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.auth + +data class AuthNeededState( + val message: String, + val authType: AuthFollowUpType +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/ChatSession.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/ChatSession.kt new file mode 100644 index 0000000000..1a70a93630 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/ChatSession.kt @@ -0,0 +1,17 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.clients.chat + +import kotlinx.coroutines.flow.Flow +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatRequestData +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatResponseEvent + +/** + * Interface for the API that interacts with the CodeWhispererChat service. Enables sending queries to the service + * and receiving responses. + */ +interface ChatSession { + val conversationId: String? + fun chat(data: ChatRequestData): Flow +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/ChatSessionFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/ChatSessionFactory.kt new file mode 100644 index 0000000000..61c4395876 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/ChatSessionFactory.kt @@ -0,0 +1,10 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.clients.chat + +import com.intellij.openapi.project.Project + +interface ChatSessionFactory { + fun create(project: Project): ChatSession +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/exceptions/ChatApiException.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/exceptions/ChatApiException.kt new file mode 100644 index 0000000000..b8ba11e98a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/exceptions/ChatApiException.kt @@ -0,0 +1,17 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.clients.chat.exceptions + +import software.aws.toolkits.jetbrains.services.cwc.exceptions.ChatException + +/** + * Exceptions thrown by the Chat API + */ +open class ChatApiException( + message: String, + val sessionId: String?, + val requestId: String? = null, + val statusCode: Int? = null, + cause: Throwable? = null, +) : ChatException(message, cause) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Requests.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Requests.kt new file mode 100644 index 0000000000..4c62ba98b8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Requests.kt @@ -0,0 +1,58 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.clients.chat.model + +import com.fasterxml.jackson.annotation.JsonProperty +import software.amazon.awssdk.services.codewhispererstreaming.model.UserIntent +import software.aws.toolkits.jetbrains.services.cwc.editor.context.ActiveFileContext + +enum class TriggerType { + Click, + ContextMenu, + Hotkeys +} + +data class ChatRequestData( + val tabId: String, + val message: String, + val activeFileContext: ActiveFileContext, + val userIntent: UserIntent?, + val triggerType: TriggerType +) + +interface CodeNames { + val simpleNames: List? + + // TODO switch to FullyQualifiedNames in new API + val fullyQualifiedNames: FullyQualifiedNames? +} + +// TODO(kylechen): confirm if mutable is needed +// NOTE: MatchPolicy was originally QueryContext in old code +data class MatchPolicy( + val must: Set = emptySet(), + val should: Set = emptySet(), + val mustNot: Set = emptySet(), +) { + fun withMust(m: String) = copy(must = must + m) + fun withShould(s: String) = copy(should = should + s) + fun withMustNot(mn: String) = copy(mustNot = mustNot + mn) +} + +data class Context( + val matchPolicy: MatchPolicy?, +) + +data class FullyQualifiedNames( + val used: List?, +) +data class FullyQualifiedName( + val source: List?, + val symbol: List?, +) + +data class CodeNamesImpl( + @JsonProperty("simpleNames") override val simpleNames: List?, + @JsonProperty("fullyQualifiedNames") override val fullyQualifiedNames: FullyQualifiedNames?, +) : CodeNames diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Responses.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Responses.kt new file mode 100644 index 0000000000..548211f6ca --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/model/Responses.kt @@ -0,0 +1,67 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.cwc.clients.chat.model + +data class ChatResponseEvent( + val requestId: String, + val statusCode: Int, + val token: String?, + val followUps: List?, + val suggestions: List?, + val codeReferences: List?, + val query: String?, +) + +enum class FollowUpType { + Alternatives, + CommonPractices, + Improvements, + MoreExamples, + CiteSources, + LineByLine, + ExplainInDetail, + Generated, +} + +data class SuggestedFollowUp( + val type: FollowUpType, + val pillText: String?, + val prompt: String?, + val message: String?, + val attachedSuggestions: List?, +) + +data class Suggestion( + val url: String, + val title: String, + val body: String, + val context: List, + val metadata: SuggestionMetadata?, + val type: String?, +) + +data class SuggestionMetadata( + val stackOverflow: StackExchangeMetadata?, + val stackExchange: StackExchangeMetadata?, +) + +data class StackExchangeMetadata( + val answerCount: Long, + val isAccepted: Boolean, + val score: Long, + val lastActivityDate: Long, +) + +data class Reference( + val licenseName: String?, + val repository: String?, + val url: String?, + val recommendationContentSpan: RecommendationContentSpan? +) + +data class RecommendationContentSpan( + val start: Int?, + val end: Int? +) + +data class Header(val sender: String, val responseTo: String, val sequenceId: String) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionFactoryV1.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionFactoryV1.kt new file mode 100644 index 0000000000..63623dcde9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionFactoryV1.kt @@ -0,0 +1,11 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.clients.chat.v1 + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.ChatSessionFactory + +class ChatSessionFactoryV1 : ChatSessionFactory { + override fun create(project: Project) = ChatSessionV1(project) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt new file mode 100644 index 0000000000..db8833b4a3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/clients/chat/v1/ChatSessionV1.kt @@ -0,0 +1,320 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.clients.chat.v1 + +import com.intellij.openapi.project.Project +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.future.await +import kotlinx.coroutines.withTimeout +import software.amazon.awssdk.services.codewhispererstreaming.CodeWhispererStreamingAsyncClient +import software.amazon.awssdk.services.codewhispererstreaming.model.AssistantResponseEvent +import software.amazon.awssdk.services.codewhispererstreaming.model.ChatMessage +import software.amazon.awssdk.services.codewhispererstreaming.model.ChatResponseStream +import software.amazon.awssdk.services.codewhispererstreaming.model.ChatTriggerType +import software.amazon.awssdk.services.codewhispererstreaming.model.CodeReferenceEvent +import software.amazon.awssdk.services.codewhispererstreaming.model.ConversationState +import software.amazon.awssdk.services.codewhispererstreaming.model.CursorState +import software.amazon.awssdk.services.codewhispererstreaming.model.DocumentSymbol +import software.amazon.awssdk.services.codewhispererstreaming.model.EditorState +import software.amazon.awssdk.services.codewhispererstreaming.model.FollowupPromptEvent +import software.amazon.awssdk.services.codewhispererstreaming.model.GenerateAssistantResponseRequest +import software.amazon.awssdk.services.codewhispererstreaming.model.GenerateAssistantResponseResponseHandler +import software.amazon.awssdk.services.codewhispererstreaming.model.Position +import software.amazon.awssdk.services.codewhispererstreaming.model.ProgrammingLanguage +import software.amazon.awssdk.services.codewhispererstreaming.model.Range +import software.amazon.awssdk.services.codewhispererstreaming.model.SupplementaryWebLinksEvent +import software.amazon.awssdk.services.codewhispererstreaming.model.SymbolType +import software.amazon.awssdk.services.codewhispererstreaming.model.TextDocument +import software.amazon.awssdk.services.codewhispererstreaming.model.UserInputMessage +import software.amazon.awssdk.services.codewhispererstreaming.model.UserInputMessageContext +import software.amazon.awssdk.services.codewhispererstreaming.model.UserIntent +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.cwc.ChatConstants +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.ChatSession +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.exceptions.ChatApiException +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatRequestData +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatResponseEvent +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.FollowUpType +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.RecommendationContentSpan +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.Reference +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.SuggestedFollowUp +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.Suggestion +import software.aws.toolkits.jetbrains.services.cwc.editor.context.ActiveFileContext + +class ChatSessionV1( + private val project: Project, +) : ChatSession { + + override var conversationId: String? = null + + override fun chat(data: ChatRequestData): Flow = callbackFlow { + var requestId: String = "" + var statusCode: Int = 0 + + val responseHandler = GenerateAssistantResponseResponseHandler.builder() + .onResponse { + requestId = it.responseMetadata().requestId() + statusCode = it.sdkHttpResponse().statusCode() + conversationId = it.conversationId() + + logger.info { + val metadata = it.responseMetadata() + "Response to tab: ${data.tabId}, conversationId: $conversationId, requestId: ${metadata.requestId()}, metadata: $metadata" + } + } + .subscriber { stream: ChatResponseStream -> + stream.accept(object : GenerateAssistantResponseResponseHandler.Visitor { + + override fun visitAssistantResponseEvent(event: AssistantResponseEvent) { + trySend( + ChatResponseEvent( + requestId = requestId, + statusCode = statusCode, + token = event.content(), + followUps = null, + suggestions = null, + query = null, + codeReferences = null, + ), + ) + } + + override fun visitSupplementaryWebLinksEvent(event: SupplementaryWebLinksEvent) { + val suggestions = event.supplementaryWebLinks().map { link -> + Suggestion( + url = link.url(), + title = link.title(), + body = link.snippet(), + context = emptyList(), + metadata = null, + type = null, + ) + } + trySend( + ChatResponseEvent( + requestId = requestId, + statusCode = statusCode, + token = null, + followUps = null, + suggestions = suggestions, + query = null, + codeReferences = null, + ), + ) + } + + override fun visitFollowupPromptEvent(event: FollowupPromptEvent) { + val followUp = event.followupPrompt() + trySend( + ChatResponseEvent( + requestId = requestId, + statusCode = statusCode, + token = null, + followUps = listOf( + SuggestedFollowUp( + type = followUp.userIntent().toFollowUpType(), + pillText = followUp.content(), + prompt = followUp.content(), + message = null, + attachedSuggestions = null, + ), + ), + suggestions = null, + query = null, + codeReferences = null, + ), + ) + } + + override fun visitCodeReferenceEvent(event: CodeReferenceEvent) { + val references = event.references().map { reference -> + Reference( + licenseName = reference.licenseName(), + url = reference.url(), + repository = reference.repository(), + recommendationContentSpan = reference.recommendationContentSpan()?.let { span -> + RecommendationContentSpan( + start = span.start(), + end = span.end(), + ) + }, + ) + } + trySend( + ChatResponseEvent( + requestId = requestId, + statusCode = statusCode, + token = null, + followUps = null, + suggestions = null, + query = null, + codeReferences = references, + ), + ) + } + }) + } + .build() + + try { + withTimeout(ChatConstants.REQUEST_TIMEOUT_MS.toLong()) { + val connection = ToolkitConnectionManager.getInstance(project).activeConnectionForFeature(QConnection.getInstance()) + // this should never happen because it should have been handled upstream by [AuthController] + ?: error("connection was found to be null") + + val client = AwsClientManager.getInstance().getClient(connection.getConnectionSettings()) + val request = data.toChatRequest() + logger.info { "Request from tab: ${data.tabId}, conversationId: $conversationId, request: $request" } + client.generateAssistantResponse(request, responseHandler).await() + } + } catch (e: TimeoutCancellationException) { + // Re-throw an exception that can be caught downstream + throw ChatApiException( + message = "API request timed out", + sessionId = conversationId, + ) + } finally { + channel.close() + } + + awaitClose { + // nothing to do here + } + }.flowOn(getCoroutineBgContext()) + + /** + * Turns the request data into the request structure used by the client + */ + private fun ChatRequestData.toChatRequest(): GenerateAssistantResponseRequest { + val userInputMessageContextBuilder = UserInputMessageContext.builder() + if (activeFileContext.fileContext != null) { + userInputMessageContextBuilder.editorState(activeFileContext.toEditorState()) + } + val userInputMessageContext = userInputMessageContextBuilder.build() + + val userInput = UserInputMessage.builder() + .content(message.take(ChatConstants.CUSTOMER_MESSAGE_SIZE_LIMIT)) + .userIntent(userIntent) + .userInputMessageContext(userInputMessageContext) + .build() + val conversationState = ConversationState.builder() + .conversationId(conversationId) + .currentMessage(ChatMessage.fromUserInputMessage(userInput)) + .chatTriggerType(ChatTriggerType.MANUAL) + .build() + return GenerateAssistantResponseRequest.builder() + .conversationState(conversationState) + .build() + } + + private fun ActiveFileContext.toEditorState(): EditorState { + // Cursor State + val start = focusAreaContext?.codeSelectionRange?.start + val end = focusAreaContext?.codeSelectionRange?.end + + val cursorStateBuilder = CursorState.builder() + + if (start != null && end != null) { + cursorStateBuilder.range( + Range.builder() + .start( + Position.builder() + .line(start.row) + .character(start.column) + .build(), + ) + .end( + Position.builder() + .line(end.row) + .character(end.column) + .build(), + ).build(), + ) + } + + // Code Names -> DocumentSymbols + val codeNames = focusAreaContext?.codeNames + val documentBuilder = TextDocument.builder() + + val documentSymbolList = codeNames?.fullyQualifiedNames?.used?.map { + DocumentSymbol.builder() + .name(it.symbol?.joinToString(separator = ".")) + .type(SymbolType.USAGE) + .source(it.source?.joinToString(separator = ".")) + .build() + }?.filter { it.name().length in ChatConstants.FQN_SIZE_MIN until ChatConstants.FQN_SIZE_LIMIT }.orEmpty() + documentBuilder.documentSymbols(documentSymbolList) + + // File Text + val trimmedFileText = focusAreaContext?.trimmedSurroundingFileText + documentBuilder.text(trimmedFileText) + + // Programming Language + val programmingLanguage = fileContext?.fileLanguage + if (programmingLanguage != null && validLanguages.contains(programmingLanguage)) { + documentBuilder.programmingLanguage( + ProgrammingLanguage.builder() + .languageName(programmingLanguage).build(), + ) + } + + // Relative File Path + val filePath = fileContext?.filePath + if (filePath != null) { + documentBuilder.relativeFilePath(filePath.take(ChatConstants.FILE_PATH_SIZE_LIMIT)) + } + + return EditorState.builder() + .cursorState(cursorStateBuilder.build()) + .document(documentBuilder.build()) + .build() + } + + private fun UserIntent?.toFollowUpType() = when (this) { + UserIntent.SUGGEST_ALTERNATE_IMPLEMENTATION -> FollowUpType.Alternatives + UserIntent.APPLY_COMMON_BEST_PRACTICES -> FollowUpType.CommonPractices + UserIntent.IMPROVE_CODE -> FollowUpType.Improvements + UserIntent.SHOW_EXAMPLES -> FollowUpType.MoreExamples + UserIntent.CITE_SOURCES -> FollowUpType.CiteSources + UserIntent.EXPLAIN_LINE_BY_LINE -> FollowUpType.LineByLine + UserIntent.EXPLAIN_CODE_SELECTION -> FollowUpType.ExplainInDetail + UserIntent.UNKNOWN_TO_SDK_VERSION -> FollowUpType.Generated + null -> FollowUpType.Generated + } + + companion object { + private val logger = getLogger() + + private val validLanguages = arrayOf( + "python", + "javascript", + "java", + "csharp", + "typescript", + "c", + "cpp", + "go", + "kotlin", + "php", + "ruby", + "rust", + "scala", + "shell", + "sql", + "json", + "yaml", + "vue", + "tf", + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/ActionRegistrar.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/ActionRegistrar.kt new file mode 100644 index 0000000000..7ecd3fb440 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/ActionRegistrar.kt @@ -0,0 +1,23 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +// Register Editor Actions in the Editor Context Menu +class ActionRegistrar { + + private val _messages by lazy { MutableSharedFlow(extraBufferCapacity = 10) } + val flow = _messages.asSharedFlow() + + fun reportMessageClick(command: EditorContextCommand) { + _messages.tryEmit(ContextMenuActionMessage(command)) + } + + // provide singleton access + companion object { + val instance = ActionRegistrar() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/ContextMenuActionMessage.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/ContextMenuActionMessage.kt new file mode 100644 index 0000000000..5f6b4a2651 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/ContextMenuActionMessage.kt @@ -0,0 +1,11 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage + +/** + * Event emitted for context menu editor actions + */ +data class ContextMenuActionMessage(val command: EditorContextCommand) : AmazonQMessage diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/CustomAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/CustomAction.kt new file mode 100644 index 0000000000..f4e7e0f978 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/CustomAction.kt @@ -0,0 +1,32 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.wm.ToolWindowManager +import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.TriggerType + +open class CustomAction(private val command: EditorContextCommand) : AnAction(), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + val project = e.getData(CommonDataKeys.PROJECT) ?: return + + val toolWindowManager: ToolWindowManager = ToolWindowManager.getInstance(project) + val toolWindowId = AmazonQToolWindowFactory.WINDOW_ID + toolWindowManager.getToolWindow(toolWindowId)?.show() + + command.triggerType = when { + ActionPlaces.isPopupPlace(e.place) -> TriggerType.ContextMenu + ActionPlaces.isShortcutPlace(e.place) -> TriggerType.Hotkeys + // TODO: track command palette trigger + else -> TriggerType.ContextMenu + } + + ActionRegistrar.instance.reportMessageClick(command) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/EditorContextCommand.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/EditorContextCommand.kt new file mode 100644 index 0000000000..a51e520147 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/EditorContextCommand.kt @@ -0,0 +1,33 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.TriggerType + +enum class EditorContextCommand( + val verb: String, + val actionId: String, + var triggerType: TriggerType = TriggerType.ContextMenu +) { + Explain( + verb = "Explain", + actionId = "aws.amazonq.explainCode", + ), + Refactor( + verb = "Refactor", + actionId = "aws.amazonq.refactorCode", + ), + Fix( + verb = "Fix", + actionId = "aws.amazonq.fixCode", + ), + Optimize( + verb = "Optimize", + actionId = "aws.amazonq.optimizeCode", + ), + SendToPrompt( + verb = "SendToPrompt", + actionId = "aws.amazonq.sendToPrompt", + ), +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/ExplainCodeAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/ExplainCodeAction.kt new file mode 100644 index 0000000000..9578b15669 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/ExplainCodeAction.kt @@ -0,0 +1,6 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +class ExplainCodeAction : CustomAction(EditorContextCommand.Explain) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/FixCodeAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/FixCodeAction.kt new file mode 100644 index 0000000000..c5cc1a53c5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/FixCodeAction.kt @@ -0,0 +1,6 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +class FixCodeAction : CustomAction(EditorContextCommand.Fix) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/OptimizeCodeAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/OptimizeCodeAction.kt new file mode 100644 index 0000000000..41e8ab7591 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/OptimizeCodeAction.kt @@ -0,0 +1,6 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +class OptimizeCodeAction : CustomAction(EditorContextCommand.Optimize) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/RefactorCodeAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/RefactorCodeAction.kt new file mode 100644 index 0000000000..3eb1ffcf32 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/RefactorCodeAction.kt @@ -0,0 +1,6 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +class RefactorCodeAction : CustomAction(EditorContextCommand.Refactor) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToPromptAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToPromptAction.kt new file mode 100644 index 0000000000..9187a2bf38 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToPromptAction.kt @@ -0,0 +1,6 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +class SendToPromptAction : CustomAction(EditorContextCommand.SendToPrompt) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToQActionGroup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToQActionGroup.kt new file mode 100644 index 0000000000..e59f2bfad0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/commands/SendToQActionGroup.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.commands + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.wm.ToolWindowManager +import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory + +class SendToQActionGroup : DefaultActionGroup(), DumbAware { + override fun update(e: AnActionEvent) { + val project = e.project ?: return + val amazonQWindow = ToolWindowManager.getInstance(project).getToolWindow(AmazonQToolWindowFactory.WINDOW_ID) + e.presentation.isEnabledAndVisible = amazonQWindow?.isAvailable ?: false + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt new file mode 100644 index 0000000000..ba43484bdf --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/ChatController.kt @@ -0,0 +1,489 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.controller + +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.PropertyAccessor +import com.fasterxml.jackson.databind.MapperFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Caret +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.psi.PsiDocumentManager +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.job +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.codewhispererstreaming.model.UserIntent +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.EDT +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext +import software.aws.toolkits.jetbrains.services.amazonq.messages.MessagePublisher +import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteraction +import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteractionType +import software.aws.toolkits.jetbrains.services.codemodernizer.CodeModernizerManager +import software.aws.toolkits.jetbrains.services.codemodernizer.state.CodeTransformTelemetryState +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.CodeWhispererUserModificationTracker +import software.aws.toolkits.jetbrains.services.cwc.InboundAppMessagesHandler +import software.aws.toolkits.jetbrains.services.cwc.auth.AuthController +import software.aws.toolkits.jetbrains.services.cwc.auth.AuthNeededState +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.exceptions.ChatApiException +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatRequestData +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.FollowUpType +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.TriggerType +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.v1.ChatSessionFactoryV1 +import software.aws.toolkits.jetbrains.services.cwc.commands.ContextMenuActionMessage +import software.aws.toolkits.jetbrains.services.cwc.commands.EditorContextCommand +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.StaticPrompt +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.StaticTextResponse +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.messenger.ChatPromptHandler +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.InsertedCodeModificationEntry +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.TelemetryHelper +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.userIntent.UserIntentRecognizer +import software.aws.toolkits.jetbrains.services.cwc.editor.context.ActiveFileContext +import software.aws.toolkits.jetbrains.services.cwc.editor.context.ActiveFileContextExtractor +import software.aws.toolkits.jetbrains.services.cwc.editor.context.ExtractionTriggerType +import software.aws.toolkits.jetbrains.services.cwc.messages.AuthNeededException +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessageType +import software.aws.toolkits.jetbrains.services.cwc.messages.EditorContextCommandMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.ErrorMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.FocusType +import software.aws.toolkits.jetbrains.services.cwc.messages.FollowUp +import software.aws.toolkits.jetbrains.services.cwc.messages.IncomingCwcMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.OnboardingPageInteractionMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.QuickActionMessage +import software.aws.toolkits.jetbrains.services.cwc.storage.ChatSessionStorage +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodetransformTelemetry +import software.aws.toolkits.telemetry.CwsprChatCommandType +import java.time.Instant +import java.util.UUID + +class ChatController private constructor( + private val context: AmazonQAppInitContext, + private val chatSessionStorage: ChatSessionStorage, + private val contextExtractor: ActiveFileContextExtractor, + private val intentRecognizer: UserIntentRecognizer, + private val authController: AuthController, +) : InboundAppMessagesHandler { + + private val messagePublisher: MessagePublisher = context.messagesFromAppToUi + private val telemetryHelper = TelemetryHelper(context, chatSessionStorage) + + constructor( + context: AmazonQAppInitContext, + ) : this( + context = context, + chatSessionStorage = ChatSessionStorage(ChatSessionFactoryV1()), + contextExtractor = ActiveFileContextExtractor.create(fqnWebviewAdapter = context.fqnWebviewAdapter, project = context.project), + intentRecognizer = UserIntentRecognizer(), + authController = AuthController(), + ) + + override suspend fun processClearQuickAction(message: IncomingCwcMessage.ClearChat) { + chatSessionStorage.deleteSession(message.tabId) + TelemetryHelper.recordTelemetryChatRunCommand(CwsprChatCommandType.Clear) + } + + override suspend fun processHelpQuickAction(message: IncomingCwcMessage.Help) { + val triggerId = UUID.randomUUID().toString() + + sendQuickActionMessage(triggerId, StaticPrompt.Help) + + val tabId = waitForTabId(triggerId) + + sendStaticTextResponse( + tabId = tabId, + triggerId = triggerId, + response = StaticTextResponse.Help, + ) + TelemetryHelper.recordTelemetryChatRunCommand(CwsprChatCommandType.Help) + } + + override suspend fun processTransformQuickAction(message: IncomingCwcMessage.Transform) { + val triggerId = UUID.randomUUID().toString() + sendQuickActionMessage(triggerId, StaticPrompt.Transform) + val manager = CodeModernizerManager.getInstance(context.project) + val isActive = manager.isModernizationJobActive() + val replyContent = if (isActive) { + message("codemodernizer.chat.reply_job_is_running") + } else { + message("codemodernizer.chat.reply") + } + val reply = ChatMessage( + tabId = message.tabId, + triggerId = UUID.randomUUID().toString(), + messageId = "", + messageType = ChatMessageType.Answer, + message = replyContent, + ) + context.messagesFromAppToUi.publish(reply) + ApplicationManager.getApplication().invokeLater { + runInEdt { + if (!isActive) { + manager.validateAndStart() + } else { + manager.getBottomToolWindow().show() + } + CodetransformTelemetry.jobIsStartedFromChatPrompt( + codeTransformSessionId = CodeTransformTelemetryState.instance.getSessionId(), + ) + } + } + TelemetryHelper.recordTelemetryChatRunCommand(CwsprChatCommandType.Transform) + } + + override suspend fun processPromptChatMessage(message: IncomingCwcMessage.ChatPrompt) { + handleChat( + tabId = message.tabId, + triggerId = UUID.randomUUID().toString(), + message = message.chatMessage, + activeFileContext = contextExtractor.extractContextForTrigger(ExtractionTriggerType.ChatMessage), + userIntent = intentRecognizer.getUserIntentFromPromptChatMessage(message.chatMessage), + TriggerType.Click, + ) + } + + override suspend fun processTabWasRemoved(message: IncomingCwcMessage.TabRemoved) { + chatSessionStorage.deleteSession(message.tabId) + } + + override suspend fun processTabChanged(message: IncomingCwcMessage.TabChanged) { + if (message.prevTabId != null) { + telemetryHelper.recordExitFocusConversation(message.prevTabId) + } + telemetryHelper.recordEnterFocusConversation(message.tabId) + } + + override suspend fun processFollowUpClick(message: IncomingCwcMessage.FollowupClicked) { + val sessionInfo = getSessionInfo(message.tabId) + val lastRequest = sessionInfo.history.lastOrNull() + + val fileContext = lastRequest?.activeFileContext ?: ActiveFileContext(null, null) + + // Re-use the editor context when making the follow-up request + handleChat( + tabId = message.tabId, + triggerId = UUID.randomUUID().toString(), + message = message.followUp.prompt, + activeFileContext = fileContext, + userIntent = intentRecognizer.getUserIntentFromFollowupType(message.followUp.type), + TriggerType.Click, + ) + + telemetryHelper.recordInteractWithMessage(message) + } + + override suspend fun processCodeWasCopiedToClipboard(message: IncomingCwcMessage.CopyCodeToClipboard) { + telemetryHelper.recordInteractWithMessage(message) + } + + override suspend fun processInsertCodeAtCursorPosition(message: IncomingCwcMessage.InsertCodeAtCursorPosition) { + withContext(EDT) { + val editor: Editor = FileEditorManager.getInstance(context.project).selectedTextEditor ?: return@withContext + + val caret: Caret = editor.caretModel.primaryCaret + val offset: Int = caret.offset + + ApplicationManager.getApplication().runWriteAction { + WriteCommandAction.runWriteCommandAction(context.project) { + if (caret.hasSelection()) { + editor.document.deleteString(caret.selectionStart, caret.selectionEnd) + } + + editor.document.insertString(offset, message.code) + + ReferenceLogController.addReferenceLog(message.code, message.codeReference, editor, context.project) + + CodeWhispererUserModificationTracker.getInstance(context.project).enqueue( + InsertedCodeModificationEntry( + telemetryHelper.getConversationId(message.tabId).orEmpty(), + message.messageId, + Instant.now(), + PsiDocumentManager.getInstance(context.project).getPsiFile(editor.document)?.virtualFile, + editor.document.createRangeMarker(caret.selectionStart, caret.selectionEnd, true), + message.code, + ), + ) + } + } + } + telemetryHelper.recordInteractWithMessage(message) + } + + override suspend fun processStopResponseMessage(message: IncomingCwcMessage.StopResponse) { + val sessionInfo = getSessionInfo(message.tabId) + // Cancel any child jobs running in the tab's scope, without cancelling the scope itself + sessionInfo.scope.coroutineContext.job.cancelChildren() + } + + override suspend fun processChatItemVoted(message: IncomingCwcMessage.ChatItemVoted) { + telemetryHelper.recordInteractWithMessage(message) + } + + override suspend fun processChatItemFeedback(message: IncomingCwcMessage.ChatItemFeedback) { + telemetryHelper.recordInteractWithMessage(message) + } + + override suspend fun processUIFocus(message: IncomingCwcMessage.UIFocus) { + if (message.type == FocusType.FOCUS) { + telemetryHelper.recordEnterFocusChat() + } else if (message.type == FocusType.BLUR) { + telemetryHelper.recordExitFocusChat() + } + } + + override suspend fun processAuthFollowUpClick(message: IncomingCwcMessage.AuthFollowUpWasClicked) { + authController.handleAuth(context.project, message.authType) + } + + override suspend fun processOnboardingPageInteraction(message: OnboardingPageInteraction) { + val context = contextExtractor.extractContextForTrigger(ExtractionTriggerType.OnboardingPageInteraction) + val triggerId = UUID.randomUUID().toString() + + val prompt = when (message.type) { + OnboardingPageInteractionType.CwcButtonClick -> StaticPrompt.OnboardingHelp.message + } + sendOnboardingPageInteractionMessage(prompt, message.type, triggerId) + + val tabId = waitForTabId(triggerId) + + if (message.type == OnboardingPageInteractionType.CwcButtonClick) { + sendStaticTextResponse( + tabId = tabId, + triggerId = triggerId, + response = StaticTextResponse.OnboardingHelp, + ) + return + } + + handleChat( + tabId = tabId, + triggerId = triggerId, + message = prompt, + activeFileContext = context, + userIntent = intentRecognizer.getUserIntentFromOnboardingPageInteraction(message), + triggerType = TriggerType.Click, // todo trigger type + ) + } + + // JB specific (not in vscode) + override suspend fun processContextMenuCommand(message: ContextMenuActionMessage) { + // Extract context + val fileContext = contextExtractor.extractContextForTrigger(ExtractionTriggerType.ContextMenu) + val triggerId = UUID.randomUUID().toString() + val codeSelection = "\n```\n${fileContext.focusAreaContext?.codeSelection?.trimIndent()?.trim()}\n```\n" + + if (message.command == EditorContextCommand.SendToPrompt) { + messagePublisher.publish( + EditorContextCommandMessage( + message = codeSelection, + command = message.command.actionId, + triggerId = triggerId, + ), + ) + return + } + + // Create prompt + val prompt = "${message.command} the following part of my code for me: $codeSelection" + + // Update UI with prompt + messagePublisher.publish( + EditorContextCommandMessage( + message = prompt, + command = message.command.actionId, + triggerId = triggerId, + ), + ) + + // Wait for the tab ID to come back + val tabId = waitForTabId(triggerId) + + if (tabId == NO_TAB_AVAILABLE) { + logger.info { "No tab is available to handle action" } + return + } + + // Get the AI response + handleChat( + tabId = tabId, + triggerId = triggerId, + message = prompt, + activeFileContext = fileContext, + userIntent = intentRecognizer.getUserIntentFromContextMenuCommand(message.command), + message.command.triggerType, + ) + } + + override suspend fun processLinkClick(message: IncomingCwcMessage.ClickedLink) { + BrowserUtil.browse(message.link) + telemetryHelper.recordInteractWithMessage(message) + } + + private suspend fun handleChat( + tabId: String, + triggerId: String, + message: String, + activeFileContext: ActiveFileContext, + userIntent: UserIntent?, + triggerType: TriggerType, + ) { + val credentialState = authController.getAuthNeededState(context.project) + if (credentialState != null) { + sendAuthNeededException( + tabId = tabId, + triggerId = triggerId, + credentialState = credentialState, + ) + return + } + + val requestData = ChatRequestData( + tabId = tabId, + message = message, + activeFileContext = activeFileContext, + userIntent = userIntent, + triggerType = triggerType, + ) + + val sessionInfo = getSessionInfo(tabId) + + // Save the request in the history + sessionInfo.history.add(requestData) + + telemetryHelper.recordEnterFocusConversation(tabId) + telemetryHelper.recordStartConversation(tabId, requestData) + + // Send the request to the API and publish the responses back to the UI. + // This is launched in a scope attached to the sessionInfo so that the Job can be cancelled on a per-session basis. + ChatPromptHandler(telemetryHelper).handle(tabId, triggerId, requestData, sessionInfo) + .catch { handleError(tabId, it) } + .onEach { context.messagesFromAppToUi.publish(it) } + .launchIn(sessionInfo.scope) + } + + private suspend fun handleError(tabId: String, exception: Throwable) { + val requestId = (exception as? ChatApiException)?.requestId + logger.warn(exception) { + "Exception encountered, tabId: $tabId, requestId: $requestId" + } + + val messageContent = buildString { + append("This error is reported to the team automatically. We will attempt to fix it as soon as possible.") + exception.message?.let { + append("\n\nDetails: ") + append(it) + } + if (exception is ChatApiException) { + exception.statusCode?.let { + append("\n\nStatus Code: ") + append(it) + } + append("\n\nSession ID: ") + append(exception.sessionId) + requestId?.let { + append("\n\nRequest ID: ") + append(it) + } + } + } + sendErrorMessage(tabId, messageContent, requestId) + } + + private suspend fun sendErrorMessage(tabId: String, message: String, requestId: String?) { + val errorMessage = ErrorMessage( + tabId = tabId, + title = "An error occurred while processing your request.", + message = message, + messageId = requestId, + ) + messagePublisher.publish(errorMessage) + } + + private suspend fun sendQuickActionMessage(triggerId: String, prompt: StaticPrompt) { + val message = QuickActionMessage( + triggerId = triggerId, + message = prompt.message, + ) + messagePublisher.publish(message) + } + + private suspend fun sendStaticTextResponse(tabId: String, triggerId: String, response: StaticTextResponse) { + val chatMessage = ChatMessage( + tabId = tabId, + triggerId = triggerId, + messageType = ChatMessageType.Answer, + messageId = "static_message_$triggerId", + message = response.message, + followUps = response.followUps.map { + FollowUp( + type = FollowUpType.Generated, + pillText = it, + prompt = it, + ) + }, + followUpsHeader = response.followUpsHeader, + ) + messagePublisher.publish(chatMessage) + } + + private suspend fun sendAuthNeededException(tabId: String, triggerId: String, credentialState: AuthNeededState) { + val message = AuthNeededException( + tabId = tabId, + triggerId = triggerId, + authType = credentialState.authType, + message = credentialState.message, + ) + messagePublisher.publish(message) + } + + private suspend fun sendOnboardingPageInteractionMessage(prompt: String, type: OnboardingPageInteractionType, triggerId: String) { + val message = OnboardingPageInteractionMessage( + message = prompt, + interactionType = type, + triggerId = triggerId, + ) + messagePublisher.publish(message) + } + + private fun getSessionInfo(tabId: String) = chatSessionStorage.getSession(tabId, context.project) + + private suspend fun waitForTabId(triggerId: String) = context.messagesFromUiToApp.flow + .mapNotNull { it as? IncomingCwcMessage.TriggerTabIdReceived } + .filter { it.triggerId == triggerId } + .map { it.tabId } + .first() + + companion object { + private val logger = getLogger() + + // This is a special tabID we can receive to indicate that there is no tab available for handling the context menu action + private const val NO_TAB_AVAILABLE = "no-available-tabs" + + val objectMapper: ObjectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/ReferenceLogController.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/ReferenceLogController.kt new file mode 100644 index 0000000000..6d222e60ff --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/ReferenceLogController.kt @@ -0,0 +1,43 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.controller + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.codewhispererruntime.model.Reference +import software.amazon.awssdk.services.codewhispererruntime.model.Span +import software.aws.toolkits.jetbrains.services.codewhisperer.editor.CodeWhispererEditorUtil +import software.aws.toolkits.jetbrains.services.codewhisperer.toolwindow.CodeWhispererCodeReferenceManager +import software.aws.toolkits.jetbrains.services.cwc.messages.CodeReference + +object ReferenceLogController { + fun addReferenceLog(originalCode: String, codeReferences: List?, editor: Editor, project: Project) { + codeReferences?.let { references -> + val cwReferences = references.map { reference -> + Reference.builder() + .licenseName(reference.licenseName) + .repository(reference.repository) + .url(reference.url) + .recommendationContentSpan( + reference.recommendationContentSpan?.let { span -> + Span.builder() + .start(span.start) + .end(span.end) + .build() + } + ) + .build() + } + val manager = CodeWhispererCodeReferenceManager.getInstance(project) + + manager.insertCodeReference( + originalCode, + cwReferences, + editor, + CodeWhispererEditorUtil.getCaretPosition(editor), + null, + ) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/StaticPrompt.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/StaticPrompt.kt new file mode 100644 index 0000000000..0911f9f1e7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/StaticPrompt.kt @@ -0,0 +1,14 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.controller.chat + +import software.aws.toolkits.resources.message + +enum class StaticPrompt( + val message: String, +) { + Help("What can Amazon Q help me with?"), + OnboardingHelp("What can Amazon Q do and what are some example questions?"), + Transform(message("q.ui.prompt.transform")), +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/StaticTextResponse.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/StaticTextResponse.kt new file mode 100644 index 0000000000..ce2b3576f3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/StaticTextResponse.kt @@ -0,0 +1,60 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.controller.chat + +enum class StaticTextResponse( + val message: String, + val followUpsHeader: String? = null, + val followUps: List = emptyList(), +) { + Help( + message = """ + I'm Amazon Q, a generative AI assistant. Learn more about me below. Your feedback will help me improve. + ### What I can do: + - Answer questions about AWS + - Answer questions about general programming concepts + - Explain what a line of code or code function does + - Write unit tests and code + - Debug and fix code + - Refactor code + ### What I don't do right now: + - Answer questions in languages other than English + - Remember conversations from your previous sessions + - Have information about your AWS account or your specific AWS resources + ### Examples of questions I can answer: + - When should I use ElastiCache? + - How do I create an Application Load Balancer? + - Explain the <selected code> and ask clarifying questions about it. + - What is the syntax of declaring a variable in TypeScript? + ### Special Commands + - /clear - Clear the conversation. + - /transform - Transform your code. Use to upgrade Java code versions. Only available through CodeWhisperer Professional Tier. + - /help - View chat topics and commands. + ### Things to note: + - I may not always provide completely accurate or current information. + - Provide feedback by choosing the like or dislike buttons that appear below answers. + - When you use Amazon Q, AWS may, for service improvement purposes, store data about your usage and content. You can opt-out of sharing this data by following the steps in AI services opt-out policies. See here + - Do not enter any confidential, sensitive, or personal information. + + *For additional help, visit the [Amazon Q User Guide](https://docs.aws.amazon.com/amazonq/latest/aws-builder-use-ug/getting-started.html).* + """.trimIndent(), + ), + OnboardingHelp( + message = """ + ### What I can do: + - Answer questions about AWS + - Answer questions about general programming concepts + - Explain what a line of code or code function does + - Write unit tests and code + - Debug and fix code + - Refactor code + """.trimIndent(), + followUpsHeader = "Try Examples:", + followUps = listOf( + "Should I use AWS Lambda or EC2 for a scalable web application backend?", + "What is the syntax of declaring a variable in TypeScript?", + "Write code for uploading a file to an s3 bucket in typescript", + ), + ), +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/messenger/ChatPromptHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/messenger/ChatPromptHandler.kt new file mode 100644 index 0000000000..337df34d47 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/messenger/ChatPromptHandler.kt @@ -0,0 +1,171 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.controller.chat.messenger + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import software.amazon.awssdk.awscore.exception.AwsServiceException +import software.amazon.awssdk.services.codewhispererstreaming.model.CodeWhispererStreamingException +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.exceptions.ChatApiException +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatRequestData +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatResponseEvent +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.TelemetryHelper +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessageType +import software.aws.toolkits.jetbrains.services.cwc.messages.CodeReference +import software.aws.toolkits.jetbrains.services.cwc.messages.FollowUp +import software.aws.toolkits.jetbrains.services.cwc.messages.RecommendationContentSpan +import software.aws.toolkits.jetbrains.services.cwc.messages.Suggestion +import software.aws.toolkits.jetbrains.services.cwc.storage.ChatSessionInfo + +class ChatPromptHandler(private val telemetryHelper: TelemetryHelper) { + + // The text content sent back to the user is built up over multiple events streamed back from the API + private val responseText = StringBuilder() + private val followUps = mutableListOf() + private val relatedSuggestions = mutableListOf() + private val codeReferences = mutableListOf() + private var requestId: String = "" + private var statusCode: Int = 0 + + fun handle( + tabId: String, + triggerId: String, + data: ChatRequestData, + sessionInfo: ChatSessionInfo, + ) = flow { + val session = sessionInfo.session + session.chat(data) + .onStart { + // The first thing we always send back is an AnswerStream message to indicate the beginning of a streaming answer + val response = + ChatMessage(tabId = tabId, triggerId = triggerId, messageId = requestId, messageType = ChatMessageType.AnswerStream, message = "") + + telemetryHelper.setResponseStreamStartTime(tabId) + emit(response) + } + .onCompletion { error -> + // Don't emit any other responses if we cancelled the collection + if (error is CancellationException) { + return@onCompletion + } + + // Send the gathered suggestions in a final answer-part message + if (relatedSuggestions.isNotEmpty()) { + val suggestionMessage = ChatMessage( + tabId = tabId, + triggerId = triggerId, + messageId = requestId, + messageType = ChatMessageType.AnswerPart, + message = responseText.toString(), + relatedSuggestions = relatedSuggestions, + ) + emit(suggestionMessage) + } + + // Send the Answer message to indicate the end of the response stream + val response = + ChatMessage(tabId = tabId, triggerId = triggerId, messageId = requestId, messageType = ChatMessageType.Answer, followUps = followUps) + + telemetryHelper.setResponseStreamTotalTime(tabId) + telemetryHelper.recordAddMessage(data, response, responseText.length, statusCode) + emit(response) + } + .catch { exception -> + val statusCode = if (exception is AwsServiceException) exception.statusCode() else 0 + telemetryHelper.recordMessageResponseError(data, tabId, statusCode) + if (exception is CodeWhispererStreamingException) { + throw ChatApiException( + message = exception.message ?: "Encountered exception calling the API", + sessionId = session.conversationId, + requestId = exception.requestId(), + statusCode = exception.statusCode(), + cause = exception, + ) + } else { + throw ChatApiException( + message = exception.message ?: "Encountered exception calling the API", + sessionId = session.conversationId, + requestId = null, + statusCode = null, + cause = exception, + ) + } + } + .collect { responseEvent -> + processChatEvent(tabId, triggerId, responseEvent)?.let { emit(it) } + } + } + + private fun processChatEvent(tabId: String, triggerId: String, event: ChatResponseEvent): ChatMessage? { + requestId = event.requestId + statusCode = event.statusCode + + if (event.codeReferences != null) { + codeReferences += event.codeReferences.map { reference -> + CodeReference( + licenseName = reference.licenseName, + repository = reference.repository, + url = reference.url, + recommendationContentSpan = RecommendationContentSpan( + reference.recommendationContentSpan?.start ?: 0, + reference.recommendationContentSpan?.end ?: 0, + ), + information = "Reference code under **${reference.licenseName}** license from repository `${reference.repository}`", + ) + } + } + + if (event.suggestions != null) { + var index = 0 + relatedSuggestions += event.suggestions.map { apiSuggestion -> + Suggestion( + title = apiSuggestion.title, + url = apiSuggestion.url, + body = apiSuggestion.body, + id = index++, + type = apiSuggestion.type, + context = apiSuggestion.context, + ) + } + } + + if (event.followUps != null) { + event.followUps.forEach { item -> + item.pillText?.let { + followUps += FollowUp( + type = item.type, + pillText = item.pillText, + prompt = item.prompt ?: item.pillText, + ) + } + item.message?.let { + followUps += FollowUp( + type = item.type, + pillText = item.message, + prompt = item.message, + ) + } + } + } + + return if (event.token != null) { + responseText.append(event.token) + telemetryHelper.setResponseStreamTimeForChunks(tabId) + ChatMessage( + tabId = tabId, + triggerId = triggerId, + messageId = event.requestId, + messageType = ChatMessageType.AnswerPart, + message = responseText.toString(), + codeReference = codeReferences, + ) + } else { + null + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/prompts/PromptsGenerator.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/prompts/PromptsGenerator.kt new file mode 100644 index 0000000000..bd3e85833c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/prompts/PromptsGenerator.kt @@ -0,0 +1,11 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.controller.chat.prompts + +class PromptsGenerator { + public fun getPromptForCommandVerb(commandVerb: String, selectedCode: String): String { + val trimSelectedCode = selectedCode.trimStart().trimEnd() + return "$commandVerb the following part of my code to me: $trimSelectedCode" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/InsertedCodeModificationEntry.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/InsertedCodeModificationEntry.kt new file mode 100644 index 0000000000..e3fb0fae8a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/InsertedCodeModificationEntry.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry + +import com.intellij.openapi.editor.RangeMarker +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.UserModificationTrackingEntry +import java.time.Instant + +data class InsertedCodeModificationEntry( + val conversationId: String, + val messageId: String, + override val time: Instant, + val vFile: VirtualFile?, + val range: RangeMarker, + val originalString: String +) : UserModificationTrackingEntry diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt new file mode 100644 index 0000000000..faa1e27eca --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/telemetry/TelemetryHelper.kt @@ -0,0 +1,366 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry + +import software.amazon.awssdk.services.codewhispererstreaming.model.UserIntent +import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatRequestData +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.TriggerType +import software.aws.toolkits.jetbrains.services.cwc.controller.ChatController +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.IncomingCwcMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.LinkType +import software.aws.toolkits.jetbrains.services.cwc.storage.ChatSessionStorage +import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.AmazonqTelemetry +import software.aws.toolkits.telemetry.CwsprChatCommandType +import software.aws.toolkits.telemetry.CwsprChatConversationType +import software.aws.toolkits.telemetry.CwsprChatInteractionType +import software.aws.toolkits.telemetry.CwsprChatTriggerInteraction +import software.aws.toolkits.telemetry.CwsprChatUserIntent +import software.aws.toolkits.telemetry.FeedbackTelemetry +import java.time.Duration +import java.time.Instant + +class TelemetryHelper(private val context: AmazonQAppInitContext, private val sessionStorage: ChatSessionStorage) { + + private val responseStreamStartTime: MutableMap = mutableMapOf() + private val responseStreamTotalTime: MutableMap = mutableMapOf() + private val responseStreamTimeForChunks: MutableMap> = mutableMapOf() + + fun getConversationId(tabId: String): String? = sessionStorage.getSession(tabId)?.session?.conversationId + + private fun getTelemetryUserIntent(userIntent: UserIntent): CwsprChatUserIntent = when (userIntent) { + UserIntent.SUGGEST_ALTERNATE_IMPLEMENTATION -> CwsprChatUserIntent.SuggestAlternateImplementation + UserIntent.APPLY_COMMON_BEST_PRACTICES -> CwsprChatUserIntent.ApplyCommonBestPractices + UserIntent.IMPROVE_CODE -> CwsprChatUserIntent.ImproveCode + UserIntent.SHOW_EXAMPLES -> CwsprChatUserIntent.ShowExample + UserIntent.CITE_SOURCES -> CwsprChatUserIntent.CiteSources + UserIntent.EXPLAIN_LINE_BY_LINE -> CwsprChatUserIntent.ExplainLineByLine + UserIntent.EXPLAIN_CODE_SELECTION -> CwsprChatUserIntent.ExplainCodeSelection + UserIntent.UNKNOWN_TO_SDK_VERSION -> CwsprChatUserIntent.Unknown + } + + private fun getTelemetryTriggerType(triggerType: TriggerType): CwsprChatTriggerInteraction = when (triggerType) { + TriggerType.Click -> CwsprChatTriggerInteraction.Click + TriggerType.ContextMenu, TriggerType.Hotkeys -> CwsprChatTriggerInteraction.ContextMenu + } + + // When chat panel is focused + fun recordEnterFocusChat() { + AmazonqTelemetry.enterFocusChat(passive = true) + } + + // When chat panel is unfocused + fun recordExitFocusChat() { + AmazonqTelemetry.exitFocusChat(passive = true) + } + + fun recordStartConversation(tabId: String, data: ChatRequestData) { + val sessionHistory = sessionStorage.getSession(tabId)?.history ?: return + if (sessionHistory.size > 1) return + + AmazonqTelemetry.startConversation( + cwsprChatConversationId = getConversationId(tabId).orEmpty(), + cwsprChatTriggerInteraction = getTelemetryTriggerType(data.triggerType), + cwsprChatConversationType = CwsprChatConversationType.Chat, + cwsprChatUserIntent = data.userIntent?.let { getTelemetryUserIntent(it) }, + cwsprChatHasCodeSnippet = data.activeFileContext.focusAreaContext?.codeSelection?.isNotEmpty() ?: false, + cwsprChatProgrammingLanguage = data.activeFileContext.fileContext?.fileLanguage, + ) + } + + // When Chat API responds to a user message (full response streamed) + fun recordAddMessage(data: ChatRequestData, response: ChatMessage, responseLength: Int, statusCode: Int) { + AmazonqTelemetry.addMessage( + cwsprChatConversationId = getConversationId(response.tabId).orEmpty(), + cwsprChatMessageId = response.messageId, + cwsprChatTriggerInteraction = getTelemetryTriggerType(data.triggerType), + cwsprChatUserIntent = data.userIntent?.let { getTelemetryUserIntent(it) }, + cwsprChatHasCodeSnippet = data.activeFileContext.focusAreaContext?.codeSelection?.isNotEmpty() ?: false, + cwsprChatProgrammingLanguage = data.activeFileContext.fileContext?.fileLanguage, + cwsprChatActiveEditorTotalCharacters = data.activeFileContext.focusAreaContext?.codeSelection?.length, + cwsprChatActiveEditorImportCount = data.activeFileContext.focusAreaContext?.codeNames?.fullyQualifiedNames?.used?.size, + cwsprChatResponseCodeSnippetCount = 0, + cwsprChatResponseCode = statusCode, + cwsprChatSourceLinkCount = response.relatedSuggestions?.size, + cwsprChatReferencesCount = 0, // TODO + cwsprChatFollowUpCount = response.followUps?.size, + cwsprChatTimeToFirstChunk = getResponseStreamTimeToFirstChunk(response.tabId), + cwsprChatTimeBetweenChunks = "", // getResponseStreamTimeBetweenChunks(response.tabId), //TODO: allow '[', ']' and ',' chars + cwsprChatFullResponseLatency = responseStreamTotalTime[response.tabId] ?: 0, + cwsprChatRequestLength = data.message.length, + cwsprChatResponseLength = responseLength, + cwsprChatConversationType = CwsprChatConversationType.Chat, + ) + + val metadata: Map = mapOf( + "cwsprChatConversationId" to getConversationId(response.tabId).orEmpty(), + "cwsprChatMessageId" to response.messageId, + "cwsprChatTriggerInteraction" to getTelemetryTriggerType(data.triggerType), + "cwsprChatUserIntent" to data.userIntent?.let { getTelemetryUserIntent(it) }, + "cwsprChatHasCodeSnippet" to (data.activeFileContext.focusAreaContext?.codeSelection?.isNotEmpty() ?: false), + "cwsprChatProgrammingLanguage" to data.activeFileContext.fileContext?.fileLanguage, + "cwsprChatActiveEditorTotalCharacters" to data.activeFileContext.focusAreaContext?.codeSelection?.length, + "cwsprChatActiveEditorImportCount" to data.activeFileContext.focusAreaContext?.codeNames?.fullyQualifiedNames?.used?.size, + "cwsprChatResponseCodeSnippetCount" to 0, + "cwsprChatResponseCode" to statusCode, + "cwsprChatSourceLinkCount" to response.relatedSuggestions?.size, + "cwsprChatReferencesCount" to 0, // TODO + "cwsprChatFollowUpCount" to response.followUps?.size, + "cwsprChatTimeToFirstChunk" to getResponseStreamTimeToFirstChunk(response.tabId), + "cwsprChatTimeBetweenChunks" to "", // getResponseStreamTimeBetweenChunks(response.tabId), //TODO: allow '[', ']' and ',' chars + "cwsprChatFullResponseLatency" to (responseStreamTotalTime[response.tabId] ?: 0), + "cwsprChatRequestLength" to data.message.length, + "cwsprChatResponseLength" to responseLength, + "cwsprChatConversationType" to CwsprChatConversationType.Chat + ) + sendMetricData("amazonq_addMessage", metadata) + } + + fun recordMessageResponseError(data: ChatRequestData, tabId: String, responseCode: Int) { + AmazonqTelemetry.messageResponseError( + cwsprChatConversationId = getConversationId(tabId).orEmpty(), + cwsprChatTriggerInteraction = getTelemetryTriggerType(data.triggerType), + cwsprChatUserIntent = data.userIntent?.let { getTelemetryUserIntent(it) }, + cwsprChatHasCodeSnippet = data.activeFileContext.focusAreaContext?.codeSelection?.isNotEmpty() ?: false, + cwsprChatProgrammingLanguage = data.activeFileContext.fileContext?.fileLanguage, + cwsprChatActiveEditorTotalCharacters = data.activeFileContext.focusAreaContext?.codeSelection?.length, + cwsprChatActiveEditorImportCount = data.activeFileContext.focusAreaContext?.codeNames?.fullyQualifiedNames?.used?.size, + cwsprChatResponseCode = responseCode, + cwsprChatRequestLength = data.message.length, + cwsprChatConversationType = CwsprChatConversationType.Chat, + ) + } + + // When user interacts with a message (e.g. copy code, insert code, vote) + suspend fun recordInteractWithMessage(message: IncomingCwcMessage) { + val metadata: Map = when (message) { + is IncomingCwcMessage.ChatItemVoted -> { + AmazonqTelemetry.interactWithMessage( + cwsprChatConversationId = getConversationId(message.tabId).orEmpty(), + cwsprChatMessageId = message.messageId, + cwsprChatInteractionType = when (message.vote) { + "upvote" -> CwsprChatInteractionType.Upvote + "downvote" -> CwsprChatInteractionType.Downvote + else -> CwsprChatInteractionType.Unknown + }, + ) + mapOf( + "cwsprChatConversationId" to getConversationId(message.tabId).orEmpty(), + "cwsprChatMessageId" to message.messageId, + "cwsprChatInteractionType" to when (message.vote) { + "upvote" -> CwsprChatInteractionType.Upvote + "downvote" -> CwsprChatInteractionType.Downvote + else -> CwsprChatInteractionType.Unknown + } + ) + } + + is IncomingCwcMessage.FollowupClicked -> { + AmazonqTelemetry.interactWithMessage( + cwsprChatConversationId = getConversationId(message.tabId).orEmpty(), + cwsprChatMessageId = message.messageId.orEmpty(), + cwsprChatInteractionType = CwsprChatInteractionType.ClickFollowUp, + ) + mapOf( + "cwsprChatConversationId" to getConversationId(message.tabId).orEmpty(), + "cwsprChatMessageId" to message.messageId.orEmpty(), + "cwsprChatInteractionType" to CwsprChatInteractionType.ClickFollowUp, + ) + } + + is IncomingCwcMessage.CopyCodeToClipboard -> { + AmazonqTelemetry.interactWithMessage( + cwsprChatConversationId = getConversationId(message.tabId).orEmpty(), + cwsprChatMessageId = message.messageId, + cwsprChatInteractionType = CwsprChatInteractionType.CopySnippet, + cwsprChatAcceptedCharactersLength = message.code.length, + cwsprChatInteractionTarget = message.insertionTargetType, + cwsprChatHasReference = null, + ) + mapOf( + "cwsprChatConversationId" to getConversationId(message.tabId).orEmpty(), + "cwsprChatMessageId" to message.messageId, + "cwsprChatInteractionType" to CwsprChatInteractionType.CopySnippet, + "cwsprChatAcceptedCharactersLength" to message.code.length, + "cwsprChatInteractionTarget" to message.insertionTargetType, + "cwsprChatHasReference" to null, + ) + } + + is IncomingCwcMessage.InsertCodeAtCursorPosition -> { + AmazonqTelemetry.interactWithMessage( + cwsprChatConversationId = getConversationId(message.tabId).orEmpty(), + cwsprChatMessageId = message.messageId, + cwsprChatInteractionType = CwsprChatInteractionType.InsertAtCursor, + cwsprChatAcceptedCharactersLength = message.code.length, + cwsprChatInteractionTarget = message.insertionTargetType, + cwsprChatHasReference = null, + ) + mapOf( + "cwsprChatConversationId" to getConversationId(message.tabId).orEmpty(), + "cwsprChatMessageId" to message.messageId, + "cwsprChatInteractionType" to CwsprChatInteractionType.InsertAtCursor, + "cwsprChatAcceptedCharactersLength" to message.code.length, + "cwsprChatInteractionTarget" to message.insertionTargetType, + "cwsprChatHasReference" to null, + ) + } + + is IncomingCwcMessage.ClickedLink -> { + // Null when internal Amazon link is clicked + if (message.messageId == null) return + + val linkInteractionType = when (message.type) { + LinkType.SourceLink -> CwsprChatInteractionType.ClickLink + LinkType.BodyLink -> CwsprChatInteractionType.ClickBodyLink + else -> CwsprChatInteractionType.Unknown + } + AmazonqTelemetry.interactWithMessage( + cwsprChatConversationId = getConversationId(message.tabId).orEmpty(), + cwsprChatMessageId = message.messageId, + cwsprChatInteractionType = linkInteractionType, + cwsprChatInteractionTarget = message.link, + cwsprChatHasReference = null, + ) + mapOf( + "cwsprChatConversationId" to getConversationId(message.tabId).orEmpty(), + "cwsprChatMessageId" to message.messageId, + "cwsprChatInteractionType" to linkInteractionType, + "cwsprChatInteractionTarget" to message.link, + "cwsprChatHasReference" to null, + ) + } + + is IncomingCwcMessage.ChatItemFeedback -> { + recordFeedback(message) + emptyMap() + } + + else -> emptyMap() + } + + sendMetricData("amazonq_interactWithMessage", metadata) + } + + private suspend fun recordFeedback(message: IncomingCwcMessage.ChatItemFeedback) { + val comment = FeedbackComment( + conversationId = getConversationId(message.tabId).orEmpty(), + messageId = message.messageId, + reason = message.selectedOption, + userComment = message.comment.orEmpty(), + ) + + try { + TelemetryService.getInstance().sendFeedback( + sentiment = Sentiment.NEGATIVE, + comment = ChatController.objectMapper.writeValueAsString(comment), + ) + logger.info { "CodeWhispererChat answer feedback sent: \"Negative\"" } + recordFeedbackResult(true) + } catch (e: Throwable) { + e.notifyError(message("feedback.submit_failed", e)) + logger.warn(e) { "Failed to submit feedback" } + recordFeedbackResult(false) + return + } + } + + private fun recordFeedbackResult(success: Boolean) { + FeedbackTelemetry.result(project = null, success = success) + } + + // When a conversation(tab) is focused + fun recordEnterFocusConversation(tabId: String) { + getConversationId(tabId)?.let { + AmazonqTelemetry.enterFocusConversation( + cwsprChatConversationId = it, + ) + } + } + + // When a conversation(tab) is unfocused + fun recordExitFocusConversation(tabId: String) { + getConversationId(tabId)?.let { + AmazonqTelemetry.exitFocusConversation( + cwsprChatConversationId = it, + ) + } + } + + fun setResponseStreamStartTime(tabId: String) { + responseStreamStartTime[tabId] = Instant.now() + responseStreamTimeForChunks[tabId] = mutableListOf(Instant.now()) + } + + fun setResponseStreamTimeForChunks(tabId: String) { + val chunkTimes = responseStreamTimeForChunks.getOrPut(tabId) { mutableListOf() } + chunkTimes += Instant.now() + } + + fun setResponseStreamTotalTime(tabId: String) { + val totalTime = Duration.between(responseStreamStartTime[tabId], Instant.now()).toMillis().toInt() + responseStreamTotalTime[tabId] = totalTime + } + + private fun getResponseStreamTimeToFirstChunk(tabId: String): Int { + val chunkTimes = responseStreamTimeForChunks[tabId] ?: return 0 + if (chunkTimes.size == 1) return Duration.between(chunkTimes[0], Instant.now()).toMillis().toInt() + return Duration.between(chunkTimes[0], chunkTimes[1]).toMillis().toInt() + } + +// private fun getResponseStreamTimeBetweenChunks(tabId: String): String = try { +// val chunkDeltaTimes = mutableListOf() +// val chunkTimes = responseStreamTimeForChunks[tabId] ?: listOf(Instant.now()) +// for (idx in 0 until (chunkTimes.size - 1)) { +// chunkDeltaTimes += Duration.between(chunkTimes[idx], chunkTimes[idx + 1]).toMillis().toInt() +// } +// +// val joined = "[" + chunkDeltaTimes.joinToString(",").take(2000) +// val fixed = if (joined.last() == ',') "${joined}0" else joined +// if (fixed.last() == ']') fixed else "$fixed]" +// } catch (e: Exception) { +// "[-1]" +// } + + private fun sendMetricData(eventName: String, metadata: Map) { + try { + CodeWhispererClientAdaptor.getInstance(context.project).sendMetricDataTelemetry(eventName, metadata) + } catch (e: Throwable) { + logger.warn(e) { "Failed to send metric data" } + } + } + + companion object { + private val logger = getLogger() + + fun recordOpenChat() { + AmazonqTelemetry.openChat(passive = true) + } + + fun recordCloseChat() { + AmazonqTelemetry.closeChat(passive = true) + } + + fun recordTelemetryChatRunCommand(type: CwsprChatCommandType, name: String? = null) { + AmazonqTelemetry.runCommand(cwsprChatCommandType = type, cwsprChatCommandName = name) + } + } +} + +data class FeedbackComment( + val conversationId: String, + val messageId: String, + val reason: String, + val userComment: String, + val type: String = "codewhisperer-chat-answer-feedback", +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/userIntent/UserIntentRecognizer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/userIntent/UserIntentRecognizer.kt new file mode 100644 index 0000000000..394281e710 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/controller/chat/userIntent/UserIntentRecognizer.kt @@ -0,0 +1,43 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.controller.chat.userIntent + +import software.amazon.awssdk.services.codewhispererstreaming.model.UserIntent +import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteraction +import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteractionType +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.FollowUpType +import software.aws.toolkits.jetbrains.services.cwc.commands.EditorContextCommand + +class UserIntentRecognizer { + fun getUserIntentFromContextMenuCommand(command: EditorContextCommand) = when (command) { + EditorContextCommand.Explain -> UserIntent.EXPLAIN_CODE_SELECTION + EditorContextCommand.Refactor -> UserIntent.SUGGEST_ALTERNATE_IMPLEMENTATION + EditorContextCommand.Fix -> UserIntent.APPLY_COMMON_BEST_PRACTICES + EditorContextCommand.Optimize -> UserIntent.IMPROVE_CODE + EditorContextCommand.SendToPrompt -> null + } + + fun getUserIntentFromPromptChatMessage(prompt: String) = when { + prompt.startsWith("Explain") -> UserIntent.EXPLAIN_CODE_SELECTION + prompt.startsWith("Refactor") -> UserIntent.SUGGEST_ALTERNATE_IMPLEMENTATION + prompt.startsWith("Fix") -> UserIntent.APPLY_COMMON_BEST_PRACTICES + prompt.startsWith("Optimize") -> UserIntent.IMPROVE_CODE + else -> null + } + + fun getUserIntentFromFollowupType(type: FollowUpType) = when (type) { + FollowUpType.Alternatives -> UserIntent.SUGGEST_ALTERNATE_IMPLEMENTATION + FollowUpType.CommonPractices -> UserIntent.APPLY_COMMON_BEST_PRACTICES + FollowUpType.Improvements -> UserIntent.IMPROVE_CODE + FollowUpType.MoreExamples -> UserIntent.SHOW_EXAMPLES + FollowUpType.CiteSources -> UserIntent.CITE_SOURCES + FollowUpType.LineByLine -> UserIntent.EXPLAIN_LINE_BY_LINE + FollowUpType.ExplainInDetail -> UserIntent.EXPLAIN_CODE_SELECTION + FollowUpType.Generated -> null + } + + fun getUserIntentFromOnboardingPageInteraction(interaction: OnboardingPageInteraction) = when (interaction.type) { + OnboardingPageInteractionType.CwcButtonClick -> null + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContext.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContext.kt new file mode 100644 index 0000000000..f2e7903745 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContext.kt @@ -0,0 +1,12 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context + +import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.FileContext +import software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea.FocusAreaContext + +data class ActiveFileContext( + val fileContext: FileContext?, + val focusAreaContext: FocusAreaContext?, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContextExtractor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContextExtractor.kt new file mode 100644 index 0000000000..099d2f56c8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ActiveFileContextExtractor.kt @@ -0,0 +1,40 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.amazonq.webview.FqnWebviewAdapter +import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.FileContextExtractor +import software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea.FocusAreaContextExtractor + +class ActiveFileContextExtractor( + private val fileContextExtractor: FileContextExtractor, + private val focusAreaContextExtractor: FocusAreaContextExtractor, +) { + + suspend fun extractContextForTrigger(triggerType: ExtractionTriggerType) = + ActiveFileContext( + extractActiveFileContext(triggerType), + extractFocusAreaContext(triggerType), + ) + + private suspend fun extractFocusAreaContext(triggerType: ExtractionTriggerType) = when (triggerType) { + ExtractionTriggerType.ChatMessage -> focusAreaContextExtractor.extract() + ExtractionTriggerType.ContextMenu -> focusAreaContextExtractor.extract() + ExtractionTriggerType.OnboardingPageInteraction -> null + } + + private suspend fun extractActiveFileContext(triggerType: ExtractionTriggerType) = when (triggerType) { + ExtractionTriggerType.ChatMessage -> fileContextExtractor.extract() + ExtractionTriggerType.ContextMenu -> fileContextExtractor.extract() + ExtractionTriggerType.OnboardingPageInteraction -> null + } + + companion object { + fun create(fqnWebviewAdapter: FqnWebviewAdapter, project: Project) = ActiveFileContextExtractor( + fileContextExtractor = FileContextExtractor(fqnWebviewAdapter, project), + focusAreaContextExtractor = FocusAreaContextExtractor(fqnWebviewAdapter, project), + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ExtractionTriggerType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ExtractionTriggerType.kt new file mode 100644 index 0000000000..c33461ff20 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/ExtractionTriggerType.kt @@ -0,0 +1,10 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context + +enum class ExtractionTriggerType { + ChatMessage, + ContextMenu, + OnboardingPageInteraction +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContext.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContext.kt new file mode 100644 index 0000000000..0c9f0f2230 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContext.kt @@ -0,0 +1,12 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context.file + +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.MatchPolicy + +data class FileContext( + val fileLanguage: String?, + val filePath: String?, + val matchPolicy: MatchPolicy?, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContextExtractor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContextExtractor.kt new file mode 100644 index 0000000000..1998c3fe32 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/FileContextExtractor.kt @@ -0,0 +1,50 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context.file + +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile +import software.aws.toolkits.jetbrains.services.amazonq.webview.FqnWebviewAdapter +import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.util.LanguageExtractor +import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.util.MatchPolicyExtractor +import software.aws.toolkits.jetbrains.utils.computeOnEdt + +class FileContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter, private val project: Project) { + private val languageExtractor: LanguageExtractor = LanguageExtractor() + suspend fun extract(): FileContext? { + val editor = computeOnEdt { + FileEditorManager.getInstance(project).selectedTextEditor + } ?: return null + + val fileLanguage = computeOnEdt { + languageExtractor.extractLanguageNameFromCurrentFile(editor, project) + } + val fileText = computeOnEdt { + editor.document.text + } + + val filePath = runReadAction { + val doc: Document = editor.document + val psiFile: PsiFile? = PsiDocumentManager.getInstance(project).getPsiFile(doc) + psiFile?.virtualFile?.path + } + + val matchPolicy = MatchPolicyExtractor.extractMatchPolicyFromCurrentFile( + isCodeSelected = false, + fileLanguage = fileLanguage, + fileText = fileText, + fqnWebviewAdapter, + ) + + return FileContext( + fileLanguage = fileLanguage, + filePath = filePath, + matchPolicy = matchPolicy, + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt new file mode 100644 index 0000000000..a63cc67d42 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt @@ -0,0 +1,20 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context.file.util + +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiFile + +class LanguageExtractor { + fun extractLanguageNameFromCurrentFile(editor: Editor, project: Project): String? = + runReadAction { + val doc: Document = editor.document + val psiFile: PsiFile? = PsiDocumentManager.getInstance(project).getPsiFile(doc) + psiFile?.fileType?.name?.lowercase() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/MatchPolicyExtractor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/MatchPolicyExtractor.kt new file mode 100644 index 0000000000..0585407bca --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/MatchPolicyExtractor.kt @@ -0,0 +1,130 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context.file.util + +import com.fasterxml.jackson.module.kotlin.readValue +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.amazonq.webview.FqnWebviewAdapter +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.MatchPolicy +import software.aws.toolkits.jetbrains.services.cwc.controller.ChatController + +object MatchPolicyExtractor { + suspend fun extractMatchPolicyFromCurrentFile( + isCodeSelected: Boolean = false, + fileLanguage: String?, + fileText: String?, + fqnWebviewAdapter: FqnWebviewAdapter, + ): MatchPolicy? { + val should = extractAdditionalLanguageMatchPolicies(fileLanguage) + + val must = mutableSetOf() + + if (fileLanguage == null || fileText == null) return MatchPolicy() + + if (isCodeSelected) must.add(fileLanguage) else should.add(fileLanguage) + + val readImportsRequest = ReadImportsRequest(fileText, fileLanguage) + val requestString = ChatController.objectMapper.writeValueAsString(readImportsRequest) + + return try { + val importsString = fqnWebviewAdapter.readImports(requestString) + val imports = ChatController.objectMapper.readValue>(importsString) + + imports + .filterIndexed { index, elem -> index == imports.indexOf(elem) && elem != fileLanguage } + .forEach { importKey -> should.add(importKey) } + MatchPolicy(must, should) + } catch (e: Exception) { + getLogger().warn(e) { "Failed to extract imports from file" } + null + } + } + + private fun extractAdditionalLanguageMatchPolicies(languageId: String?): MutableSet { + if (languageId == null) { + return mutableSetOf() + } + + if ( + languages.contains(languageId) + ) { + return mutableSetOf() + } + + return when (languageId) { + "bat" -> mutableSetOf("windows") + "cpp", "csharp", "fsharp", "git-commit", "git-rebase", "objective-c", "objective-cpp", + "plaintext", "jade", "shellscript", "vb", + -> mutableSetOf() + + "cuda-cpp" -> mutableSetOf("cuda") + "dockerfile" -> mutableSetOf("docker") + "javascriptreact", "typescriptreact" -> mutableSetOf("react") + "jsonc" -> mutableSetOf("comments") + "razor" -> mutableSetOf("html") + "scss" -> mutableSetOf("scss", "css") + "vue-html" -> mutableSetOf("html") + else -> { + if (listOf("javascript", "node").any { identifier -> languageId.contains(identifier) } || + languageId.contains("typescript") || + languageId.contains("python") + ) { + mutableSetOf() + } else { + mutableSetOf() + } + } + } + } + + val languages = listOf( + "yaml", + "xsl", + "xml", + "vue", + "tex", + "typescript", + "swift", + "stylus", + "sql", + "slim", + "shaderlab", + "sass", + "rust", + "ruby", + "r", + "python", + "pug", + "powershell", + "php", + "perl", + "markdown", + "makefile", + "lua", + "less", + "latex", + "json", + "javascript", + "java", + "ini", + "html", + "haml", + "handlebars", + "groovy", + "go", + "diff", + "css", + "c", + "coffeescript", + "clojure", + "bibtex", + "abap", + ) +} + +data class ReadImportsRequest( + val fileContent: String, + val language: String, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContext.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContext.kt new file mode 100644 index 0000000000..f2b64a04c6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContext.kt @@ -0,0 +1,13 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea + +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.CodeNames + +data class FocusAreaContext( + val codeSelection: String?, + val codeSelectionRange: UICodeSelectionRange?, + val trimmedSurroundingFileText: String?, + val codeNames: CodeNames?, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt new file mode 100644 index 0000000000..ceadc7c41d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt @@ -0,0 +1,207 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.LogicalPosition +import com.intellij.openapi.editor.SelectionModel +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.amazonq.webview.FqnWebviewAdapter +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.CodeNames +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.CodeNamesImpl +import software.aws.toolkits.jetbrains.services.cwc.controller.ChatController +import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.util.LanguageExtractor +import software.aws.toolkits.jetbrains.utils.computeOnEdt +import java.awt.Point +import kotlin.math.min + +class FocusAreaContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter, private val project: Project) { + + private val languageExtractor: LanguageExtractor = LanguageExtractor() + suspend fun extract(): FocusAreaContext? { + val editor = computeOnEdt { + FileEditorManager.getInstance(project).selectedTextEditor + } ?: return null + + if (editor.document.text.isBlank()) return null + + // Get 10k characters around the cursor + val (trimmedFileText, trimmedFileTextSelection) = computeOnEdt { + val (start, end) = getOffsetRangeAtCursor(MAX_LENGTH, editor) + val fileTextStartPos = editor.offsetToLogicalPosition(start) + val fileTextEndPos = editor.offsetToLogicalPosition(end) + val trimmedFileText = getTextAtOffsets(fileTextStartPos, fileTextEndPos, editor) + val trimmedFileTextSelection = UICodeSelectionRange( + start = UICodeSelectionLineRange( + row = fileTextStartPos.line, + column = fileTextStartPos.column, + ), + end = UICodeSelectionLineRange( + row = fileTextEndPos.line, + column = fileTextEndPos.column, + ), + ) + Pair(trimmedFileText, trimmedFileTextSelection) + } + + // Get user selected code or visible text area + val (codeSelection, codeSelectionRange) = computeOnEdt { + val selectionModel: SelectionModel = editor.selectionModel + val selectedText = selectionModel.selectedText + if (selectedText == null) { + // Get visible area text + val visibleArea = editor.scrollingModel.visibleArea + val startOffset = editor.xyToLogicalPosition(Point(visibleArea.x, visibleArea.y)) + val endOffset = editor.xyToLogicalPosition(Point(visibleArea.x + visibleArea.width, visibleArea.y + visibleArea.height)) + + // Get text of visible area + val visibleAreaText = getTextAtOffsets(startOffset, endOffset, editor) + + // If visible area text too big use trimmedSurroundingText + if (visibleAreaText.length > MAX_LENGTH) { + Pair(trimmedFileText, trimmedFileTextSelection) + } else { + // Ensure end line isn't beyond the end of the document + val endLine = min(endOffset.line, editor.document.lineCount - 1) + val endColumn = min(endOffset.column, visibleAreaText.lengthOfLastLine() - 1) + + val codeSelectionRange = UICodeSelectionRange( + start = UICodeSelectionLineRange( + row = startOffset.line, + column = startOffset.column, + ), + end = UICodeSelectionLineRange( + row = endLine, + column = endColumn, + ), + ) + Pair(visibleAreaText, codeSelectionRange) + } + } else if (selectedText.length > MAX_LENGTH) { + Pair(trimmedFileText, trimmedFileTextSelection) + } else { + // Use selected text ranges + val selectedStartPos = editor.offsetToLogicalPosition(selectionModel.selectionStart) + val selectedEndPos = editor.offsetToLogicalPosition(selectionModel.selectionEnd) + + val codeSelectionRange = UICodeSelectionRange( + start = UICodeSelectionLineRange( + row = selectedStartPos.line, + column = selectedStartPos.column, + ), + end = UICodeSelectionLineRange( + row = selectedEndPos.line, + column = selectedEndPos.column, + ), + ) + Pair(selectedText, codeSelectionRange) + } + } + + // Retrieve from trimmedFileText + val fileLanguage = computeOnEdt { + languageExtractor.extractLanguageNameFromCurrentFile(editor, project) + } + val fileText = editor.document.text + val fileName = FileEditorManager.getInstance(project).selectedFiles.first().name + + // Offset the selection range to the start of the trimmedFileText + val selectionInsideTrimmedFileTextRange = codeSelectionRange.let { + UICodeSelectionRange( + start = UICodeSelectionLineRange( + row = it.start.row - trimmedFileTextSelection.start.row, + column = it.start.column + ), + end = UICodeSelectionLineRange( + row = it.end.row - trimmedFileTextSelection.start.row, + column = it.end.column + ), + ) + } + + var codeNames: CodeNames? = null + if (fileLanguage != null) { + val extractNamesRequest = ExtractNamesRequest( + language = fileLanguage, + fileContent = fileText, + codeSelection = UICodeSelection( + selectedCode = trimmedFileText, + file = UICodeSelectionFile( + name = fileName, + range = selectionInsideTrimmedFileTextRange, + ), + ), + ) + val requestString = ChatController.objectMapper.writeValueAsString(extractNamesRequest) + + codeNames = try { + val namesString = fqnWebviewAdapter.extractNames(requestString) + ChatController.objectMapper.readValue(namesString, CodeNamesImpl::class.java) + } catch (e: Exception) { + getLogger().warn(e) { "Failed to extract names from file" } + null + } + } + + return FocusAreaContext( + codeSelection = codeSelection, + codeSelectionRange = selectionInsideTrimmedFileTextRange, + trimmedSurroundingFileText = trimmedFileText, + codeNames = codeNames, + ) + } + + private fun getTextAtOffsets(startOffset: LogicalPosition, endOffset: LogicalPosition, editor: Editor): String { + val startInt = editor.logicalPositionToOffset(startOffset) + val endInt = editor.logicalPositionToOffset(endOffset) + + return editor.document.getText(TextRange(startInt, endInt)) + } + + // Get 10k characters range around the cursor + private fun getOffsetRangeAtCursor(maxCharacters: Int, editor: Editor): Pair { + // Get cursor position + val caretModel = editor.caretModel + val offset = caretModel.offset + + // Get entire file text + val document = editor.document + val fileText = document.text + + // Calculate the start and end offsets + val halfMaxCharacters = maxCharacters / 2 + val startOffset = 0.coerceAtLeast(offset - halfMaxCharacters) + val endOffset = fileText.length.coerceAtMost(offset + halfMaxCharacters) + + // Adjust the start and end offsets if necessary to ensure a total of 10k characters + val excessCharacters = maxCharacters - (endOffset - startOffset) + val adjustedStartOffset = 0.coerceAtLeast(startOffset - excessCharacters) + val adjustedEndOffset = fileText.length.coerceAtMost(endOffset + excessCharacters) + + return Pair(adjustedStartOffset, adjustedEndOffset) + } + + private fun String.lengthOfLastLine(): Int { + for (i in length - 1 downTo 0) { + if (this[i] == '\n') { + return length - i + } + } + return length + } + + companion object { + const val MAX_LENGTH = 10000 + } +} + +data class ExtractNamesRequest( + val fileContent: String, + val language: String, + val codeSelection: UICodeSelection, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/UICodeSelection.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/UICodeSelection.kt new file mode 100644 index 0000000000..b80ed00cd4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/UICodeSelection.kt @@ -0,0 +1,14 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea + +data class UICodeSelection( + val selectedCode: String = "", + val file: UICodeSelectionFile, +) + +data class UICodeSelectionRange(val start: UICodeSelectionLineRange, val end: UICodeSelectionLineRange) +data class UICodeSelectionLineRange(val row: Int, val column: Int) + +data class UICodeSelectionFile(val name: String, val range: UICodeSelectionRange) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/exceptions/ChatException.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/exceptions/ChatException.kt new file mode 100644 index 0000000000..6200a580cd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/exceptions/ChatException.kt @@ -0,0 +1,12 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.exceptions + +/** + * Base class for exceptions thrown by this app + */ +open class ChatException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/messages/CwcMessage.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/messages/CwcMessage.kt new file mode 100644 index 0000000000..928fe32288 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/messages/CwcMessage.kt @@ -0,0 +1,249 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.messages + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonValue +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage +import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteractionType +import software.aws.toolkits.jetbrains.services.cwc.auth.AuthFollowUpType +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.FollowUpType +import java.time.Instant + +sealed interface CwcMessage : AmazonQMessage + +// === UI -> App Messages === +sealed interface IncomingCwcMessage : CwcMessage { + data class ClearChat( + @JsonProperty("tabID") val tabId: String, + ) : IncomingCwcMessage + + data class Help( + @JsonProperty("tabID") val tabId: String, + ) : IncomingCwcMessage + + data class ChatPrompt( + val chatMessage: String, + val command: String, + @JsonProperty("tabID") val tabId: String, + val userIntent: String?, + ) : IncomingCwcMessage + + // TODO: new tab was created + data class TabRemoved( + @JsonProperty("tabID") val tabId: String, + val tabType: String, + ) : IncomingCwcMessage + + data class TabChanged( + @JsonProperty("tabID") val tabId: String, + @JsonProperty("prevTabID") val prevTabId: String?, + ) : IncomingCwcMessage + + data class FollowupClicked( + val followUp: FollowUp, + @JsonProperty("tabID") val tabId: String, + val messageId: String?, + val command: String, + ) : IncomingCwcMessage + + data class CopyCodeToClipboard( + val command: String?, + @JsonProperty("tabID") val tabId: String, + val messageId: String, + val code: String, + val insertionTargetType: String?, + ) : IncomingCwcMessage + + data class InsertCodeAtCursorPosition( + @JsonProperty("tabID") val tabId: String, + val messageId: String, + val code: String, + val insertionTargetType: String?, + val codeReference: List?, + ) : IncomingCwcMessage + + data class TriggerTabIdReceived( + @JsonProperty("triggerID") val triggerId: String, + @JsonProperty("tabID") val tabId: String, + ) : IncomingCwcMessage + + data class StopResponse( + @JsonProperty("tabID") val tabId: String, + ) : IncomingCwcMessage + + data class ChatItemVoted( + @JsonProperty("tabID") val tabId: String, + val messageId: String, + val vote: String, // upvote / downvote + ) : IncomingCwcMessage + + data class ChatItemFeedback( + @JsonProperty("tabID") val tabId: String, + val selectedOption: String, + val comment: String?, + val messageId: String, + ) : IncomingCwcMessage + + data class UIFocus( + val command: String, + @JsonDeserialize(using = FocusTypeDeserializer::class) + @JsonSerialize(using = FocusTypeSerializer::class) + val type: FocusType, + ) : IncomingCwcMessage + + data class Transform( + @JsonProperty("tabID") val tabId: String, + ) : IncomingCwcMessage + + data class ClickedLink( + @JsonProperty("command") val type: LinkType, + @JsonProperty("tabID") val tabId: String, + val messageId: String?, + val link: String, + ) : IncomingCwcMessage + + data class AuthFollowUpWasClicked( + @JsonProperty("tabID") val tabId: String, + val authType: AuthFollowUpType, + ) : IncomingCwcMessage +} + +enum class FocusType { + FOCUS, + BLUR, +} + +enum class LinkType( + @field:JsonValue val command: String, +) { + SourceLink("source-link-click"), + BodyLink("response-body-link-click"), + FooterInfoLink("footer-info-link-click"), +} + +class FocusTypeDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): FocusType = FocusType.valueOf(p.valueAsString.uppercase()) +} + +class FocusTypeSerializer : JsonSerializer() { + override fun serialize(value: FocusType, gen: JsonGenerator, serializers: SerializerProvider) { + gen.writeString(value.name.lowercase()) + } +} + +sealed class UiMessage( + open val tabId: String?, + open val type: String, +) : CwcMessage { + val time = Instant.now().epochSecond + val sender = "CWChat" +} + +enum class ChatMessageType( + @field:JsonValue val json: String, +) { + AnswerStream("answer-stream"), + AnswerPart("answer-part"), + Answer("answer"), +} + +data class CodeReference( + val licenseName: String? = null, + val repository: String? = null, + val url: String? = null, + val recommendationContentSpan: RecommendationContentSpan? = null, + val information: String, +) + +data class RecommendationContentSpan( + val start: Int, + val end: Int, +) + +data class FollowUp( + val type: FollowUpType, + val pillText: String, + val prompt: String, +) + +data class Suggestion( + val title: String, + val url: String, + val body: String, + val id: Int, + val type: String?, + val context: List, +) + +// === App -> UI messages === + +data class ChatMessage( + @JsonProperty("tabID") override val tabId: String, + @JsonProperty("triggerID") val triggerId: String, + val messageType: ChatMessageType, + val messageId: String, + val message: String? = null, + val followUps: List? = null, + val followUpsHeader: String? = null, + val relatedSuggestions: List? = null, + val codeReference: List? = null, +) : UiMessage( + tabId = tabId, + type = "chatMessage", +) + +data class EditorContextCommandMessage( + val message: String?, + @JsonProperty("triggerID") val triggerId: String?, + val command: String?, +) : UiMessage( + tabId = null, + type = "editorContextCommandMessage", +) + +data class AuthNeededException( + @JsonProperty("tabID") override val tabId: String, + @JsonProperty("triggerID") val triggerId: String, + val authType: AuthFollowUpType, + val message: String, +) : UiMessage( + tabId = tabId, + type = "authNeededException", +) + +data class ErrorMessage( + @JsonProperty("tabID") override val tabId: String, + val title: String, + val message: String, + val messageId: String?, +) : UiMessage( + tabId = tabId, + type = "errorMessage", +) + +data class QuickActionMessage( + val message: String, + @JsonProperty("triggerID") val triggerId: String, +) : UiMessage( + tabId = null, + type = "editorContextCommandMessage", +) + +data class OnboardingPageInteractionMessage( + val message: String, + val interactionType: OnboardingPageInteractionType, + @JsonProperty("triggerID") val triggerId: String +) : UiMessage( + tabId = null, + type = "editorContextCommandMessage", +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionInfo.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionInfo.kt new file mode 100644 index 0000000000..37181333dd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionInfo.kt @@ -0,0 +1,14 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.storage + +import kotlinx.coroutines.CoroutineScope +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.ChatSession +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatRequestData + +data class ChatSessionInfo( + val session: ChatSession, + val scope: CoroutineScope, + val history: MutableList, +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionStorage.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionStorage.kt new file mode 100644 index 0000000000..231cdd21d5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cwc/storage/ChatSessionStorage.kt @@ -0,0 +1,29 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cwc.storage + +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.ChatSessionFactory +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.v1.ChatSessionFactoryV1 + +class ChatSessionStorage( + private val chatSessionFactory: ChatSessionFactory = ChatSessionFactoryV1(), +) { + private val sessions = mutableMapOf() + + fun getSession(tabId: String) = sessions[tabId] + + fun getSession(tabId: String, project: Project) = sessions.getOrPut(tabId) { + val session = chatSessionFactory.create(project) + val scope = CoroutineScope(SupervisorJob()) + ChatSessionInfo(session = session, scope = scope, history = mutableListOf()) + } + + fun deleteSession(tabId: String) { + sessions.remove(tabId)?.scope?.cancel() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/CloudControlApiResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/CloudControlApiResources.kt new file mode 100644 index 0000000000..e607d8db39 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/CloudControlApiResources.kt @@ -0,0 +1,64 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.LightVirtualFile +import software.amazon.awssdk.arns.Arn +import software.amazon.awssdk.services.cloudcontrol.CloudControlClient +import software.amazon.awssdk.services.cloudformation.CloudFormationClient +import software.amazon.awssdk.services.cloudformation.model.RegistryType +import software.amazon.awssdk.services.cloudformation.model.Visibility +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource +import software.aws.toolkits.jetbrains.core.Resource +import software.aws.toolkits.jetbrains.core.map +import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources + +object CloudControlApiResources { + fun listResources(typeName: String): Resource> = + when (typeName) { + S3_BUCKET -> S3Resources.LIST_BUCKETS.map { it.name() } + else -> ClientBackedCachedResource(CloudControlClient::class, "cloudcontrolapi.dynamic.resources.$typeName") { + this.listResourcesPaginator { req -> req.typeName(typeName) } + .flatMap { page -> page.resourceDescriptions().map { it.identifier() } } + } + }.map { DynamicResource(resourceTypeFromResourceTypeName(typeName), it) } + + fun resourceTypeFromResourceTypeName(typeName: String): ResourceType { + val (_, svc, type) = typeName.split("::") + return ResourceType(typeName, svc, type) + } + + fun listResources(resourceType: ResourceType): Resource> = listResources(resourceType.fullName) + + fun getResourceDisplayName(identifier: String): String = + if (identifier.startsWith("arn:")) { + Arn.fromString(identifier).resourceAsString() + } else { + identifier + } + + fun getResourceSchema(resourceType: String): Resource.Cached = + ClientBackedCachedResource(CloudFormationClient::class, "cloudformation.dynamic.resources.schema.$resourceType") { + val schema = this.describeType { + it.type(RegistryType.RESOURCE) + it.typeName(resourceType) + }.schema() + LightVirtualFile("${resourceType}Schema.json", schema) + } + + fun listTypes(): Resource.Cached> = ClientBackedCachedResource(CloudFormationClient::class, "cloudformation.listTypes") { + this.listTypesPaginator { + it.visibility(Visibility.PUBLIC) + it.type(RegistryType.RESOURCE) + }.flatMap { it.typeSummaries().map { it.typeName() } } + } + private const val S3_BUCKET = "AWS::S3::Bucket" +} + +data class ResourceDetails(val operations: List, val arnRegex: String?, val documentation: String?) + +enum class PermittedOperation { + CREATE, READ, UPDATE, DELETE, LIST +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/CreateResourceFileStatusHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/CreateResourceFileStatusHandler.kt new file mode 100644 index 0000000000..f6dc1da9c2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/CreateResourceFileStatusHandler.kt @@ -0,0 +1,56 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.cloudcontrol.model.Operation +import software.amazon.awssdk.services.cloudcontrol.model.OperationStatus +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.services.dynamic.explorer.OpenResourceModelSourceAction + +class CreateResourceFileStatusHandler(private val project: Project) : DynamicResourceStateMutationHandler { + private val resourceCreationProgressTracker: MutableMap = mutableMapOf() + + init { + project.messageBus.connect(project).subscribe(DynamicResourceUpdateManager.DYNAMIC_RESOURCE_STATE_CHANGED, this) + } + + fun recordResourceBeingCreated(token: String, file: VirtualFile) { + resourceCreationProgressTracker[token] = file + } + + override fun mutationStatusChanged(state: ResourceMutationState) { + if (state.operation == Operation.CREATE && state.status == OperationStatus.SUCCESS && state.resourceIdentifier != null) { + runInEdt { + resourceCreationProgressTracker[state.token]?.let { FileEditorManager.getInstance(project).closeFile(it) } + resourceCreationProgressTracker.remove(state.token) + } + + val dynamicResourceIdentifier = DynamicResourceIdentifier(state.connectionSettings, state.resourceType, state.resourceIdentifier) + val model = OpenViewEditableDynamicResourceVirtualFile.getResourceModel( + project, + state.connectionSettings.awsClient(), + state.resourceType, + state.resourceIdentifier + ) ?: return + val file = ViewEditableDynamicResourceVirtualFile( + dynamicResourceIdentifier, + model + ) + OpenViewEditableDynamicResourceVirtualFile.openFile(project, file, OpenResourceModelSourceAction.READ, state.resourceType) + } + } + + @TestOnly + fun getNumberOfResourcesBeingCreated(): Int = resourceCreationProgressTracker.size + + companion object { + fun getInstance(project: Project) = project.service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceFileActionProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceFileActionProvider.kt new file mode 100644 index 0000000000..4ce686e26d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceFileActionProvider.kt @@ -0,0 +1,74 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.intellij.ide.browsers.BrowserLauncher +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.EditorNotifications +import com.jetbrains.jsonSchema.ide.JsonSchemaService +import software.aws.toolkits.jetbrains.core.experiments.isEnabled +import software.aws.toolkits.resources.message + +class DynamicResourceFileActionProvider : + EditorNotifications.Provider() { + override fun getKey(): Key = KEY + + override fun createNotificationPanel(file: VirtualFile, fileEditor: FileEditor, project: Project): + DynamicResourceVirtualFilePanel? { + if (!JsonResourceModificationExperiment.isEnabled()) return null + return when (file) { + is CreateDynamicResourceVirtualFile -> + DynamicResourceVirtualFilePanel( + project, + file, + message("dynamic_resources.create_resource_instruction"), + "dynamic.resource.editor.submitResourceCreationRequest" + ).also { DynamicResourceSchemaMapping.getInstance().addResourceSchemaMapping(project, file) } + is ViewEditableDynamicResourceVirtualFile -> + when (file.isWritable) { + true -> DynamicResourceVirtualFilePanel( + project, + file, + message("dynamic_resources.update_resource_instruction"), + "dynamic.resource.editor.submitResourceUpdateRequest" + ).also { DynamicResourceSchemaMapping.getInstance().addResourceSchemaMapping(project, file) } + false -> DynamicResourceVirtualFilePanel( + project, + file, + message("dynamic_resources.edit_resource_instruction"), + "dynamic.resource.editor.enableEditingResource" + ).also { JsonSchemaService.Impl.get(project).reset() } + } + else -> null + } + } + + class DynamicResourceVirtualFilePanel(project: Project, file: DynamicResourceVirtualFile, text: String, actionId: String) : EditorNotificationPanel() { + init { + text(text) + DynamicResourceSupportedTypes.getInstance().getDocs(file.dynamicResourceType)?.let { docUrl -> + createActionLabel(message("dynamic_resources.type.explorer.view_documentation")) { + BrowserLauncher.instance.browse(docUrl, project = project) + } + } + + val action = ActionManager.getInstance().getAction(actionId) + action.templateText?.let { + createActionLabel(it) { + executeAction(actionId) + EditorNotifications.getInstance(project).updateNotifications(file) + } + } + } + } + + companion object { + val KEY = Key.create("software.aws.toolkits.jetbrains.core.dynamic.resource.file.actions") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceSchemaMapping.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceSchemaMapping.kt new file mode 100644 index 0000000000..8dde412778 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceSchemaMapping.kt @@ -0,0 +1,37 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.jetbrains.jsonSchema.JsonSchemaMappingsProjectConfiguration +import com.jetbrains.jsonSchema.ide.JsonSchemaService +import org.jetbrains.annotations.TestOnly + +class DynamicResourceSchemaMapping { + private val currentlyActiveResourceTypes: MutableSet = mutableSetOf() + + fun addResourceSchemaMapping( + project: Project, + file: DynamicResourceVirtualFile + ) { + val configuration = JsonSchemaMappingsProjectConfiguration.getInstance(project).findMappingForFile(file) + if (configuration == null) { + currentlyActiveResourceTypes.add(file.dynamicResourceType) + JsonSchemaService.Impl.get(project).reset() + } + } + + fun getCurrentlyActiveResourceTypes(): Set = currentlyActiveResourceTypes + + @TestOnly + fun removeCurrentlyActiveResourceTypes(project: Project) { + currentlyActiveResourceTypes.clear() + JsonSchemaService.Impl.get(project).reset() + } + + companion object { + fun getInstance(): DynamicResourceSchemaMapping = service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceStateChangedNotificationHandler.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceStateChangedNotificationHandler.kt new file mode 100644 index 0000000000..b31913b509 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceStateChangedNotificationHandler.kt @@ -0,0 +1,96 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.cloudcontrol.model.OperationStatus +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.explorer.ExplorerToolWindow +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceTelemetryResources.addOperationToTelemetry +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DynamicresourceTelemetry +import software.aws.toolkits.telemetry.Result +import java.time.temporal.ChronoUnit +import java.util.concurrent.atomic.AtomicBoolean + +class DynamicResourceStateChangedNotificationHandler(private val project: Project) : DynamicResourceStateMutationHandler { + private val refreshRequired = AtomicBoolean(false) + override fun mutationStatusChanged(state: ResourceMutationState) { + if (state.status == OperationStatus.SUCCESS) { + notifyInfo( + message( + "dynamic_resources.operation_status_notification_title", + state.resourceIdentifier ?: state.resourceType, + state.operation.name.toLowerCase() + ), + message( + "dynamic_resources.operation_status_success", + state.resourceIdentifier ?: state.resourceType, + state.operation.name.toLowerCase() + ), + project + ) + DynamicresourceTelemetry.mutateResource( + project, + Result.Succeeded, + state.resourceType, + addOperationToTelemetry(state.operation), + ChronoUnit.MILLIS.between(state.startTime, DynamicResourceTelemetryResources.getCurrentTime()).toDouble() + ) + } else if (state.status == OperationStatus.FAILED) { + if (state.message.isNullOrBlank()) { + displayErrorMessage( + state, + message( + "dynamic_resources.operation_status_failed_no_message", + state.resourceIdentifier ?: state.resourceType, + state.operation.name.toLowerCase() + ) + ) + } else { + displayErrorMessage( + state, + message( + "dynamic_resources.operation_status_failed", + state.resourceIdentifier ?: state.resourceType, + state.operation.name.toLowerCase(), + state.message + ) + ) + } + DynamicresourceTelemetry.mutateResource( + project, + Result.Failed, + state.resourceType, + addOperationToTelemetry(state.operation), + ChronoUnit.MILLIS.between(state.startTime, DynamicResourceTelemetryResources.getCurrentTime()).toDouble() + ) + } + AwsResourceCache.getInstance().clear(CloudControlApiResources.listResources(state.resourceType), state.connectionSettings) + refreshRequired.set(true) + } + + private fun displayErrorMessage(state: ResourceMutationState, errorMessage: String) { + notifyError( + message( + "dynamic_resources.operation_status_notification_title", + state.resourceIdentifier ?: state.resourceType, + state.operation.name.toLowerCase() + ), + errorMessage, + project + ) + } + + override fun statusCheckComplete() { + runInEdt { + if (refreshRequired.getAndSet(false)) { + ExplorerToolWindow.getInstance(project).invalidateTree() + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceSupportedTypes.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceSupportedTypes.kt new file mode 100644 index 0000000000..197182efbf --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceSupportedTypes.kt @@ -0,0 +1,31 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.components.service +import software.aws.toolkits.jetbrains.utils.runUnderProgressIfNeeded +import software.aws.toolkits.resources.message + +class DynamicResourceSupportedTypes { + + private val supportedTypes by lazy { + runUnderProgressIfNeeded(null, message("dynamic_resources.loading_manifest"), cancelable = false) { + this.javaClass.getResourceAsStream("/cloudapi/dynamic_resources.json")?.use { resourceStream -> + MAPPER.readValue>(resourceStream) + } ?: throw RuntimeException("dynamic resource manifest not found") + } + } + + fun getSupportedTypes(): List = supportedTypes.filterValues { it.operations.contains(PermittedOperation.LIST) }.keys.toList() + + fun getDocs(resourceType: String) = supportedTypes[resourceType]?.documentation + + companion object { + fun getInstance(): DynamicResourceSupportedTypes = service() + private val MAPPER = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceVirtualFile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceVirtualFile.kt new file mode 100644 index 0000000000..5ce2a2eaf1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourceVirtualFile.kt @@ -0,0 +1,75 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.intellij.json.JsonFileType +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.psi.codeStyle.CodeStyleManager +import com.intellij.psi.util.PsiUtilCore +import com.intellij.testFramework.LightVirtualFile +import software.amazon.awssdk.services.cloudcontrol.CloudControlClient +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.services.dynamic.explorer.OpenResourceModelSourceAction +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DynamicresourceTelemetry + +sealed class DynamicResourceVirtualFile(fileName: String, val dynamicResourceType: String, fileContent: String) : + LightVirtualFile( + fileName, + JsonFileType.INSTANCE, + fileContent + ) + +class CreateDynamicResourceVirtualFile(val connectionSettings: ConnectionSettings, dynamicResourceType: String) : + DynamicResourceVirtualFile( + message("dynamic_resources.create_resource_file_name", dynamicResourceType), + dynamicResourceType, + InitialCreateDynamicResourceContent.initialContent + ) + +class ViewEditableDynamicResourceVirtualFile(val dynamicResourceIdentifier: DynamicResourceIdentifier, fileContent: String) : + DynamicResourceVirtualFile( + CloudControlApiResources.getResourceDisplayName(dynamicResourceIdentifier.resourceIdentifier), + dynamicResourceIdentifier.resourceType, + fileContent + ) + +object InitialCreateDynamicResourceContent { + const val initialContent = "{}" +} + +object OpenViewEditableDynamicResourceVirtualFile { + fun openFile(project: Project, file: ViewEditableDynamicResourceVirtualFile, sourceAction: OpenResourceModelSourceAction, resourceType: String) { + WriteCommandAction.runWriteCommandAction(project) { + CodeStyleManager.getInstance(project).reformat(PsiUtilCore.getPsiFile(project, file)) + if (sourceAction == OpenResourceModelSourceAction.READ) { + file.isWritable = false + DynamicresourceTelemetry.getResource(project, success = true, resourceType = resourceType) + } else if (sourceAction == OpenResourceModelSourceAction.EDIT) { + file.isWritable = true + } + FileEditorManager.getInstance(project).openFile(file, true) + } + } + + fun getResourceModel(project: Project, client: CloudControlClient, resourceType: String, resourceIdentifier: String): String? = try { + client.getResource { + it.typeName(resourceType) + it.identifier(resourceIdentifier) + } + .resourceDescription() + .properties() + } catch (e: Exception) { + notifyError( + project = project, + title = message("dynamic_resources.fetch.fail.title"), + content = message("dynamic_resources.fetch.fail.content", resourceIdentifier) + ) + DynamicresourceTelemetry.getResource(project, success = false, resourceType = resourceType) + null + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourcesProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourcesProvider.kt new file mode 100644 index 0000000000..25636deefc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourcesProvider.kt @@ -0,0 +1,39 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.cloudformation.CloudFormationClient +import software.amazon.awssdk.services.cloudformation.model.ProvisioningType +import software.amazon.awssdk.services.cloudformation.model.Visibility +import kotlin.coroutines.coroutineContext + +class DynamicResourcesProvider(private val cfnClient: CloudFormationClient) { + suspend fun listSupportedTypes(): List = withContext(coroutineContext) { + val mutable = async { + cfnClient.listTypesPaginator { + it.visibility(Visibility.PUBLIC) + it.provisioningType(ProvisioningType.FULLY_MUTABLE) + } + } + + val immutable = async { + cfnClient.listTypesPaginator { + it.visibility(Visibility.PUBLIC) + it.provisioningType(ProvisioningType.IMMUTABLE) + } + } + val types = mutable.await() + immutable.await() + + types.flatMap { resp -> + resp.typeSummaries().map { summary -> + CloudControlApiResources.resourceTypeFromResourceTypeName(summary.typeName()) + } + } + } +} + +data class ResourceType(val fullName: String, val service: String, val name: String) +data class DynamicResource(val type: ResourceType, val identifier: String) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourcesUpdateManager.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourcesUpdateManager.kt new file mode 100644 index 0000000000..15054de2d8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/DynamicResourcesUpdateManager.kt @@ -0,0 +1,244 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.Alarm +import com.intellij.util.AlarmFactory +import com.intellij.util.messages.Topic +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.cloudcontrol.CloudControlClient +import software.amazon.awssdk.services.cloudcontrol.model.Operation +import software.amazon.awssdk.services.cloudcontrol.model.OperationStatus +import software.amazon.awssdk.services.cloudcontrol.model.ProgressEvent +import software.amazon.awssdk.services.cloudcontrol.model.RequestTokenNotFoundException +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceTelemetryResources.addOperationToTelemetry +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DynamicResourceOperation +import software.aws.toolkits.telemetry.DynamicresourceTelemetry +import software.aws.toolkits.telemetry.Result +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.ConcurrentLinkedQueue + +internal class DynamicResourceUpdateManager(private val project: Project) { + // TODO: Make DynamicResourceUpdateManager an application-level service + + private val pendingMutations = ConcurrentLinkedQueue() + private val coroutineScope = projectCoroutineScope(project) + private val alarm: Alarm = + AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, project) + + fun deleteResource(dynamicResourceIdentifier: DynamicResourceIdentifier) { + coroutineScope.launch { + try { + val client = dynamicResourceIdentifier.connectionSettings.awsClient() + val progress = client.deleteResource { + it.typeName(dynamicResourceIdentifier.resourceType) + it.identifier(dynamicResourceIdentifier.resourceIdentifier) + }.progressEvent() + startCheckingProgress(dynamicResourceIdentifier.connectionSettings, progress, DynamicResourceTelemetryResources.getCurrentTime()) + } catch (e: Exception) { + e.notifyError( + message( + "dynamic_resources.operation_status_notification_title", + dynamicResourceIdentifier.resourceIdentifier, + message("general.delete").toLowerCase() + ), + project + ) + DynamicresourceTelemetry.mutateResource( + project, + Result.Failed, + dynamicResourceIdentifier.resourceType, + addOperationToTelemetry(Operation.DELETE), + 0.0 + ) + } + } + } + + fun updateResource(dynamicResourceIdentifier: DynamicResourceIdentifier, patchOperation: String) { + coroutineScope.launch { + try { + val client = dynamicResourceIdentifier.connectionSettings.awsClient() + val progress = client.updateResource { + it.typeName(dynamicResourceIdentifier.resourceType) + it.identifier(dynamicResourceIdentifier.resourceIdentifier) + it.patchDocument(patchOperation) + }.progressEvent() + startCheckingProgress(dynamicResourceIdentifier.connectionSettings, progress, DynamicResourceTelemetryResources.getCurrentTime()) + } catch (e: Exception) { + e.notifyError( + message( + "dynamic_resources.operation_status_notification_title", + dynamicResourceIdentifier.resourceIdentifier, + message("dynamic_resources.editor.submitResourceUpdateRequest_text").toLowerCase() + ), + project + ) + DynamicresourceTelemetry.mutateResource( + project, + Result.Failed, + dynamicResourceIdentifier.resourceType, + addOperationToTelemetry(Operation.UPDATE), + 0.0 + ) + } + } + } + + fun createResource(connectionSettings: ConnectionSettings, dynamicResourceType: String, desiredState: String, file: VirtualFile) { + coroutineScope.launch { + try { + val client = connectionSettings.awsClient() + val progress = client.createResource { + it.typeName(dynamicResourceType) + it.desiredState(desiredState) + }.progressEvent() + + CreateResourceFileStatusHandler.getInstance(project).recordResourceBeingCreated(progress.requestToken(), file) + startCheckingProgress(connectionSettings, progress, DynamicResourceTelemetryResources.getCurrentTime()) + } catch (e: Exception) { + e.notifyError( + message("dynamic_resources.operation_status_notification_title", dynamicResourceType, message("general.create".decapitalize())), + project + ) + DynamicresourceTelemetry.mutateResource(project, Result.Failed, dynamicResourceType, addOperationToTelemetry(Operation.CREATE), 0.0) + } + } + } + + private fun startCheckingProgress(connectionSettings: ConnectionSettings, progress: ProgressEvent, startTime: Instant) { + pendingMutations.add(ResourceMutationState.fromEvent(connectionSettings, progress, startTime)) + if (pendingMutations.size == 1) { + alarm.addRequest({ getProgress() }, 0) + } + } + + fun getUpdateStatus(dynamicResourceIdentifier: DynamicResourceIdentifier): ResourceMutationState? = + pendingMutations.find { + it.connectionSettings == dynamicResourceIdentifier.connectionSettings && + it.resourceType == dynamicResourceIdentifier.resourceType && + it.resourceIdentifier == dynamicResourceIdentifier.resourceIdentifier + } + + private fun getProgress() { + var size = pendingMutations.size + while (size > 0) { + val mutation = pendingMutations.remove() + + val client = mutation.connectionSettings.awsClient() + val (progressEvent, shouldDropFromPendingQueue) = try { + val progress = client.getResourceRequestStatus { it.requestToken(mutation.token) } + progress.progressEvent() to progress.progressEvent().operationStatus().isTerminal() + } catch (e: Exception) { + when (e) { + is RequestTokenNotFoundException -> { + e.notifyError( + message( + "dynamic_resources.operation_status_notification_title", + mutation.resourceIdentifier ?: mutation.resourceType, + mutation.operation.name.toLowerCase() + ), + project + ) + DynamicresourceTelemetry.mutateResource( + project, + Result.Failed, + mutation.resourceType, + addOperationToTelemetry(mutation.operation), + ChronoUnit.MILLIS.between(mutation.startTime, DynamicResourceTelemetryResources.getCurrentTime()).toDouble() + ) + null to true + } + else -> null to false + } + } + val updatedMutation = when (progressEvent) { + is ProgressEvent -> mutation.copy( + status = progressEvent.operationStatus(), + resourceIdentifier = progressEvent.identifier(), + message = progressEvent.statusMessage() + ) + else -> mutation + } + if (updatedMutation != mutation) { + project.messageBus.syncPublisher(DYNAMIC_RESOURCE_STATE_CHANGED).mutationStatusChanged(updatedMutation) + } + + if (!shouldDropFromPendingQueue) { + pendingMutations.add(updatedMutation) + } + + size-- + } + project.messageBus.syncPublisher(DYNAMIC_RESOURCE_STATE_CHANGED).statusCheckComplete() + + if (pendingMutations.size != 0) { + alarm.addRequest({ getProgress() }, DEFAULT_DELAY) + } + } + + companion object { + private const val DEFAULT_DELAY = 500 + val DYNAMIC_RESOURCE_STATE_CHANGED: Topic = Topic.create( + "Resource State Changed", + DynamicResourceStateMutationHandler::class.java + ) + + fun OperationStatus.isTerminal() = this in setOf(OperationStatus.SUCCESS, OperationStatus.CANCEL_COMPLETE, OperationStatus.FAILED) + + fun getInstance(project: Project): DynamicResourceUpdateManager = project.service() + } +} + +interface DynamicResourceStateMutationHandler { + fun mutationStatusChanged(state: ResourceMutationState) + fun statusCheckComplete() {} +} + +data class DynamicResourceIdentifier(val connectionSettings: ConnectionSettings, val resourceType: String, val resourceIdentifier: String) + +data class ResourceMutationState( + val connectionSettings: ConnectionSettings, + val token: String, + val operation: Operation, + val resourceType: String, + val status: OperationStatus, + val resourceIdentifier: String?, + val message: String?, + val startTime: Instant +) { + companion object { + fun fromEvent(connectionSettings: ConnectionSettings, progress: ProgressEvent, startTime: Instant) = + ResourceMutationState( + connectionSettings = connectionSettings, + token = progress.requestToken(), + operation = progress.operation(), + resourceType = progress.typeName(), + status = progress.operationStatus(), + resourceIdentifier = progress.identifier(), + message = progress.statusMessage(), + startTime = startTime + ) + } +} + +object DynamicResourceTelemetryResources { + fun addOperationToTelemetry(operation: Operation): DynamicResourceOperation = when (operation) { + Operation.CREATE -> DynamicResourceOperation.Create + Operation.UPDATE -> DynamicResourceOperation.Update + Operation.DELETE -> DynamicResourceOperation.Delete + else -> DynamicResourceOperation.Unknown + } + + fun getCurrentTime(): Instant = Instant.now() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/JsonResourceModificationExperiment.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/JsonResourceModificationExperiment.kt new file mode 100644 index 0000000000..3532cf96a9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/JsonResourceModificationExperiment.kt @@ -0,0 +1,38 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.EditorNotifications +import software.aws.toolkits.jetbrains.core.experiments.ToolkitExperiment +import software.aws.toolkits.jetbrains.core.experiments.ToolkitExperimentStateChangedListener +import software.aws.toolkits.jetbrains.core.experiments.suggest +import software.aws.toolkits.resources.message + +object JsonResourceModificationExperiment : ToolkitExperiment( + "jsonResourceModification", + { message("dynamic_resources.experiment.title") }, + { message("dynamic_resources.experiment.description") } +) + +class SuggestEditExperimentListener : FileEditorManagerListener { + override fun fileOpened(source: FileEditorManager, file: VirtualFile) { + if (file is DynamicResourceVirtualFile) { + JsonResourceModificationExperiment.suggest() + } + } +} + +class UpdateOnExperimentState(private val project: Project) : ToolkitExperimentStateChangedListener { + override fun enableSettingsStateChanged(toolkitExperiment: ToolkitExperiment) { + if (toolkitExperiment is JsonResourceModificationExperiment) { + with(EditorNotifications.getInstance(project)) { + FileEditorManager.getInstance(project).openFiles.filterIsInstance().forEach { updateNotifications(it) } + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/ResourceSchemaProviderFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/ResourceSchemaProviderFactory.kt new file mode 100644 index 0000000000..3ce02ba445 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/ResourceSchemaProviderFactory.kt @@ -0,0 +1,34 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.jetbrains.jsonSchema.extension.JsonSchemaFileProvider +import com.jetbrains.jsonSchema.extension.JsonSchemaProviderFactory +import com.jetbrains.jsonSchema.extension.SchemaType +import com.jetbrains.jsonSchema.impl.JsonSchemaVersion +import software.aws.toolkits.jetbrains.core.getResourceNow + +class ResourceSchemaProviderFactory : JsonSchemaProviderFactory { + override fun getProviders(project: Project): List { + val schemaProviders = mutableListOf() + DynamicResourceSchemaMapping.getInstance().getCurrentlyActiveResourceTypes().forEach { + val schemaFile = object : JsonSchemaFileProvider { + override fun isAvailable(file: VirtualFile): Boolean = + file is DynamicResourceVirtualFile && file.dynamicResourceType == it && file.isWritable + + override fun getName(): String = "$it schema" + + override fun getSchemaFile(): VirtualFile? = project.getResourceNow(CloudControlApiResources.getResourceSchema(it)) + + override fun getSchemaVersion(): JsonSchemaVersion = JsonSchemaVersion.SCHEMA_7 + + override fun getSchemaType(): SchemaType = SchemaType.embeddedSchema + } + schemaProviders.add(schemaFile) + } + return schemaProviders + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/editor/actions/EnableEditingResourceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/editor/actions/EnableEditingResourceAction.kt new file mode 100644 index 0000000000..89c6903042 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/editor/actions/EnableEditingResourceAction.kt @@ -0,0 +1,17 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.editor.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import software.aws.toolkits.resources.message + +class EnableEditingResourceAction : AnAction(message("dynamic_resources.editor.enableEditingResource_text")) { + override fun actionPerformed(e: AnActionEvent) { + val psiFile = e.getData(CommonDataKeys.PSI_FILE) ?: throw Exception("file not found") + val file = psiFile.virtualFile + file?.isWritable = true + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/editor/actions/SubmitResourceCreationRequestAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/editor/actions/SubmitResourceCreationRequestAction.kt new file mode 100644 index 0000000000..e15c95058d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/editor/actions/SubmitResourceCreationRequestAction.kt @@ -0,0 +1,43 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.editor.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.ui.Messages +import software.aws.toolkits.jetbrains.services.dynamic.CreateDynamicResourceVirtualFile +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceUpdateManager +import software.aws.toolkits.jetbrains.services.dynamic.InitialCreateDynamicResourceContent +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message + +class SubmitResourceCreationRequestAction : AnAction(message("general.create")) { + + override fun actionPerformed(e: AnActionEvent) { + val psiFile = e.getData(CommonDataKeys.PSI_FILE) + val file = psiFile?.virtualFile as? CreateDynamicResourceVirtualFile ?: return + val resourceType = file.dynamicResourceType + + val contentString = psiFile.text + val continueWithContent = if (contentString == InitialCreateDynamicResourceContent.initialContent) { + Messages.showYesNoDialog( + psiFile.project, + message("dynamic_resources.create_resource_file_empty"), + message("dynamic_resources.create_resource_file_empty_title"), + Messages.getWarningIcon() + ) == Messages.YES + } else { + true + } + if (continueWithContent) { + notifyInfo( + message("dynamic_resources.resource_creation", resourceType), + message("dynamic_resources.begin_resource_creation", resourceType), + psiFile.project + ) + DynamicResourceUpdateManager.getInstance(psiFile.project).createResource(file.connectionSettings, file.dynamicResourceType, contentString, file) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/editor/actions/SubmitResourceUpdateRequestAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/editor/actions/SubmitResourceUpdateRequestAction.kt new file mode 100644 index 0000000000..fe3d659ac8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/editor/actions/SubmitResourceUpdateRequestAction.kt @@ -0,0 +1,43 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.editor.actions + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.flipkart.zjsonpatch.JsonDiff +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.Messages.showYesNoDialog +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceUpdateManager +import software.aws.toolkits.jetbrains.services.dynamic.ViewEditableDynamicResourceVirtualFile +import software.aws.toolkits.resources.message + +class SubmitResourceUpdateRequestAction : AnAction(message("dynamic_resources.editor.submitResourceUpdateRequest_text")) { + override fun actionPerformed(e: AnActionEvent) { + val psiFile = e.getData(CommonDataKeys.PSI_FILE) + val file = psiFile?.virtualFile as? ViewEditableDynamicResourceVirtualFile ?: return + + val content = psiFile.text + val patchOperations = JsonDiff.asJson(mapper.readTree(file.inputStream), mapper.readTree(content)) + if (patchOperations.isEmpty) { + if (showYesNoDialog( + psiFile.project, + message("dynamic_resources.update_resource_no_changes_made"), + message("dynamic_resources.update_resource_no_changes_made_title"), + Messages.getWarningIcon() + ) == Messages.NO + ) { + file.isWritable = false + } + } else { + file.isWritable = false + DynamicResourceUpdateManager.getInstance(psiFile.project).updateResource(file.dynamicResourceIdentifier, patchOperations.toPrettyString()) + } + } + + companion object { + val mapper = jacksonObjectMapper() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/DynamicResourceSelectorNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/DynamicResourceSelectorNode.kt new file mode 100644 index 0000000000..66f3790e2b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/DynamicResourceSelectorNode.kt @@ -0,0 +1,31 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer + +import com.intellij.icons.AllIcons +import com.intellij.ide.projectView.PresentationData +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceSupportedTypes +import software.aws.toolkits.jetbrains.settings.DynamicResourcesConfigurable +import software.aws.toolkits.jetbrains.settings.DynamicResourcesSettings +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DynamicresourceTelemetry + +class DynamicResourceSelectorNode(nodeProject: Project) : AwsExplorerNode(nodeProject, Unit, AllIcons.Actions.Edit) { + override fun displayName() = message("explorer.node.other.add_remove") + + override fun getChildren(): List> = emptyList() + + override fun onDoubleClick() { + ShowSettingsUtil.getInstance().showSettingsDialog(null, DynamicResourcesConfigurable::class.java) + DynamicresourceTelemetry.selectResources(nodeProject) + } + + override fun update(presentation: PresentationData) { + val remaining = DynamicResourceSupportedTypes.getInstance().getSupportedTypes().size - DynamicResourcesSettings.getInstance().selected.size + presentation.tooltip = message("dynamic_resources.add_remove_resources_tooltip", remaining) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/DynamicResourceServiceNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/DynamicResourceServiceNode.kt new file mode 100644 index 0000000000..e389b2d4a1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/DynamicResourceServiceNode.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.jetbrains.services.dynamic.explorer + +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.ui.EditorNotifications +import software.amazon.awssdk.services.cloudcontrol.model.UnsupportedActionException +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettingsOrThrow +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerEmptyNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceActionNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceParentNode +import software.aws.toolkits.jetbrains.core.getResourceNow +import software.aws.toolkits.jetbrains.services.dynamic.CloudControlApiResources +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResource +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceIdentifier +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceUpdateManager +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceUpdateManager.Companion.isTerminal +import software.aws.toolkits.jetbrains.services.dynamic.OpenViewEditableDynamicResourceVirtualFile +import software.aws.toolkits.jetbrains.services.dynamic.ViewEditableDynamicResourceVirtualFile +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DynamicresourceTelemetry +import software.aws.toolkits.telemetry.Result + +class DynamicResourceResourceTypeNode(project: Project, val resourceType: String) : + AwsExplorerNode(project, resourceType, null), + ResourceParentNode, + ResourceActionNode { + + override fun displayName(): String = resourceType + override fun isAlwaysShowPlus(): Boolean = true + + // calls to CloudAPI time-expensive and likely to throttle + override fun isAlwaysExpand(): Boolean = false + + override fun getChildren(): List> = super.getChildren() + + override fun getChildrenInternal(): List> = try { + nodeProject.getResourceNow(CloudControlApiResources.listResources(resourceType)) + .map { DynamicResourceNode(nodeProject, it) } + .also { DynamicresourceTelemetry.listResource(project = nodeProject, success = true, resourceType = resourceType) } + } catch (e: Exception) { + when (e) { + is UnsupportedActionException -> { + DynamicresourceTelemetry.listResource(project = nodeProject, result = Result.Cancelled, resourceType = resourceType) + listOf(AwsExplorerEmptyNode(nodeProject, message("dynamic_resources.unavailable_in_region", region.id))) + } + else -> { + DynamicresourceTelemetry.listResource(project = nodeProject, success = false, resourceType = resourceType) + throw e + } + } + } + + override fun actionGroupName(): String = "aws.toolkit.explorer.dynamic.resource.type" +} + +class UnavailableDynamicResourceTypeNode(project: Project, resourceType: String) : AwsExplorerNode(project, resourceType, null) { + override fun statusText(): String = message("dynamic_resources.unavailable_in_region", region.id) + override fun getChildren(): List> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +class DynamicResourceNode(project: Project, val resource: DynamicResource) : + AwsExplorerNode(project, resource, null), + ResourceActionNode { + + override fun actionGroupName() = "aws.toolkit.explorer.dynamic.resource" + override fun displayName(): String = CloudControlApiResources.getResourceDisplayName(resource.identifier) + + override fun statusText(): String? { + val state = DynamicResourceUpdateManager.getInstance(nodeProject) + .getUpdateStatus(DynamicResourceIdentifier(nodeProject.getConnectionSettingsOrThrow(), resource.type.fullName, resource.identifier))?.takeIf { + !it.status.isTerminal() + } + ?: return null + return "${state.operation} ${state.status}" + } + + override fun isAlwaysShowPlus(): Boolean = false + override fun isAlwaysLeaf(): Boolean = true + override fun getChildren(): List> = emptyList() + override fun onDoubleClick() = openResourceModelInEditor(OpenResourceModelSourceAction.READ) + + fun openResourceModelInEditor(sourceAction: OpenResourceModelSourceAction) { + val dynamicResourceIdentifier = DynamicResourceIdentifier(nodeProject.getConnectionSettingsOrThrow(), resource.type.fullName, resource.identifier) + val openFiles = FileEditorManager.getInstance(nodeProject).openFiles.filter { + it is ViewEditableDynamicResourceVirtualFile && it.dynamicResourceIdentifier == dynamicResourceIdentifier + } + if (openFiles.isEmpty()) { + object : Task.Backgroundable(nodeProject, message("dynamic_resources.fetch.indicator_title", resource.identifier), true) { + override fun run(indicator: ProgressIndicator) { + indicator.text = message("dynamic_resources.fetch.fetch") + val model = OpenViewEditableDynamicResourceVirtualFile.getResourceModel( + nodeProject, + nodeProject.awsClient(), + resource.type.fullName, + resource.identifier + ) ?: return + val file = ViewEditableDynamicResourceVirtualFile( + dynamicResourceIdentifier, + model + ) + + indicator.text = message("dynamic_resources.fetch.open") + OpenViewEditableDynamicResourceVirtualFile.openFile(nodeProject, file, sourceAction, resource.type.fullName) + } + }.queue() + } else { + val openFile = openFiles.first() + if (sourceAction == OpenResourceModelSourceAction.EDIT) { + openFile.isWritable = true + } + FileEditorManager.getInstance(nodeProject).openFile(openFile, true) + EditorNotifications.getInstance(nodeProject).updateNotifications(openFile) + } + } + + private companion object { + val LOG = getLogger() + } +} + +enum class OpenResourceModelSourceAction { + READ, EDIT +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/DynamicResourceTreeStructureProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/DynamicResourceTreeStructureProvider.kt new file mode 100644 index 0000000000..29ddf0746c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/DynamicResourceTreeStructureProvider.kt @@ -0,0 +1,36 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer + +import com.intellij.ide.util.treeView.AbstractTreeNode +import software.aws.toolkits.jetbrains.core.explorer.AwsExplorerTreeStructureProvider +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerRootNode + +class DynamicResourceTreeStructureProvider : AwsExplorerTreeStructureProvider() { + override fun modify(parent: AbstractTreeNode<*>, children: MutableCollection>): MutableCollection> { + if (parent is OtherResourcesNode) { + val list = children.toMutableList() + // Forces the filter node to the start of the list + val index = list.indexOfFirst { it is DynamicResourceSelectorNode } + if (index > 0) { + list.add(0, list.removeAt(index)) + } + + return list + } + + if (parent !is AwsExplorerRootNode) { + return children + } + + val list = children.toMutableList() + // Forces the other resources node to the end of the list + val index = list.indexOfFirst { it is OtherResourcesNode } + if (index >= 0) { + list.add(list.removeAt(index)) + } + + return list + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/OtherResourcesNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/OtherResourcesNode.kt new file mode 100644 index 0000000000..8eeea66f1f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/OtherResourcesNode.kt @@ -0,0 +1,42 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceActionNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceParentNode +import software.aws.toolkits.jetbrains.core.getResourceNow +import software.aws.toolkits.jetbrains.services.dynamic.CloudControlApiResources +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceSupportedTypes +import software.aws.toolkits.jetbrains.settings.DynamicResourcesSettings +import software.aws.toolkits.resources.message + +class OtherResourcesNode(project: Project, service: AwsExplorerServiceNode) : + AwsExplorerNode(project, service, null), + ResourceParentNode, + ResourceActionNode { + + override fun displayName(): String = message("explorer.node.other") + override fun isAlwaysShowPlus(): Boolean = true + + override fun getChildren(): List> = super.getChildren() + override fun getChildrenInternal(): List> { + val shouldShow = DynamicResourcesSettings.getInstance().selected + val resourcesAvailableInRegion = nodeProject.getResourceNow(CloudControlApiResources.listTypes()).toSet() + + return listOf(DynamicResourceSelectorNode(nodeProject)) + DynamicResourceSupportedTypes.getInstance().getSupportedTypes() + .filter { it in shouldShow } + .map { resourceType -> + if (resourceType in resourcesAvailableInRegion) { + DynamicResourceResourceTypeNode(nodeProject, resourceType) + } else { + UnavailableDynamicResourceTypeNode(nodeProject, resourceType) + } + } + } + + override fun actionGroupName(): String = "aws.toolkit.explorer.dynamic.more.resources" +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/AddResourcesToExplorerAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/AddResourcesToExplorerAction.kt new file mode 100644 index 0000000000..c399aaab37 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/AddResourcesToExplorerAction.kt @@ -0,0 +1,22 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.settings.DynamicResourcesConfigurable +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DynamicresourceTelemetry + +class AddResourcesToExplorerAction : DumbAwareAction( + { message("explorer.node.other.add_remove") }, + AllIcons.Actions.Edit +) { + override fun actionPerformed(e: AnActionEvent) { + ShowSettingsUtil.getInstance().showSettingsDialog(null, DynamicResourcesConfigurable::class.java) + DynamicresourceTelemetry.selectResources(e.project) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/CopyIdentifierAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/CopyIdentifierAction.kt new file mode 100644 index 0000000000..da61abbb1c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/CopyIdentifierAction.kt @@ -0,0 +1,23 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.dynamic.explorer.DynamicResourceNode +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DynamicresourceTelemetry +import java.awt.datatransfer.StringSelection + +class CopyIdentifierAction : + SingleExplorerNodeAction(message("explorer.copy_identifier"), icon = AllIcons.Actions.Copy), + DumbAware { + override fun actionPerformed(selected: DynamicResourceNode, e: AnActionEvent) { + CopyPasteManager.getInstance().setContents(StringSelection(selected.resource.identifier)) + DynamicresourceTelemetry.copyIdentifier(selected.nodeProject, resourceType = selected.resource.type.fullName) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/CreateResourceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/CreateResourceAction.kt new file mode 100644 index 0000000000..d2d61b2dee --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/CreateResourceAction.kt @@ -0,0 +1,42 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.DumbAware +import com.intellij.psi.codeStyle.CodeStyleManager +import com.intellij.psi.util.PsiUtilCore +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettingsOrThrow +import software.aws.toolkits.jetbrains.core.experiments.isEnabled +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.dynamic.CreateDynamicResourceVirtualFile +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceSchemaMapping +import software.aws.toolkits.jetbrains.services.dynamic.JsonResourceModificationExperiment +import software.aws.toolkits.jetbrains.services.dynamic.explorer.DynamicResourceResourceTypeNode +import software.aws.toolkits.resources.message + +class CreateResourceAction : + SingleExplorerNodeAction(message("dynamic_resources.type.explorer.create_resource")), DumbAware { + + override fun actionPerformed(selected: DynamicResourceResourceTypeNode, e: AnActionEvent) { + val file = CreateDynamicResourceVirtualFile( + selected.nodeProject.getConnectionSettingsOrThrow(), + selected.value + ) + // TODO: Populate the file with required properties in the schema + + DynamicResourceSchemaMapping.getInstance().addResourceSchemaMapping(selected.nodeProject, file) + WriteCommandAction.runWriteCommandAction(selected.nodeProject) { + CodeStyleManager.getInstance(selected.nodeProject).reformat(PsiUtilCore.getPsiFile(selected.nodeProject, file)) + FileEditorManager.getInstance(selected.nodeProject).openFile(file, true) + file.isWritable = true + } + } + + override fun update(selected: DynamicResourceResourceTypeNode, e: AnActionEvent) { + e.presentation.isEnabledAndVisible = JsonResourceModificationExperiment.isEnabled() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/DynamicResourceDeleteResourceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/DynamicResourceDeleteResourceAction.kt new file mode 100644 index 0000000000..c7a70f6880 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/DynamicResourceDeleteResourceAction.kt @@ -0,0 +1,48 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettingsOrThrow +import software.aws.toolkits.jetbrains.core.experiments.isEnabled +import software.aws.toolkits.jetbrains.core.explorer.DeleteResourceDialog +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceIdentifier +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceUpdateManager +import software.aws.toolkits.jetbrains.services.dynamic.JsonResourceModificationExperiment +import software.aws.toolkits.jetbrains.services.dynamic.ViewEditableDynamicResourceVirtualFile +import software.aws.toolkits.jetbrains.services.dynamic.explorer.DynamicResourceNode +import software.aws.toolkits.resources.message + +class DynamicResourceDeleteResourceAction : + SingleExplorerNodeAction(message("dynamic_resources.delete_resource"), icon = AllIcons.Actions.Cancel), + DumbAware { + + override fun actionPerformed(selected: DynamicResourceNode, e: AnActionEvent) { + val resourceType = selected.resource.type.fullName + val response = DeleteResourceDialog(selected.nodeProject, resourceType, selected.displayName()).showAndGet() + if (response) { + val dynamicResourceIdentifier = DynamicResourceIdentifier( + selected.nodeProject.getConnectionSettingsOrThrow(), + selected.resource.type.fullName, + selected.resource.identifier + ) + val fileEditorManager = FileEditorManager.getInstance(selected.nodeProject) + fileEditorManager.openFiles.forEach { + if (it is ViewEditableDynamicResourceVirtualFile && it.dynamicResourceIdentifier == dynamicResourceIdentifier) { + ApplicationManager.getApplication().invokeAndWait { fileEditorManager.closeFile(it) } + } + } + DynamicResourceUpdateManager.getInstance(selected.nodeProject).deleteResource(dynamicResourceIdentifier) + } + } + + override fun update(selected: DynamicResourceNode, e: AnActionEvent) { + e.presentation.isEnabledAndVisible = JsonResourceModificationExperiment.isEnabled() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/OpenFileForUpdateAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/OpenFileForUpdateAction.kt new file mode 100644 index 0000000000..673f9ed91d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/OpenFileForUpdateAction.kt @@ -0,0 +1,25 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.experiments.isEnabled +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.dynamic.JsonResourceModificationExperiment +import software.aws.toolkits.jetbrains.services.dynamic.explorer.DynamicResourceNode +import software.aws.toolkits.jetbrains.services.dynamic.explorer.OpenResourceModelSourceAction +import software.aws.toolkits.resources.message + +class OpenFileForUpdateAction : + SingleExplorerNodeAction(message("dynamic_resources.openFileForUpdate_text")), + DumbAware { + override fun actionPerformed(selected: DynamicResourceNode, e: AnActionEvent) { + selected.openResourceModelInEditor(OpenResourceModelSourceAction.EDIT) + } + + override fun update(selected: DynamicResourceNode, e: AnActionEvent) { + e.presentation.isEnabledAndVisible = JsonResourceModificationExperiment.isEnabled() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/OpenReadOnlyFileAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/OpenReadOnlyFileAction.kt new file mode 100644 index 0000000000..12f1586e19 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/OpenReadOnlyFileAction.kt @@ -0,0 +1,19 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.dynamic.explorer.DynamicResourceNode +import software.aws.toolkits.jetbrains.services.dynamic.explorer.OpenResourceModelSourceAction +import software.aws.toolkits.resources.message + +class OpenReadOnlyFileAction : + SingleExplorerNodeAction(message("dynamic_resources.openReadOnlyFile_text")), + DumbAware { + override fun actionPerformed(selected: DynamicResourceNode, e: AnActionEvent) { + selected.openResourceModelInEditor(OpenResourceModelSourceAction.READ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/ViewDocumentationAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/ViewDocumentationAction.kt new file mode 100644 index 0000000000..816edd1a10 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamic/explorer/actions/ViewDocumentationAction.kt @@ -0,0 +1,24 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamic.explorer.actions + +import com.intellij.ide.browsers.BrowserLauncher +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceSupportedTypes +import software.aws.toolkits.jetbrains.services.dynamic.explorer.DynamicResourceResourceTypeNode +import software.aws.toolkits.resources.message + +class ViewDocumentationAction : SingleExplorerNodeAction(message("dynamic_resources.type.explorer.view_documentation")) { + private val supportedType = DynamicResourceSupportedTypes.getInstance() + override fun actionPerformed(selected: DynamicResourceResourceTypeNode, e: AnActionEvent) { + supportedType.getDocs(selected.resourceType)?.let { docUrl -> + BrowserLauncher.instance.browse(docUrl, project = e.project) + } + } + + override fun update(selected: DynamicResourceResourceTypeNode, e: AnActionEvent) { + e.presentation.isEnabledAndVisible = supportedType.getDocs(selected.resourceType) != null + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/Attributes.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/Attributes.kt new file mode 100644 index 0000000000..9fe95d0eb2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/Attributes.kt @@ -0,0 +1,100 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb + +import software.amazon.awssdk.services.dynamodb.model.AttributeValue +import java.util.Base64 + +typealias SearchResults = List>> + +private const val QUOTE = '"' + +/** + * See: + * * https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_AttributeValue.html, + * * https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html + */ +sealed class DynamoAttribute(val value: T) { + abstract val dataType: String + + open fun stringRepresentation() = value.toString() +} + +class StringAttribute(value: String) : DynamoAttribute(value) { + override val dataType: String = "S" + + override fun stringRepresentation(): String = QUOTE + value + QUOTE +} + +class BooleanAttribute(value: Boolean) : DynamoAttribute(value) { + override val dataType: String = "BOOL" +} + +class NumberAttribute(value: String) : DynamoAttribute(value) { + override val dataType: String = "N" +} + +class BinaryAttribute(value: ByteArray) : DynamoAttribute(value) { + override val dataType: String = "B" + + override fun stringRepresentation(): String = Base64.getEncoder().encodeToString(value) +} + +object NullAttribute : DynamoAttribute(/*Dynamo always expects the NUL field to contain true */true) { + override val dataType: String = "NUL" + + override fun stringRepresentation(): String = "" +} + +class StringSetAttribute(value: List) : DynamoAttribute>(value) { + override val dataType: String = "SS" + + override fun stringRepresentation(): String = value.joinToString(prefix = "{", postfix = "}") { + QUOTE + it + QUOTE + } +} + +class NumberSetAttribute(value: List) : DynamoAttribute>(value) { + override val dataType: String = "NS" + + override fun stringRepresentation(): String = value.joinToString(prefix = "{", postfix = "}") +} + +class BinarySetAttribute(value: List) : DynamoAttribute>(value) { + override val dataType: String = "BS" + + override fun stringRepresentation(): String = value.joinToString(prefix = "{", postfix = "}") { + Base64.getEncoder().encodeToString(it) + } +} + +class MapAttribute(value: Map>) : DynamoAttribute>>(value) { + override val dataType: String = "M" + + override fun stringRepresentation(): String = value.entries.joinToString(prefix = "{", postfix = "}") { + "$QUOTE${it.key}$QUOTE: {$QUOTE${it.value.dataType}$QUOTE: ${it.value.stringRepresentation()}}" + } +} + +class ListAttribute(value: List>) : DynamoAttribute>>(value) { + override val dataType: String = "L" + + override fun stringRepresentation(): String = value.joinToString(prefix = "[", postfix = "]") { + "{$QUOTE${it.dataType}$QUOTE: ${it.stringRepresentation()}}" + } +} + +fun AttributeValue.toAttribute(): DynamoAttribute<*> = when { + this.s() != null -> StringAttribute(this.s()) + this.b() != null -> BinaryAttribute(this.b().asByteArray()) + this.bool() != null -> BooleanAttribute(this.bool()) + this.n() != null -> NumberAttribute(this.n()) + this.nul() != null -> NullAttribute + this.hasSs() -> StringSetAttribute(this.ss()) + this.hasNs() -> NumberSetAttribute(this.ns()) + this.hasBs() -> BinarySetAttribute(this.bs().map { it.asByteArray() }) + this.hasM() -> MapAttribute(this.m().mapValues { it.value.toAttribute() }) + this.hasL() -> ListAttribute(this.l().map { it.toAttribute() }) + else -> throw UnsupportedOperationException(this.toString()) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/DynamoDbResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/DynamoDbResources.kt new file mode 100644 index 0000000000..c8074dfb0a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/DynamoDbResources.kt @@ -0,0 +1,13 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource + +object DynamoDbResources { + val LIST_TABLES = ClientBackedCachedResource(DynamoDbClient::class, "dynamodb.list_tables") { + listTablesPaginator().tableNames().toList() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/DynamoDbUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/DynamoDbUtils.kt new file mode 100644 index 0000000000..52c60026dc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/DynamoDbUtils.kt @@ -0,0 +1,21 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.amazon.awssdk.services.dynamodb.model.ExecuteStatementRequest +import software.amazon.awssdk.services.dynamodb.model.ExecuteStatementResponse + +object DynamoDbUtils { + fun DynamoDbClient.executeStatementPaginator(request: ExecuteStatementRequest): Sequence = + // Partiql does not have paginators, do it manually + generateSequence( + seed = this.executeStatement(request.toBuilder().build()), + nextFunction = { + it.nextToken()?.let { token -> + this.executeStatement(request.toBuilder().nextToken(token).build()) + } + }, + ) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/Index.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/Index.kt new file mode 100644 index 0000000000..e1db017f34 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/Index.kt @@ -0,0 +1,11 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb + +data class Index( + val displayName: String, + val indexName: String?, + val partitionKey: String, + val sortKey: String? +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/actions/DeleteTableAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/actions/DeleteTableAction.kt new file mode 100644 index 0000000000..fb8c749fe5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/actions/DeleteTableAction.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.services.dynamodb.actions + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.explorer.actions.DeleteResourceAction +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.services.dynamodb.DynamoDbResources +import software.aws.toolkits.jetbrains.services.dynamodb.editor.DynamoDbVirtualFile +import software.aws.toolkits.jetbrains.services.dynamodb.explorer.DynamoDbTableNode + +class DeleteTableAction : DeleteResourceAction() { + override fun performDelete(selected: DynamoDbTableNode) { + val project = selected.nodeProject + val client = project.awsClient() + + val fileEditorManager = FileEditorManager.getInstance(selected.nodeProject) + fileEditorManager.openFiles.forEach { + if (it is DynamoDbVirtualFile && it.tableArn == selected.resourceArn()) { + // Wait so that we know it closes successfully, otherwise this operation is not a success + ApplicationManager.getApplication().invokeAndWait { + fileEditorManager.closeFile(it) + } + } + } + + client.deleteTable { it.tableName(selected.displayName()) } + project.refreshAwsTree(DynamoDbResources.LIST_TABLES) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbFileIconProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbFileIconProvider.kt new file mode 100644 index 0000000000..eb4423633c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbFileIconProvider.kt @@ -0,0 +1,18 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import com.intellij.ide.FileIconProvider +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import icons.AwsIcons +import javax.swing.Icon + +class DynamoDbFileIconProvider : FileIconProvider { + override fun getIcon(file: VirtualFile, flags: Int, project: Project?): Icon? = if (file is DynamoDbVirtualFile) { + AwsIcons.Resources.DynamoDb.TABLE + } else { + null + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbTableEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbTableEditor.kt new file mode 100644 index 0000000000..adbd358e59 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbTableEditor.kt @@ -0,0 +1,211 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import com.intellij.codeHighlighting.BackgroundEditorHighlighter +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorLocation +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.fileEditor.FileEditorStateLevel +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBLoadingPanel +import com.intellij.ui.components.JBPanelWithEmptyText +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.dynamodb.model.ExecuteStatementRequest +import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement +import software.amazon.awssdk.services.dynamodb.model.KeyType +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.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.services.dynamodb.DynamoDbUtils.executeStatementPaginator +import software.aws.toolkits.jetbrains.services.dynamodb.Index +import software.aws.toolkits.jetbrains.services.dynamodb.toAttribute +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DynamoDbFetchType +import software.aws.toolkits.telemetry.DynamoDbIndexType +import software.aws.toolkits.telemetry.DynamodbTelemetry +import software.aws.toolkits.telemetry.Result +import java.awt.BorderLayout +import java.beans.PropertyChangeListener +import javax.swing.JComponent + +class DynamoDbTableEditor(private val dynamoTable: DynamoDbVirtualFile) : UserDataHolderBase(), FileEditor { + data class EditorState(var maxResults: Int = DEFAULT_MAX_RESULTS) + + private val coroutineScope = disposableCoroutineScope(this) + private val bg = getCoroutineBgContext() + private val edt = getCoroutineUiContext() + + val editorState = EditorState() + + private val loadingPanel = JBLoadingPanel(BorderLayout(), this) + + private lateinit var searchPanel: SearchPanel + private lateinit var tableInfo: TableInfo + private val searchResults = SearchResultsPanel() + + init { + // Async load in the editor so we can get the table info + loadingPanel.startLoading() + + coroutineScope.launch { + tableInfo = try { + getTableInfo(dynamoTable.tableName) + } catch (e: Exception) { + withContext(edt) { + loadingPanel.add( + JBPanelWithEmptyText().also { + it.emptyText.setText( + message("dynamodb.viewer.open.failed.with_error", e.message ?: message("general.unknown_error")), + SimpleTextAttributes.ERROR_ATTRIBUTES + ) + }, + BorderLayout.CENTER + ) + loadingPanel.stopLoading() + } + return@launch + } + + withContext(edt) { + searchPanel = SearchPanel( + tableInfo = tableInfo, + initialSearchType = SearchPanel.SearchType.Scan, + initialSearchIndex = tableInfo.tableIndex, + runAction = { executeSearch() } + ) + + loadingPanel.add(searchPanel.getComponent(), BorderLayout.NORTH) + loadingPanel.add(searchResults, BorderLayout.CENTER) + + executeSearch(PREVIEW_SIZE) + + loadingPanel.stopLoading() + } + } + } + + private fun getTableInfo(tableName: String): TableInfo { + fun keySchemaToIndex(name: String?, keySchema: List) = Index( + displayName = name ?: tableName, + indexName = name, + partitionKey = keySchema.first { it.keyType() == KeyType.HASH }.attributeName(), + sortKey = keySchema.find { it.keyType() == KeyType.RANGE }?.attributeName() + ) + + val describeResponse = dynamoTable.dynamoDbClient.describeTable { + it.tableName(tableName) + }.table() + + return TableInfo( + tableName = tableName, + tableIndex = keySchemaToIndex(name = null, describeResponse.keySchema()), + localSecondary = describeResponse.localSecondaryIndexes().map { + keySchemaToIndex(it.indexName(), it.keySchema()) + }, + globalSecondary = describeResponse.globalSecondaryIndexes().map { + keySchemaToIndex(it.indexName(), it.keySchema()) + } + ) + } + + private fun executeSearch(maxResults: Int = editorState.maxResults) { + coroutineScope.launch(edt) { + searchResults.setBusy(true) + val (index, partiqlStatement) = searchPanel.getSearchQuery() + val fetchType = when (searchPanel.searchType) { + SearchPanel.SearchType.Scan -> DynamoDbFetchType.Scan + SearchPanel.SearchType.Query -> DynamoDbFetchType.Query + } + + withContext(bg) { + LOG.debug { "Querying Dynamo with '$partiqlStatement'" } + + val request = ExecuteStatementRequest.builder().statement(partiqlStatement).build() + var telemetryResult = Result.Succeeded + try { + val results = dynamoTable.dynamoDbClient.executeStatementPaginator(request) + .flatMap { it.items().asSequence() } + .map { it.mapValues { attr -> attr.value.toAttribute() } } + .take(maxResults) + .toList() + + withContext(edt) { + searchResults.setResults(index, results) + searchResults.setBusy(false) + } + } catch (e: Exception) { + LOG.error(e) { "Query failed to execute" } + telemetryResult = Result.Failed + withContext(edt) { + searchResults.setError(e) + searchResults.setBusy(false) + } + } finally { + val indexType = when (index) { + tableInfo.tableIndex -> DynamoDbIndexType.Primary + in tableInfo.localSecondary -> DynamoDbIndexType.LocalSecondary + in tableInfo.globalSecondary -> DynamoDbIndexType.GlobalSecondary + else -> DynamoDbIndexType.Unknown + } + DynamodbTelemetry.fetchRecords( + dynamoTable.connectionSettings, + result = telemetryResult, + dynamoDbFetchType = fetchType, + dynamoDbIndexType = indexType + ) + } + } + } + } + + override fun getComponent(): JComponent = loadingPanel + + override fun getName(): String = "DynamoDBTable" + + override fun getPreferredFocusedComponent(): JComponent? = null + + override fun isValid(): Boolean = true + + override fun getCurrentLocation(): FileEditorLocation? = null + + override fun getState(level: FileEditorStateLevel): FileEditorState = FileEditorState.INSTANCE + + override fun isModified(): Boolean = false + + override fun dispose() {} + + override fun addPropertyChangeListener(listener: PropertyChangeListener) {} + + override fun deselectNotify() {} + + override fun getBackgroundHighlighter(): BackgroundEditorHighlighter? = null + + override fun selectNotify() {} + + override fun removePropertyChangeListener(listener: PropertyChangeListener) {} + + override fun setState(state: FileEditorState) {} + + override fun getFile(): VirtualFile = dynamoTable + + companion object { + private val LOG = getLogger() + + /** + * The number of rows to list with the initial scan preview + */ + private const val PREVIEW_SIZE = 20 + + /* Matches the options from the console */ + val MAX_RESULTS_OPTIONS = listOf(50, 100, 200, 300) + private const val DEFAULT_MAX_RESULTS = 50 + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbTableEditorProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbTableEditorProvider.kt new file mode 100644 index 0000000000..31fc3498ff --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbTableEditorProvider.kt @@ -0,0 +1,46 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettingsOrThrow +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DynamodbTelemetry +import software.aws.toolkits.telemetry.Result + +class DynamoDbTableEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile): Boolean = file is DynamoDbVirtualFile + + override fun createEditor(project: Project, file: VirtualFile): FileEditor = DynamoDbTableEditor(file as DynamoDbVirtualFile) + + override fun getEditorTypeId(): String = "DynamoDbTableEditor" + + override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR + + companion object { + fun openViewer(project: Project, tableArn: String) { + try { + val virtualFile = DynamoDbVirtualFile(tableArn, project.getConnectionSettingsOrThrow()) + FileEditorManager.getInstance(project).openTextEditor( + OpenFileDescriptor(project, virtualFile), + /*focusEditor*/ + true + ) + + DynamodbTelemetry.openTable(project, Result.Succeeded) + } catch (e: Exception) { + e.notifyError(message("dynamodb.viewer.open.failed")) + DynamodbTelemetry.openTable(project, Result.Failed) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbVirtualFile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbVirtualFile.kt new file mode 100644 index 0000000000..4598cca64e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/DynamoDbVirtualFile.kt @@ -0,0 +1,41 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import com.intellij.testFramework.LightVirtualFile +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.core.awsClient + +/** + * Light virtual file to represent a dynamo table, used to open the custom editor + */ +class DynamoDbVirtualFile(val tableArn: String, val connectionSettings: ConnectionSettings) : LightVirtualFile(tableArn) { + val dynamoDbClient: DynamoDbClient = connectionSettings.awsClient() + val tableName = tableArn.substringAfterLast('/') + + /** + * Override the presentable name so editor tabs only use table name + */ + override fun getPresentableName(): String = tableName + + /** + * Use the ARN as the path so editor tool tips can be differentiated + */ + override fun getPath(): String = tableArn + + override fun isWritable(): Boolean = false + + /** + * We use the ARN as the equality, so that we can show 2 tables from different accounts/regions with same name + */ + override fun equals(other: Any?): Boolean { + if (other !is DynamoDbVirtualFile) { + return false + } + return this.tableArn == other.tableArn + } + + override fun hashCode(): Int = tableArn.hashCode() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/IndexComboBoxModel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/IndexComboBoxModel.kt new file mode 100644 index 0000000000..41b47398c5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/IndexComboBoxModel.kt @@ -0,0 +1,40 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import com.intellij.ui.CollectionComboBoxModel +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.jetbrains.services.dynamodb.Index +import software.aws.toolkits.resources.message + +class IndexComboBoxModel(indexes: TableInfo) : CollectionComboBoxModel() { + val separatorNames: Map + + init { + val builtGroupings = buildGroupings(indexes) + + replaceAll(builtGroupings.first) + separatorNames = builtGroupings.second + } + + private fun buildGroupings(tableInfo: TableInfo): Pair, Map> { + val groupIndexes = mutableMapOf() + val groups = buildList { + // We dont put a header on the primary table index + add(tableInfo.tableIndex) + + if (tableInfo.localSecondary.isNotEmpty()) { + groupIndexes[size] = message("dynamodb.viewer.search.index.local") + addAll(tableInfo.localSecondary) + } + + if (tableInfo.globalSecondary.isNotEmpty()) { + groupIndexes[size] = message("dynamodb.viewer.search.index.global") + addAll(tableInfo.globalSecondary) + } + } + + return groups to groupIndexes + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/IndexRenderer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/IndexRenderer.kt new file mode 100644 index 0000000000..742d6ec5a5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/IndexRenderer.kt @@ -0,0 +1,47 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import com.intellij.ui.SeparatorWithText +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import software.aws.toolkits.jetbrains.services.dynamodb.Index +import java.awt.Component +import java.awt.Font +import javax.swing.JComponent +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.border.Border + +class IndexRenderer(private val model: IndexComboBoxModel) : ListCellRenderer { + private val rowRenderer = SimpleListCellRenderer.create("") { it.displayName } + private val separator = SeparatorWithText().apply { + textForeground = UIUtil.getListForeground() + font = font.deriveFont(Font.PLAIN) + } + + override fun getListCellRendererComponent(list: JList, value: Index?, index: Int, isSelected: Boolean, cellHasFocus: Boolean): Component { + val row = rowRenderer.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) as JComponent + + val separatorName = model.separatorNames[index] + return if (separatorName != null) { + separator.caption = separatorName + + return object : BorderLayoutPanel() { + override fun setBorder(border: Border?) { + // Redirect the border onto the row and not the panel, else it gets indented into the list and breaks highlighting + row.border = border + } + }.apply { + background = UIUtil.getListBackground() + + addToTop(separator) + addToCenter(row) + } + } else { + row + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/SearchPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/SearchPanel.kt new file mode 100644 index 0000000000..fde5f5cb4f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/SearchPanel.kt @@ -0,0 +1,82 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import com.intellij.openapi.ui.DialogPanel +import com.intellij.ui.HideableDecorator +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.toNullableProperty +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.jetbrains.services.dynamodb.Index +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +class SearchPanel(private val tableInfo: TableInfo, initialSearchType: SearchType, initialSearchIndex: Index, runAction: () -> Unit) { + enum class SearchType { Scan, Query } + + private val searchIndexModel = IndexComboBoxModel(tableInfo) + + var searchType = initialSearchType + var searchIndex = initialSearchIndex + + private val queryScanPanel: DialogPanel = panel { + row { + label(message("dynamodb.viewer.search.index.label")) + comboBox(searchIndexModel, IndexRenderer(searchIndexModel)).bindItem(::searchIndex.toNullableProperty()).align(AlignX.FILL) + } + + row { + button(message("dynamodb.viewer.search.run.title")) { runAction() } + } + }.withBorder(JBUI.Borders.empty(0, UIUtil.PANEL_REGULAR_INSETS.left)) + + private val panel: JComponent = JPanel(BorderLayout()).apply { + val decorator = HideableDecorator(this, message("dynamodb.viewer.search.title"), false) + decorator.setOn(false) // Collapse by default + decorator.setContentComponent(queryScanPanel) + } + + fun getComponent() = panel + + fun getSearchQuery(): Pair { + queryScanPanel.apply() + + val fromField = buildString { + append('"') + append(verifyString(tableInfo.tableName)) + append('"') + + searchIndex.indexName?.let { + append('.') + append('"') + append(verifyString(it)) + append('"') + } + } + + return searchIndex to buildString { + append("SELECT * FROM ") + append(fromField) + + if (searchType == SearchType.Query) { + append(" WHERE ") + TODO() + } + } + } + + private fun verifyString(str: String): String = str.takeIf { + str.matches(NAMING_RULES) + } ?: throw IllegalArgumentException("'$str' does not match $NAMING_RULES") + + private companion object { + // https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.NamingRules + private val NAMING_RULES = """^[A-Za-z0-9_\-.]+${'$'}""".toRegex() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/SearchResultsPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/SearchResultsPanel.kt new file mode 100644 index 0000000000..e6aff8dfe1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/SearchResultsPanel.kt @@ -0,0 +1,55 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.SideBorder +import com.intellij.util.ui.components.BorderLayoutPanel +import software.aws.toolkits.jetbrains.services.dynamodb.Index +import software.aws.toolkits.jetbrains.services.dynamodb.SearchResults + +class SearchResultsPanel : BorderLayoutPanel() { + private val resultsTable = TableResults() + + init { + val primaryToolbar = createToolbar("aws.toolkit.dynamoViewer.toolbar.primary") + val secondaryToolbar = createToolbar("aws.toolkit.dynamoViewer.toolbar.secondary") + + val toolbarPanel = BorderLayoutPanel().apply { + addToLeft(primaryToolbar.component) + addToRight(secondaryToolbar.component) + } + + addToTop(toolbarPanel) + addToCenter(ScrollPaneFactory.createScrollPane(resultsTable)) + + border = IdeBorderFactory.createBorder(SideBorder.TOP) + } + + private fun createToolbar(group: String): ActionToolbar { + val actionManager = ActionManager.getInstance() + val actionGroup = actionManager.getAction(group) as ActionGroup + + val toolbar = actionManager.createActionToolbar(ActionPlaces.UNKNOWN, actionGroup, true) + toolbar.setTargetComponent(resultsTable) + return toolbar + } + + fun setBusy(isBusy: Boolean) { + resultsTable.setPaintBusy(isBusy) + } + + fun setResults(index: Index, results: SearchResults) { + resultsTable.setResults(index, results) + } + + fun setError(e: Exception) { + resultsTable.setError(e) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/TableInfo.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/TableInfo.kt new file mode 100644 index 0000000000..333e64910c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/TableInfo.kt @@ -0,0 +1,8 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import software.aws.toolkits.jetbrains.services.dynamodb.Index + +data class TableInfo(val tableName: String, val tableIndex: Index, val localSecondary: List, val globalSecondary: List) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/TableResults.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/TableResults.kt new file mode 100644 index 0000000000..8f1f3deefa --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/TableResults.kt @@ -0,0 +1,86 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor + +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.TableSpeedSearch +import com.intellij.ui.table.JBTable +import com.intellij.util.containers.BidirectionalMap +import com.intellij.util.containers.Convertor +import com.intellij.util.ui.StatusText +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.jetbrains.services.dynamodb.DynamoAttribute +import software.aws.toolkits.jetbrains.services.dynamodb.Index +import software.aws.toolkits.jetbrains.services.dynamodb.SearchResults +import software.aws.toolkits.resources.message +import javax.swing.table.AbstractTableModel +import javax.swing.table.DefaultTableCellRenderer + +class TableResults : JBTable(TableModel(BidirectionalMap(), emptyList())) { + init { + // Make sure we call the method, and not edit the protected field + setAutoResizeMode(AUTO_RESIZE_ALL_COLUMNS) + setCellSelectionEnabled(true) + + font = EditorColorsManager.getInstance().globalScheme.getFont(EditorFontType.PLAIN) + + getTableHeader().reorderingAllowed = false + + val tableCellRenderer = DefaultTableCellRenderer() + tableCellRenderer.putClientProperty("html.disable", true) + setDefaultRenderer(Any::class.java, tableCellRenderer) + + TableSpeedSearch(this, Convertor { (it as? DynamoAttribute<*>)?.stringRepresentation() }) + } + + override fun getModel(): TableModel = super.getModel() as TableModel + + fun setResults(index: Index, results: SearchResults) { + model = TableModel.buildModel(index, results) + emptyText.text = StatusText.getDefaultEmptyText() + setPaintBusy(false) + } + + fun setError(e: Exception) { + emptyText.setText(e.message ?: message("general.unknown_error"), SimpleTextAttributes.ERROR_ATTRIBUTES) + setPaintBusy(false) + } +} + +class TableModel(private val columns: BidirectionalMap, private val data: List>>) : AbstractTableModel() { + override fun getRowCount(): Int = data.size + override fun getColumnCount(): Int = columns.size + override fun getColumnName(column: Int): String = columns.getKeysByValue(column)?.firstOrNull() ?: "" + + override fun getValueAt(rowIndex: Int, columnIndex: Int): String? { + val columnName = getColumnName(columnIndex) + return data[rowIndex][columnName]?.stringRepresentation() + } + + companion object { + fun buildModel(index: Index, data: SearchResults): TableModel { + // Build the columns by putting the index fields first, then sort the rest of the attributes by name (alphabetically) + val columns = buildList { + add(index.partitionKey) + index.sortKey?.let { + add(it) + } + + val attributes = data.asSequence() + .flatMap { it.keys.asSequence() } + .filterNot { it == index.partitionKey || it == index.sortKey } + .toSortedSet() + + addAll(attributes) + } + .asSequence() + .mapIndexed { idx, attribute -> attribute to idx } + .toMap(BidirectionalMap()) + + return TableModel(columns, data) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/actions/ConfigureMaxResultsAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/actions/ConfigureMaxResultsAction.kt new file mode 100644 index 0000000000..18181738a5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/editor/actions/ConfigureMaxResultsAction.kt @@ -0,0 +1,34 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.editor.actions + +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ComputableActionGroup +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.services.dynamodb.editor.DynamoDbTableEditor + +class ConfigureMaxResultsAction : ComputableActionGroup.Simple(/* popup */ true) { + override fun computeChildren(manager: ActionManager): Array = DynamoDbTableEditor.MAX_RESULTS_OPTIONS + .map { (ChangeMaxResults(it)) }.toTypedArray() + + private class ChangeMaxResults(private val choice: Int) : ToggleAction(choice.toString()), DumbAware { + override fun isSelected(e: AnActionEvent): Boolean = getEditorState(e.dataContext)?.maxResults == choice + + override fun setSelected(e: AnActionEvent, state: Boolean) { + if (state) { + getEditorState(e.dataContext)?.maxResults = choice + } + } + + private fun getEditorState(dataContext: DataContext): DynamoDbTableEditor.EditorState? { + val dynamoTableEditor = dataContext.getData(PlatformDataKeys.FILE_EDITOR) as? DynamoDbTableEditor + return dynamoTableEditor?.editorState + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/explorer/DynamoDbExplorerNodes.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/explorer/DynamoDbExplorerNodes.kt new file mode 100644 index 0000000000..2c5e271cd0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/dynamodb/explorer/DynamoDbExplorerNodes.kt @@ -0,0 +1,41 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.dynamodb.explorer + +import com.intellij.openapi.project.Project +import icons.AwsIcons +import software.amazon.awssdk.services.dynamodb.DynamoDbClient +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.credentials.activeRegion +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 +import software.aws.toolkits.jetbrains.core.getResourceIfPresent +import software.aws.toolkits.jetbrains.services.dynamodb.DynamoDbResources +import software.aws.toolkits.jetbrains.services.dynamodb.editor.DynamoDbTableEditorProvider +import software.aws.toolkits.jetbrains.services.sts.StsResources +import software.aws.toolkits.resources.message + +class DynamoDbServiceNode(project: Project, service: AwsExplorerServiceNode) : + CacheBackedAwsExplorerServiceRootNode(project, service, DynamoDbResources.LIST_TABLES) { + override fun displayName(): String = message("explorer.node.dynamo") + override fun toNode(child: String): AwsExplorerNode<*> = DynamoDbTableNode(nodeProject, child) +} + +class DynamoDbTableNode(project: Project, private val tableName: String) : + AwsExplorerResourceNode(project, DynamoDbClient.SERVICE_METADATA_ID, tableName, AwsIcons.Resources.DynamoDb.TABLE) { + private val arn = run { + val account = tryOrNull { nodeProject.getResourceIfPresent(StsResources.ACCOUNT) } ?: "" + "arn:${nodeProject.activeRegion().partitionId}:dynamodb:${nodeProject.activeRegion().id}:$account:table/$tableName" + } + + override fun displayName(): String = tableName + override fun resourceType(): String = "table" + override fun resourceArn(): String = arn + + override fun onDoubleClick() { + DynamoDbTableEditorProvider.openViewer(nodeProject, resourceArn()) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/CreateEcrRepoDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/CreateEcrRepoDialog.kt new file mode 100644 index 0000000000..0c4c5ac5f6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/CreateEcrRepoDialog.kt @@ -0,0 +1,111 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.ecr.EcrClient +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.EcrTelemetry +import software.aws.toolkits.telemetry.Result +import java.awt.Component +import javax.swing.JComponent + +class CreateEcrRepoDialog( + private val project: Project, + private val ecrClient: EcrClient, + parent: Component? = null +) : DialogWrapper(project, parent, false, IdeModalityType.PROJECT) { + var repoName: String = "" + + private val panel = panel { + row(message("general.name.label")) { + textField() + .focused() + .validationOnApply { + if (it.text.isBlank()) { + error(message("ecr.create.repo.validation.empty")) + } else { + null + } + } + .bindText(::repoName) + } + } + + init { + title = message("ecr.create.repo.title") + setOKButtonText(message("general.create_button")) + + init() + } + + override fun createCenterPanel(): JComponent = panel + + override fun doCancelAction() { + EcrTelemetry.createRepository(project, Result.Cancelled) + super.doCancelAction() + } + + override fun continuousValidation() = false + + override fun doValidateAll(): List = + panel.validateCallbacks.mapNotNull { it() } + + override fun doOKAction() { + val validation = doValidateAll() + if (!validation.isEmpty()) { + setErrorInfoAll(validation) + return + } + panel.apply() + + if (okAction.isEnabled) { + setOKButtonText(message("general.create_in_progress")) + isOKActionEnabled = false + + ApplicationManager.getApplication().executeOnPooledThread { + try { + createRepo() + ApplicationManager.getApplication().invokeLater( + { + close(OK_EXIT_CODE) + }, + ModalityState.stateForComponent(rootPane) + ) + project.refreshAwsTree(EcrResources.LIST_REPOS) + EcrTelemetry.createRepository(project, Result.Succeeded) + } catch (e: Exception) { + ApplicationManager.getApplication().invokeLater( + { + setErrorText(e.message, panel) + setOKButtonText(message("general.create_button")) + isOKActionEnabled = true + }, + ModalityState.stateForComponent(rootPane) + ) + EcrTelemetry.createRepository(project, Result.Failed) + } + } + } + } + + fun createRepo() { + ecrClient.createRepository { it.repositoryName(repoName.trim()) } + } + + @TestOnly + fun validateForTest(): List { + panel.reset() + return doValidateAll() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/EcrExplorerNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/EcrExplorerNode.kt new file mode 100644 index 0000000000..40de6ce5ed --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/EcrExplorerNode.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.services.ecr + +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import icons.AwsIcons +import software.amazon.awssdk.services.ecr.EcrClient +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.ResourceActionNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceParentNode +import software.aws.toolkits.jetbrains.core.getResourceNow +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository +import software.aws.toolkits.resources.message + +class EcrServiceNode(project: Project, service: AwsExplorerServiceNode) : AwsExplorerServiceRootNode(project, service) { + override fun displayName(): String = message("explorer.node.ecr") + + override fun getChildrenInternal(): List> = + nodeProject.getResourceNow(EcrResources.LIST_REPOS).map { EcrRepositoryNode(nodeProject, it) } +} + +class EcrRepositoryNode( + project: Project, + val repository: Repository +) : + AwsExplorerResourceNode( + project, + EcrClient.SERVICE_NAME, + repository.repositoryName, + AwsIcons.Resources.ECR_REPOSITORY + ), + ResourceParentNode { + + override fun resourceType(): String = "repository" + + override fun resourceArn() = repository.repositoryArn + + override fun isAlwaysShowPlus(): Boolean = true + override fun isAlwaysLeaf(): Boolean = false + + override fun getChildren(): List> = super.getChildren() + override fun getChildrenInternal(): List> = nodeProject + .getResourceNow(EcrResources.listTags(repository.repositoryName)) + .map { EcrTagNode(nodeProject, repository, it) } +} + +class EcrTagNode(project: Project, val repository: Repository, val tag: String) : AwsExplorerNode(project, tag, null), ResourceActionNode { + override fun actionGroupName(): String = "aws.toolkit.explorer.ecr.tag" + override fun isAlwaysShowPlus(): Boolean = false + override fun isAlwaysLeaf(): Boolean = true + override fun getChildren(): List> = emptyList() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/EcrUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/EcrUtils.kt new file mode 100644 index 0000000000..287ae97358 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/EcrUtils.kt @@ -0,0 +1,151 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr + +import com.intellij.docker.DockerCloudConfiguration +import com.intellij.docker.DockerCloudType +import com.intellij.docker.DockerDeploymentConfiguration +import com.intellij.docker.deploymentSource.DockerFileDeploymentSourceType +import com.intellij.docker.registry.DockerRepositoryModel +import com.intellij.execution.RunManager +import com.intellij.execution.RunnerAndConfigurationSettings +import com.intellij.openapi.project.Project +import com.intellij.remoteServer.configuration.RemoteServersManager +import com.intellij.remoteServer.impl.configuration.deployment.DeployToServerRunConfiguration +import com.intellij.util.Base64 +import software.amazon.awssdk.services.ecr.model.AuthorizationData +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.docker.DockerRuntimeFacade +import software.aws.toolkits.jetbrains.core.docker.ToolkitDockerAdapter +import software.aws.toolkits.jetbrains.core.docker.compatability.DockerRegistry +import software.aws.toolkits.jetbrains.core.docker.getDockerServerRuntimeFacade +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import java.util.concurrent.CompletableFuture +import software.amazon.awssdk.services.ecr.model.Repository as SdkRepository + +typealias DockerRunConfiguration = DeployToServerRunConfiguration + +data class EcrLogin( + val username: String, + val password: String +) { + override fun toString() = "EcrLogin@${hashCode()}" +} + +sealed class EcrPushRequest +data class ImageEcrPushRequest( + val dockerRuntimeFacade: DockerRuntimeFacade, + val localImageId: String, + val remoteRepo: Repository, + val remoteTag: String +) : EcrPushRequest() + +data class DockerfileEcrPushRequest( + val dockerBuildConfiguration: DockerRunConfiguration, + val remoteRepo: Repository, + val remoteTag: String +) : EcrPushRequest() + +object EcrUtils { + val LOG = getLogger() + + fun buildDockerRepositoryModel(ecrLogin: EcrLogin?, repository: Repository, tag: String) = DockerRepositoryModel().also { + val repoUri = repository.repositoryUri + it.repository = repoUri + it.tag = tag + it.registry = DockerRegistry().also { registry -> + registry.address = repoUri + + ecrLogin?.let { login -> + val (username, password) = login + registry.username = username + registry.password = password + } + } + } + + suspend fun pushImage(project: Project, ecrLogin: EcrLogin, pushRequest: EcrPushRequest) = + when (pushRequest) { + is DockerfileEcrPushRequest -> { + LOG.debug { "Building Docker image from ${pushRequest.dockerBuildConfiguration}" } + buildAndPushDockerfile(project, ecrLogin, pushRequest) + } + is ImageEcrPushRequest -> { + LOG.debug { "Pushing '${pushRequest.localImageId}' to ECR" } + val model = buildDockerRepositoryModel(ecrLogin, pushRequest.remoteRepo, pushRequest.remoteTag) + ToolkitDockerAdapter(project, pushRequest.dockerRuntimeFacade).pushImage(pushRequest.localImageId, model) + } + } + + private suspend fun buildAndPushDockerfile( + project: Project, + ecrLogin: EcrLogin, + pushRequest: DockerfileEcrPushRequest + ): CompletableFuture { + val (runConfiguration, remoteRepo, remoteTag) = pushRequest + // use connection specified in run configuration + val server = RemoteServersManager.getInstance().findByName(runConfiguration.serverName, runConfiguration.serverType) + val dockerRuntime = getDockerServerRuntimeFacade(project, server) + + // find the built image and send to ECR + val imageIdPrefix = ToolkitDockerAdapter(project, dockerRuntime).hackyBuildDockerfileWithUi(project, pushRequest) + if (imageIdPrefix == null) { + notifyError(message("ecr.push.title"), message("ecr.push.unknown_exception")) + return CompletableFuture.completedFuture(Unit) + } + + LOG.debug { "Finding built image with prefix '$imageIdPrefix'" } + val imageId = dockerRuntime.agent.getImages(null).first { it.imageId.startsWith(imageIdPrefix) }.imageId + LOG.debug { "Found image with full id '$imageId'" } + + return pushImage(project, ecrLogin, ImageEcrPushRequest(dockerRuntime, imageId, remoteRepo, remoteTag)) + } + + fun dockerRunConfigurationFromPath(project: Project, configurationName: String, path: String): RunnerAndConfigurationSettings { + val remoteServersManager = RemoteServersManager.getInstance() + val dockerServerType = DockerCloudType.getInstance() + if (remoteServersManager.getServers(dockerServerType).isEmpty()) { + // add the default configuration if one doesn't exist + remoteServersManager.addServer(remoteServersManager.createServer(dockerServerType)) + } + + val runManager = RunManager.getInstance(project) + val factory = DockerCloudType.getRunConfigurationType().getFactoryForType(DockerFileDeploymentSourceType.getInstance()) + val settings = runManager.createConfiguration(configurationName, factory) + val configurator = DockerCloudType.getInstance().createDeploymentConfigurator(project) + (settings.configuration as DockerRunConfiguration).apply { + val sourceType = DockerFileDeploymentSourceType.getInstance() + deploymentSource = sourceType.singletonSource + deploymentConfiguration = configurator.createDefaultConfiguration(deploymentSource) + suggestedName()?.let { name = it } + deploymentConfiguration.sourceFilePath = path + onNewConfigurationCreated() + } + + runManager.addConfiguration(settings) + + return settings + } +} + +fun AuthorizationData.getDockerLogin(): EcrLogin { + // service returns token as base64-encoded string with the format 'user:password' + val auth = Base64.decode(this.authorizationToken()).toString(Charsets.UTF_8).split(':', limit = 2) + + return EcrLogin( + auth.first(), + auth.last() + ) +} + +fun SdkRepository.toToolkitEcrRepository(): Repository? { + val name = repositoryName() ?: return null + val arn = repositoryArn() ?: return null + val uri = repositoryUri() ?: return null + + return Repository(name, arn, uri) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CopyRepositoryUriAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CopyRepositoryUriAction.kt new file mode 100644 index 0000000000..2b836c00c5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CopyRepositoryUriAction.kt @@ -0,0 +1,21 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.ecr.EcrRepositoryNode +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.EcrTelemetry +import java.awt.datatransfer.StringSelection + +class CopyRepositoryUriAction : SingleResourceNodeAction(message("ecr.copy_uri.action")), DumbAware { + override fun actionPerformed(selected: EcrRepositoryNode, e: AnActionEvent) { + val copyPasteManager = CopyPasteManager.getInstance() + copyPasteManager.setContents(StringSelection(selected.repository.repositoryUri)) + EcrTelemetry.copyRepositoryUri(selected.nodeProject) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CopyTagUriAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CopyTagUriAction.kt new file mode 100644 index 0000000000..68a14ffc93 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CopyTagUriAction.kt @@ -0,0 +1,21 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.ecr.EcrTagNode +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.EcrTelemetry +import java.awt.datatransfer.StringSelection + +class CopyTagUriAction : SingleExplorerNodeAction(message("ecr.copy_image_uri.action"), null, null), DumbAware { + override fun actionPerformed(selected: EcrTagNode, e: AnActionEvent) { + val copyPasteManager = CopyPasteManager.getInstance() + copyPasteManager.setContents(StringSelection("${selected.repository.repositoryUri}:${selected.tag}")) + EcrTelemetry.copyTagUri(selected.nodeProject) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CreateAppRunnerServiceAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CreateAppRunnerServiceAction.kt new file mode 100644 index 0000000000..f4d70cdb71 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CreateAppRunnerServiceAction.kt @@ -0,0 +1,21 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.apprunner.ui.CreationDialog +import software.aws.toolkits.jetbrains.services.ecr.EcrTagNode +import software.aws.toolkits.resources.message + +class CreateAppRunnerServiceAction : + SingleExplorerNodeAction(message("ecr.create.app_runner_service.action"), null, null), + DumbAware { + override fun actionPerformed(selected: EcrTagNode, e: AnActionEvent) { + val project = e.getRequiredData(PlatformDataKeys.PROJECT) + CreationDialog(project, "${selected.repository.repositoryUri}:${selected.tag}").show() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CreateRepositoryAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CreateRepositoryAction.kt new file mode 100644 index 0000000000..94c529e8b8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/CreateRepositoryAction.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.services.ecr.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.project.DumbAwareAction +import software.amazon.awssdk.services.ecr.EcrClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.services.ecr.CreateEcrRepoDialog +import software.aws.toolkits.resources.message + +class CreateRepositoryAction : DumbAwareAction(message("ecr.create.repo.action")) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(LangDataKeys.PROJECT) + val client: EcrClient = project.awsClient() + CreateEcrRepoDialog(project, client).show() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/DeleteRepositoryAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/DeleteRepositoryAction.kt new file mode 100644 index 0000000000..41e7a41fa0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/DeleteRepositoryAction.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.services.ecr.actions + +import software.amazon.awssdk.services.ecr.EcrClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.explorer.actions.DeleteResourceAction +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.services.ecr.EcrRepositoryNode +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.resources.message + +class DeleteRepositoryAction : DeleteResourceAction(message("ecr.delete.repo.action")) { + override fun performDelete(selected: EcrRepositoryNode) { + val client: EcrClient = selected.nodeProject.awsClient() + client.deleteRepository { it.repositoryName(selected.repository.repositoryName) } + selected.nodeProject.refreshAwsTree(EcrResources.LIST_REPOS) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/DeleteTagAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/DeleteTagAction.kt new file mode 100644 index 0000000000..8769b1dcb7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/DeleteTagAction.kt @@ -0,0 +1,98 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.ui.Messages +import software.amazon.awssdk.services.ecr.EcrClient +import software.amazon.awssdk.services.ecr.model.ImageIdentifier +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.explorer.actions.ExplorerNodeAction +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.services.ecr.EcrTagNode +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.EcrTelemetry +import software.aws.toolkits.telemetry.Result + +class DeleteTagAction : ExplorerNodeAction(message("ecr.delete.tag.action", 0), null, AllIcons.Actions.Cancel), DumbAware { + override fun update(selected: List, e: AnActionEvent) { + // Only show up if the selected are part of one repository + e.presentation.isVisible = selected.map { it.repository.repositoryName }.toSet().size == 1 + e.presentation.text = message("ecr.delete.tag.action", selected.size) + } + + override fun actionPerformed(selected: List, e: AnActionEvent) { + val project = e.getRequiredData(LangDataKeys.PROJECT) + if (selected.isEmpty()) { + return + } + val repositoryName = selected.first().repository.repositoryName + val response = Messages.showOkCancelDialog( + project, + message("ecr.delete.tag.description", selected.size, repositoryName), + message("ecr.delete.tag.action", selected.size), + message("general.delete"), + Messages.getCancelButton(), + Messages.getWarningIcon() + ) + + if (response != Messages.OK) { + EcrTelemetry.deleteTags(project = project, result = Result.Cancelled, value = selected.size.toDouble()) + return + } + + ProgressManager.getInstance().run( + object : Task.Backgroundable( + project, + message("ecr.delete.tag.deleting"), + false, + ALWAYS_BACKGROUND + ) { + override fun run(indicator: ProgressIndicator) { + try { + val client: EcrClient = project.awsClient() + client.batchDeleteImage { + it + .repositoryName(repositoryName) + .imageIds( + selected.map { node -> + ImageIdentifier.builder().imageTag(node.tag).build() + } + ) + } + notifyInfo( + project = project, + title = message("aws.notification.title"), + content = message("ecr.delete.tag.succeeded", selected.size, repositoryName) + ) + project.refreshAwsTree(EcrResources.listTags(repositoryName)) + EcrTelemetry.deleteTags(project = project, result = Result.Succeeded, value = selected.size.toDouble()) + } catch (e: Exception) { + LOG.error(e) { "Exception thrown while trying to delete ${selected.size} tags" } + notifyError( + project = project, + content = message("ecr.delete.tag.failed", selected.size, repositoryName) + ) + EcrTelemetry.deleteTags(project = project, result = Result.Failed, value = selected.size.toDouble()) + } + } + } + ) + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/EcrDockerAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/EcrDockerAction.kt new file mode 100644 index 0000000000..b2ccdb3639 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/EcrDockerAction.kt @@ -0,0 +1,25 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr.actions + +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import software.aws.toolkits.jetbrains.core.docker.DockerRuntimeFacade +import software.aws.toolkits.jetbrains.core.docker.getDockerServerRuntimeFacade +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction +import software.aws.toolkits.jetbrains.services.ecr.EcrRepositoryNode + +abstract class EcrDockerAction : + SingleExplorerNodeAction(), + DumbAware { + + protected companion object { + fun CoroutineScope.dockerServerRuntimeAsync(project: Project): Deferred = + async(start = CoroutineStart.LAZY) { getDockerServerRuntimeFacade(project) } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/PullFromRepositoryAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/PullFromRepositoryAction.kt new file mode 100644 index 0000000000..341a20c717 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/PullFromRepositoryAction.kt @@ -0,0 +1,116 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr.actions + +import com.intellij.docker.agent.OngoingProcess +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.dsl.builder.panel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.ecr.EcrClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.docker.DockerRuntimeFacade +import software.aws.toolkits.jetbrains.core.docker.ToolkitDockerAdapter +import software.aws.toolkits.jetbrains.services.ecr.EcrLogin +import software.aws.toolkits.jetbrains.services.ecr.EcrRepositoryNode +import software.aws.toolkits.jetbrains.services.ecr.EcrUtils +import software.aws.toolkits.jetbrains.services.ecr.getDockerLogin +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.resources.message + +class PullFromRepositoryAction : EcrDockerAction() { + override fun actionPerformed(selected: EcrRepositoryNode, e: AnActionEvent) { + val project = selected.nodeProject + val dialog = PullFromRepositoryDialog(selected.repository, project) + val result = dialog.showAndGet() + if (!result) { + return + } + + val (repo, image) = dialog.getPullRequest() + val client: EcrClient = project.awsClient() + val scope = projectCoroutineScope(project) + scope.launch { + val runtime = scope.dockerServerRuntimeAsync(project).await() + val authData = withContext(getCoroutineBgContext()) { + client.authorizationToken.authorizationData().first() + } + PullFromEcrTask(project, authData.getDockerLogin(), repo, image, runtime).queue() + } + } +} + +private class PullFromRepositoryDialog(selectedRepository: Repository, project: Project) : DialogWrapper(project) { + private val repoSelector = ResourceSelector.builder() + .resource(EcrResources.LIST_REPOS) + .customRenderer(SimpleListCellRenderer.create("") { it.repositoryName }) + .awsConnection(project) + .build() + + private val imageSelector = ResourceSelector.builder() + .resource { + repoSelector.selected()?.repositoryName?.let { EcrResources.listTags(it) } + } + .disableAutomaticLoading() + .customRenderer(SimpleListCellRenderer.create("") { it }) + .awsConnection(project) + .build() + + init { + repoSelector.addActionListener { imageSelector.reload() } + repoSelector.selectedItem { it == selectedRepository } + title = message("ecr.pull.title") + setOKButtonText(message("ecr.pull.confirm")) + + init() + } + + override fun createCenterPanel() = panel { + val widthGroup = "repoTag" + row(message("ecr.repo.label")) { + cell(repoSelector).widthGroup(widthGroup).apply { + }.errorOnApply(message("loading_resource.still_loading")) { it.isLoading }.errorOnApply(message("ecr.repo.not_selected")) { it.selected() == null } + } + + row(message("ecr.push.remoteTag")) { + cell(imageSelector).widthGroup(widthGroup).apply { + }.errorOnApply(message("loading_resource.still_loading")) { it.isLoading }.errorOnApply(message("ecr.image.not_selected")) { it.selected() == null } + } + } + + fun getPullRequest() = repoSelector.selected()!! to imageSelector.selected()!! +} + +private class PullFromEcrTask( + project: Project, + private val ecrLogin: EcrLogin, + private val repository: Repository, + private val image: String, + private val dockerRuntime: DockerRuntimeFacade +) : Task.Backgroundable(project, message("ecr.pull.progress", repository.repositoryUri, image)) { + private var task: OngoingProcess? = null + + override fun onCancel() { + super.onCancel() + task?.cancel() + } + + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + val config = EcrUtils.buildDockerRepositoryModel(ecrLogin, repository, image) + task = ToolkitDockerAdapter(project, dockerRuntime).pullImage(config, indicator).also { + // don't return until docker process exits + it.await() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/PushToRepositoryAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/PushToRepositoryAction.kt new file mode 100644 index 0000000000..29dd28e653 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/actions/PushToRepositoryAction.kt @@ -0,0 +1,340 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecr.actions + +import com.intellij.docker.DockerCloudType +import com.intellij.docker.deploymentSource.DockerFileDeploymentSourceType +import com.intellij.docker.dockerFile.DockerFileType +import com.intellij.execution.ExecutionBundle +import com.intellij.execution.RunManager +import com.intellij.execution.impl.RunDialog +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.TextBrowseFolderListener +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.ComboboxSpeedSearch +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBRadioButton +import com.intellij.ui.components.fields.ExtendableTextComponent +import com.intellij.ui.components.fields.ExtendableTextField +import com.intellij.ui.dsl.builder.COLUMNS_MEDIUM +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.toMutableProperty +import com.intellij.ui.dsl.builder.toNullableProperty +import com.intellij.ui.layout.listCellRenderer +import com.intellij.ui.layout.selected +import com.intellij.util.text.nullize +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.core.exception.SdkException +import software.amazon.awssdk.services.ecr.EcrClient +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.docker.DockerRuntimeFacade +import software.aws.toolkits.jetbrains.core.docker.LocalImage +import software.aws.toolkits.jetbrains.core.docker.ToolkitDockerAdapter +import software.aws.toolkits.jetbrains.services.ecr.DockerRunConfiguration +import software.aws.toolkits.jetbrains.services.ecr.DockerfileEcrPushRequest +import software.aws.toolkits.jetbrains.services.ecr.EcrPushRequest +import software.aws.toolkits.jetbrains.services.ecr.EcrRepositoryNode +import software.aws.toolkits.jetbrains.services.ecr.EcrUtils +import software.aws.toolkits.jetbrains.services.ecr.ImageEcrPushRequest +import software.aws.toolkits.jetbrains.services.ecr.getDockerLogin +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.ui.installOnParent +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.EcrDeploySource +import software.aws.toolkits.telemetry.EcrTelemetry +import software.aws.toolkits.telemetry.Result +import javax.swing.JTextField +import javax.swing.plaf.basic.BasicComboBoxEditor + +class PushToRepositoryAction : EcrDockerAction() { + override fun actionPerformed(selected: EcrRepositoryNode, e: AnActionEvent) { + val project = e.getRequiredData(LangDataKeys.PROJECT) + val client: EcrClient = project.awsClient() + val scope = projectCoroutineScope(project) + val dialog = PushToEcrDialog(project, selected.repository, scope.dockerServerRuntimeAsync(project)) + if (!dialog.showAndGet()) { + // user cancelled; noop + EcrTelemetry.deployImage(project, Result.Cancelled) + return + } + + scope.launch { + val pushRequest = dialog.getPushRequest() + var result = Result.Failed + try { + val authData = withContext(getCoroutineBgContext()) { + client.authorizationToken.authorizationData().first() + } + + val ecrLogin = authData.getDockerLogin() + EcrUtils.pushImage(project, ecrLogin, pushRequest) + result = Result.Succeeded + } catch (e: SdkException) { + val message = message("ecr.push.credential_fetch_failed") + + LOG.error(e) { message } + notifyError(message("ecr.push.title"), message) + } catch (e: Exception) { + val message = message("ecr.push.unknown_exception") + + LOG.error(e) { message } + notifyError(message("ecr.push.title"), message) + } finally { + val type = when (pushRequest) { + is ImageEcrPushRequest -> EcrDeploySource.Tag + is DockerfileEcrPushRequest -> EcrDeploySource.Dockerfile + } + EcrTelemetry.deployImage( + project, + result, + ecrDeploySource = type + ) + } + } + } + + companion object { + private val LOG = getLogger() + } +} + +internal class PushToEcrDialog( + private val project: Project, + selectedRepository: Repository, + private val dockerRuntime: Deferred +) : DialogWrapper(project, null, false, IdeModalityType.PROJECT) { + private val coroutineScope = projectCoroutineScope(project) + private val defaultTag = "latest" + private val localImageRepoTags = CollectionComboBoxModel() + + var type = BuildType.LocalImage + var remoteTag = "" + var localImage: LocalImage? = null + var runConfiguration: DockerRunConfiguration? = null + + private val remoteRepos = ResourceSelector.builder() + .resource(EcrResources.LIST_REPOS) + .customRenderer(SimpleListCellRenderer.create("") { it.repositoryName }) + .awsConnection(project) + .build() + + init { + remoteRepos.selectedItem { it == selectedRepository } + + title = message("ecr.push.title") + setOKButtonText(message("ecr.push.confirm")) + + init() + + coroutineScope.launch { + val dockerAdapter = ToolkitDockerAdapter(project, dockerRuntime.await()) + localImageRepoTags.add(dockerAdapter.getLocalImages()) + localImageRepoTags.update() + } + } + + override fun createCenterPanel() = panel { + // valid tag is ascii letters, numbers, underscores, periods, or dashes + // https://docs.docker.com/engine/reference/commandline/tag/#extended-description + val validTagRegex = "[a-zA-Z0-9_.-]{1,128}".toRegex() + + lateinit var fromLocalImageButton: JBRadioButton + lateinit var fromDockerfileButton: JBRadioButton + + buttonsGroup { + row { + fromLocalImageButton = radioButton(message("ecr.push.type.local_image.label"), BuildType.LocalImage).component + fromDockerfileButton = radioButton(message("ecr.push.type.dockerfile.label"), BuildType.Dockerfile).component + } + }.bind(::type.toMutableProperty(), type = BuildType::class.java) + + val imageSelectorPanel = localImageSelectorPanel() + val dockerfilePanel = dockerfileConfigurationSelectorPanel() + + row { + cell(imageSelectorPanel) + .visibleIf(fromLocalImageButton.selected) + .installOnParent { fromLocalImageButton.isSelected } + cell(dockerfilePanel) + .visibleIf(fromDockerfileButton.selected) + .installOnParent { fromDockerfileButton.isSelected } + } + + row(message("ecr.repo.label")) { + cell(remoteRepos) + .columns(COLUMNS_MEDIUM) + .errorOnApply(message("loading_resource.still_loading")) { it.isLoading } + .errorOnApply(message("ecr.repo.not_selected")) { it.selected() == null } + } + + row(message("ecr.push.remoteTag")) { + textField() + .bindText(::remoteTag) + .also { + it.component.emptyText.text = defaultTag + } + .errorOnApply(message("ecr.tag.invalid")) { it.text.isNotEmpty() && !it.text.matches(validTagRegex) } + } + } + + private fun localImageSelectorPanel() = panel { + row(message("ecr.push.source")) { + comboBox( + localImageRepoTags, + listCellRenderer { value, _, _ -> + text = value.tag ?: value.imageId.take(15) + } + ).bindItem(::localImage.toNullableProperty()) + .applyToComponent { ComboboxSpeedSearch(this) } + .errorOnApply(message("ecr.image.not_selected")) { it.selected() == null } + .columns(30) // The size of the entire dialog is doubling if specific columns are not set for this component + } + } + + private fun dockerfileConfigurationSelectorPanel() = panel { + row(message("ecr.dockerfile.configuration.label")) { + val model = CollectionComboBoxModel() + rebuildRunConfigurationComboBoxModel(model) + comboBox( + model, + listCellRenderer { value, _, _ -> + icon = value.icon + text = value.name + } + ).bindItem(::runConfiguration.toNullableProperty()) + .applyToComponent { + // TODO: how do we render both the Docker icon and action items correctly? + isEditable = true + editor = object : BasicComboBoxEditor.UIResource() { + override fun createEditorComponent(): JTextField { + val textField = ExtendableTextField() + textField.isEditable = false + + buildDockerfileActions(model, textField) + textField.border = null + + return textField + } + } + } + .errorOnApply(message("ecr.dockerfile.configuration.invalid")) { it.selected() == null } + .errorOnApply(message("ecr.dockerfile.configuration.invalid_server")) { it.selected()?.serverName == null } + } + } + + private fun buildDockerfileActions(runConfigModel: CollectionComboBoxModel, textComponent: ExtendableTextField) { + val editExtension = ExtendableTextComponent.Extension.create( + AllIcons.General.Inline_edit, + AllIcons.General.Inline_edit_hovered, + message("ecr.dockerfile.configuration.edit") + ) { + runConfigModel.selected?.let { + RunManager.getInstance(project).findSettings(it)?.let { settings -> + RunDialog.editConfiguration( + project, + settings, + ExecutionBundle.message("run.dashboard.edit.configuration.dialog.title") + ) + } + } + } + + val browseExtension = ExtendableTextComponent.Extension.create( + AllIcons.General.OpenDisk, + AllIcons.General.OpenDiskHover, + message("ecr.dockerfile.configuration.add") + ) { + val listener = object : TextBrowseFolderListener( + FileChooserDescriptorFactory.createSingleFileDescriptor(DockerFileType.DOCKER_FILE_TYPE), + project + ) { + init { + myTextComponent = textComponent + } + + override fun getInitialFile() = this@PushToEcrDialog.project.guessProjectDir() + + override fun onFileChosen(chosenFile: VirtualFile) { + val settings = EcrUtils.dockerRunConfigurationFromPath(this@PushToEcrDialog.project, chosenFile.presentableName, chosenFile.path) + // open dialog for user + RunDialog.editConfiguration( + project, + settings, + ExecutionBundle.message("run.dashboard.edit.configuration.dialog.title") + ) + rebuildRunConfigurationComboBoxModel(runConfigModel) + } + } + + runInEdt(ModalityState.any()) { + listener.run() + } + } + + // extensions from right to left + textComponent.setExtensions(browseExtension, editExtension) + } + + private fun rebuildRunConfigurationComboBoxModel(model: CollectionComboBoxModel) { + val configs = RunManager.getInstance(project).getConfigurationsList(DockerCloudType.getRunConfigurationType()) + .filterIsInstance() + .filter { + // there are multiple types of Docker run configurations. only accept Dockerfile for now + // "image" and "compose" both seem like they only make sense as run-only configurations + it.deploymentSource.type == DockerFileDeploymentSourceType.getInstance() + } + + model.replaceAll(configs) + model.selectedItem = configs.firstOrNull() + } + + private fun selectedRepo() = remoteRepos.selected() ?: throw IllegalStateException("repository uri was null") + + suspend fun getPushRequest(): EcrPushRequest { + val tag = remoteTag.nullize() ?: defaultTag + + return when (type.ordinal) { + BuildType.LocalImage.ordinal -> ImageEcrPushRequest( + dockerRuntime.await(), + localImage?.imageId ?: throw IllegalStateException("image id was null"), + selectedRepo(), + tag + ) + + BuildType.Dockerfile.ordinal -> DockerfileEcrPushRequest( + runConfiguration ?: throw IllegalStateException("run configuration was null"), + selectedRepo(), + tag + ) + + else -> throw IllegalStateException() + } + } + + enum class BuildType { + LocalImage, Dockerfile + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/resources/EcrResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/resources/EcrResources.kt new file mode 100644 index 0000000000..91b5bdd0bf --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecr/resources/EcrResources.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.ecr.resources + +import software.amazon.awssdk.services.ecr.EcrClient +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource +import software.aws.toolkits.jetbrains.core.Resource +import software.aws.toolkits.jetbrains.services.ecr.toToolkitEcrRepository + +object EcrResources { + @JvmStatic + val LIST_REPOS: Resource.Cached> = ClientBackedCachedResource(EcrClient::class, "ecr.list_repos") { + describeRepositoriesPaginator().repositories().toList().mapNotNull { + it.toToolkitEcrRepository() + } + } + + fun listTags(repositoryName: String): Resource.Cached> = ClientBackedCachedResource(EcrClient::class, "ecr.list_tags.$repositoryName") { + describeImagesPaginator { it.repositoryName(repositoryName) }.imageDetails().flatMap { it.imageTags() }.filterNotNull() + } +} + +/** + * The SDK version of repository has every field nullable, but if a repo has no name, arn, + * or URI it is not valid. So, add a small data class to fix nullability issues + */ +data class Repository( + val repositoryName: String, + val repositoryArn: String, + val repositoryUri: String +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/ContainerActions.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/ContainerActions.kt index 833bcaa82d..9c036485ef 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/ContainerActions.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/ContainerActions.kt @@ -7,34 +7,49 @@ import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import icons.AwsIcons +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient import software.amazon.awssdk.services.ecs.model.ContainerDefinition import software.amazon.awssdk.services.ecs.model.LogDriver import software.amazon.awssdk.services.ecs.model.Service -import software.aws.toolkits.jetbrains.core.AwsResourceCache import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.credentials.withAwsConnection +import software.aws.toolkits.jetbrains.core.experiments.isEnabled import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeActionGroup -import software.aws.toolkits.jetbrains.services.clouddebug.actions.StartRemoteShellAction +import software.aws.toolkits.jetbrains.core.getResource +import software.aws.toolkits.jetbrains.core.getResourceNow +import software.aws.toolkits.jetbrains.core.plugins.pluginIsInstalledAndEnabled import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow import software.aws.toolkits.jetbrains.services.cloudwatch.logs.checkIfLogStreamExists +import software.aws.toolkits.jetbrains.services.ecs.exec.EcsExecUtils +import software.aws.toolkits.jetbrains.services.ecs.exec.OpenShellInContainerDialog +import software.aws.toolkits.jetbrains.services.ecs.exec.RunCommandDialog import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.jetbrains.utils.notifyWarn import software.aws.toolkits.resources.message class ContainerActions( private val project: Project, private val container: ContainerDetails ) : ActionGroup(container.containerDefinition.name(), null, null) { + init { isPopup = true } override fun getChildren(e: AnActionEvent?): Array = arrayOf( - StartRemoteShellAction(project, container), - ContainerLogsAction(project, container) + ContainerLogsAction(project, container), + Separator.getInstance(), + ExecuteCommandAction(project, container), + ExecuteCommandInShellAction(project, container) ) } @@ -43,13 +58,18 @@ data class ContainerDetails(val service: Service, val containerDefinition: Conta class ServiceContainerActions : SingleExplorerNodeActionGroup("Containers") { override fun getChildren(selected: EcsServiceNode, e: AnActionEvent): List { val containers = try { - AwsResourceCache.getInstance(selected.nodeProject).getResourceNow(EcsResources.listContainers(selected.value.taskDefinition())) + selected.nodeProject.getResourceNow(EcsResources.listContainers(selected.value.taskDefinition())) } catch (e: Exception) { - e.notifyError(message("cloud_debug.ecs.run_config.container.loading.error", e.localizedMessage), selected.nodeProject) + e.notifyError(message("ecs.run_config.container.loading.error", e.localizedMessage), selected.nodeProject) return emptyList() } - val containerActions = containers.map { ContainerActions(selected.nodeProject, ContainerDetails(selected.value, it)) } + val containerActions = containers.map { + ContainerActions( + selected.nodeProject, + ContainerDetails(selected.value, it) + ) + } if (containerActions.isEmpty()) { return emptyList() @@ -63,7 +83,7 @@ class ServiceContainerActions : SingleExplorerNodeActionGroup("C class ContainerLogsAction( private val project: Project, private val container: ContainerDetails -) : AnAction(message("ecs.service.container_logs.action_label"), null, AwsIcons.Resources.CloudWatch.LOGS) { +) : DumbAwareAction(message("ecs.service.container_logs.action_label"), null, AwsIcons.Resources.CloudWatch.LOGS) { private val logConfiguration: Pair? by lazy { container.containerDefinition.logConfiguration().takeIf { it.logDriver() == LogDriver.AWSLOGS }?.options()?.let { @@ -84,18 +104,19 @@ class ContainerLogsAction( val window = CloudWatchLogWindow.getInstance(project) - AwsResourceCache.getInstance(project) - .getResource(EcsResources.listTaskIds(container.service.clusterArn(), container.service.serviceArn())) + project.getResource(EcsResources.listTaskIds(container.service.clusterArn(), container.service.serviceArn())) .thenAccept { tasks -> - when { - tasks.isEmpty() -> notifyInfo(message("ecs.service.logs.no_running_tasks")) - tasks.size == 1 && showSingleStream( - window, - logGroup, - "$logPrefix/${container.containerDefinition.name()}/${tasks.first()}" - ) -> return@thenAccept + runBlocking { + when { + tasks.isEmpty() -> notifyInfo(message("ecs.service.logs.no_running_tasks")) + tasks.size == 1 && showSingleStream( + window, + logGroup, + "$logPrefix/${container.containerDefinition.name()}/${tasks.first()}" + ) -> return@runBlocking + } + window.showLogGroup(logGroup) } - window.showLogGroup(logGroup) } } @@ -104,7 +125,63 @@ class ContainerLogsAction( notifyInfo(message("ecs.service.logs.no_log_stream")) return false } - window.showLogStream(logGroup, logStream) + runInEdt { + window.showLogStream(logGroup, logStream) + } return true } } + +class ExecuteCommandAction( + private val project: Project, + private val container: ContainerDetails +) : DumbAwareAction(message("ecs.execute_command_run"), null, null) { + private val coroutineScope = projectCoroutineScope(project) + override fun actionPerformed(e: AnActionEvent) { + coroutineScope.launch { + if (EcsExecUtils.ensureServiceIsInStableState(project, container.service)) { + runInEdt { + project.withAwsConnection { + RunCommandDialog(project, container, it).show() + } + } + } else { + notifyWarn(message("ecs.execute_command_run"), message("ecs.execute_command_disable_in_progress", container.service.serviceName()), project) + } + } + } + + override fun update(e: AnActionEvent) { + e.presentation.isVisible = container.service.enableExecuteCommand() && + EcsExecExperiment.isEnabled() + } +} + +class ExecuteCommandInShellAction( + private val project: Project, + private val container: ContainerDetails +) : DumbAwareAction(message("ecs.execute_command_run_command_in_shell"), null, null) { + private val coroutineScope = projectCoroutineScope(project) + override fun actionPerformed(e: AnActionEvent) { + coroutineScope.launch { + if (EcsExecUtils.ensureServiceIsInStableState(project, container.service)) { + runInEdt { + project.withAwsConnection { + OpenShellInContainerDialog(project, container, it).show() + } + } + } else { + notifyWarn( + message("ecs.execute_command_run_command_in_shell"), + message("ecs.execute_command_disable_in_progress", container.service.serviceName()), + project + ) + } + } + } + + override fun update(e: AnActionEvent) { + e.presentation.isVisible = container.service.enableExecuteCommand() && + pluginIsInstalledAndEnabled("org.jetbrains.plugins.terminal") && EcsExecExperiment.isEnabled() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/Ecs.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/Ecs.kt deleted file mode 100644 index d8b6e727ff..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/Ecs.kt +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs - -import com.intellij.util.containers.nullize -import software.amazon.awssdk.services.ecs.EcsClient -import software.aws.toolkits.core.utils.wait -import java.time.Duration - -fun EcsClient.waitForServicesStable( - cluster: String, - vararg services: String, - waitForMissingServices: Boolean = false, - attempts: Int = 60, - delay: Duration = Duration.ofSeconds(10) -) { - wait( - call = { - describeServices { - it.cluster(cluster) - it.services(*services) - } - }, - success = { response -> - // return true when there are no non-stable services - response.services().size != 0 && - response.services().map { service -> - // service is stable if there is only a single deployment and the running count matches desired - service.deployments().size == 1 && service.runningCount() == service.desiredCount() - }.all { it } - }, - fail = { - it.failures().mapNotNull { failure -> - if (waitForMissingServices && failure.reason() == "MISSING") { - return@mapNotNull null - } - failure.toString() - }.nullize()?.joinToString(System.lineSeparator()) - }, - failByException = { it.message }, - attempts = attempts, - delay = delay - ) -} - -fun EcsClient.waitForServicesInactive( - cluster: String, - vararg services: String, - attempts: Int = 60, - delay: Duration = Duration.ofSeconds(10) -) { - wait( - call = { - describeServices { - it.cluster(cluster) - it.services(*services) - } - }, - success = { response -> - response.services().size != 0 && - response.services().map { service -> - // service is stable if there is only a single deployment and the running count matches desired - service.status() == "INACTIVE" - }.all { it } - }, - fail = { - it.failures().mapNotNull { failure -> failure.toString() }.nullize()?.joinToString(System.lineSeparator()) - }, - failByException = { it.message }, - attempts = attempts, - delay = delay - ) -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsExecExperiment.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsExecExperiment.kt new file mode 100644 index 0000000000..1b329f1a4b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsExecExperiment.kt @@ -0,0 +1,15 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecs + +import software.aws.toolkits.jetbrains.core.experiments.ToolkitExperiment +import software.aws.toolkits.resources.message + +object EcsExecExperiment : ToolkitExperiment( + "ecsExec", + { message("ecs.execute_command.experiment.title") }, + { message("ecs.execute_command.experiment.description") }, + default = true, + hidden = true +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsExplorerNodes.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsExplorerNodes.kt index 7e670c2322..3cb038ff6d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsExplorerNodes.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsExplorerNodes.kt @@ -7,7 +7,6 @@ import com.intellij.openapi.project.Project import icons.AwsIcons import software.amazon.awssdk.services.ecs.EcsClient import software.amazon.awssdk.services.ecs.model.Service -import software.aws.toolkits.jetbrains.core.AwsResourceCache import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerEmptyNode import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerResourceNode @@ -15,15 +14,14 @@ import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceNod import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceRootNode import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceLocationNode import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceParentNode -import software.aws.toolkits.jetbrains.services.ecs.execution.EcsCloudDebugLocation +import software.aws.toolkits.jetbrains.core.getResourceNow import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources import software.aws.toolkits.resources.message class EcsParentNode(project: Project, service: AwsExplorerServiceNode) : AwsExplorerServiceRootNode(project, service) { + override fun displayName(): String = message("explorer.node.ecs") override fun getChildrenInternal(): List> = listOf( - EcsClusterParentNode(nodeProject) /*, - EcsTaskDefinitionsParentNode(nodeProject) - */ + EcsClusterParentNode(nodeProject) ) } @@ -34,7 +32,7 @@ class EcsClusterParentNode(project: Project) : override fun isAlwaysShowPlus(): Boolean = true override fun getChildren(): List> = super.getChildren() - override fun getChildrenInternal(): List> = AwsResourceCache.getInstance(nodeProject) + override fun getChildrenInternal(): List> = nodeProject .getResourceNow(EcsResources.LIST_CLUSTER_ARNS) .map { EcsClusterNode(nodeProject, it) } } @@ -50,13 +48,10 @@ class EcsClusterNode(project: Project, private val clusterArn: String) : override fun emptyChildrenNode(): AwsExplorerEmptyNode = AwsExplorerEmptyNode(nodeProject, message("ecs.no_services_in_cluster")) override fun getChildren(): List> = super.getChildren() - override fun getChildrenInternal(): List> { - val resourceCache = AwsResourceCache.getInstance(nodeProject) - return AwsResourceCache.getInstance(nodeProject) - .getResourceNow(EcsResources.listServiceArns(clusterArn)) - .map { resourceCache.getResourceNow(EcsResources.describeService(clusterArn, it)) } - .map { EcsServiceNode(nodeProject, it, clusterArn) } - } + override fun getChildrenInternal(): List> = nodeProject + .getResourceNow(EcsResources.listServiceArns(clusterArn)) + .map { nodeProject.getResourceNow(EcsResources.describeService(clusterArn, it)) } + .map { EcsServiceNode(nodeProject, it, clusterArn) } } class EcsServiceNode(project: Project, private val service: Service, private val clusterArn: String) : @@ -66,30 +61,6 @@ class EcsServiceNode(project: Project, private val service: Service, private val override fun resourceType() = "service" override fun resourceArn(): String = value.serviceArn() override fun displayName(): String = value.serviceName() - override fun location() = EcsCloudDebugLocation(nodeProject, service) - + fun executeCommandEnabled() = value.enableExecuteCommand() fun clusterArn(): String = clusterArn } - -class EcsTaskDefinitionsParentNode(project: Project) : - AwsExplorerNode(project, message("ecs.task_definitions"), null), - ResourceParentNode { - - override fun isAlwaysShowPlus(): Boolean = true - - override fun getChildren(): List> = super.getChildren() - override fun getChildrenInternal(): List> = AwsResourceCache.getInstance(nodeProject) - .getResourceNow(EcsResources.LIST_ACTIVE_TASK_DEFINITION_FAMILIES) - .map { EcsTaskDefinitionNode(nodeProject, it) } -} - -class EcsTaskDefinitionNode(project: Project, familyName: String) : - AwsExplorerResourceNode(project, EcsClient.SERVICE_NAME, familyName, AwsIcons.Resources.Ecs.ECS_TASK_DEFINITION) { - override fun resourceType() = "taskDefinition" - - override fun resourceArn(): String = AwsResourceCache.getInstance(nodeProject) - .getResourceNow(EcsResources.describeTaskDefinition(value)) - .taskDefinitionArn() - - override fun getChildren(): List> = emptyList() -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsUtils.kt index 015cce8b5a..8b879f62cf 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/EcsUtils.kt @@ -3,22 +3,10 @@ package software.aws.toolkits.jetbrains.services.ecs -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants - object EcsUtils { @JvmStatic fun clusterArnToName(clusterArn: String): String = clusterArn.split("cluster/", limit = 2).last() @JvmStatic fun serviceArnToName(serviceArn: String): String = serviceArn.split("/").last() - - @JvmStatic - fun originalServiceName(serviceName: String): String = serviceArnToName(serviceName).removePrefix(CloudDebugConstants.CLOUD_DEBUG_RESOURCE_PREFIX) - - /** - * project is the active project - * service is service name or ARN - */ - @JvmStatic - fun isInstrumented(service: String): Boolean = serviceArnToName(service).startsWith(CloudDebugConstants.CLOUD_DEBUG_RESOURCE_PREFIX) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/actions/EcsLogGroupAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/actions/EcsLogGroupAction.kt index d53ca77bfb..52e5fe5a0d 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/actions/EcsLogGroupAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/actions/EcsLogGroupAction.kt @@ -6,28 +6,26 @@ package software.aws.toolkits.jetbrains.services.ecs.actions import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.project.DumbAware import icons.AwsIcons -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow import software.aws.toolkits.jetbrains.services.cloudwatch.logs.checkIfLogGroupExists import software.aws.toolkits.jetbrains.services.ecs.EcsClusterNode import software.aws.toolkits.jetbrains.services.ecs.EcsUtils.clusterArnToName -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.resources.message class EcsLogGroupAction : - SingleResourceNodeAction(message("cloudwatch.logs.view_log_streams"), null, AwsIcons.Resources.CloudWatch.LOGS), - CoroutineScope by ApplicationThreadPoolScope("EcsLogGroupAction"), - DumbAware { + SingleResourceNodeAction(message("cloudwatch.logs.view_log_streams"), null, AwsIcons.Resources.CloudWatch.LOGS), DumbAware { override fun actionPerformed(selected: EcsClusterNode, e: AnActionEvent) { - launch { - val project = selected.nodeProject - val client = project.awsClient() - val logGroup = "/ecs/${clusterArnToName(selected.resourceArn())}" + val project = selected.nodeProject + val client = project.awsClient() + val logGroup = "/ecs/${clusterArnToName(selected.resourceArn())}" + val scope = projectCoroutineScope(project) + scope.launch { if (client.checkIfLogGroupExists(logGroup)) { val window = CloudWatchLogWindow.getInstance(project) window.showLogGroup(logGroup) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/DisableEcsExecuteCommand.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/DisableEcsExecuteCommand.kt new file mode 100644 index 0000000000..9f530565ed --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/DisableEcsExecuteCommand.kt @@ -0,0 +1,39 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecs.exec + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.ecs.model.Service +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.experiments.isEnabled +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.ecs.EcsExecExperiment +import software.aws.toolkits.jetbrains.services.ecs.EcsServiceNode +import software.aws.toolkits.jetbrains.settings.EcsExecCommandSettings +import software.aws.toolkits.resources.message + +class DisableEcsExecuteCommand : + SingleResourceNodeAction(message("ecs.execute_command_disable"), null) { + private val settings = EcsExecCommandSettings.getInstance() + override fun actionPerformed(selected: EcsServiceNode, e: AnActionEvent) { + if (!settings.showExecuteCommandWarning || + EnableDisableExecuteCommandWarning(selected.nodeProject, enable = false, selected.value.serviceName()).showAndGet() + ) { + val coroutineScope = projectCoroutineScope(selected.nodeProject) + coroutineScope.launch { + disableExecuteCommand(selected.nodeProject, selected.value) + } + } + } + + override fun update(selected: EcsServiceNode, e: AnActionEvent) { + e.presentation.isVisible = selected.executeCommandEnabled() && EcsExecExperiment.isEnabled() + } + + private fun disableExecuteCommand(project: Project, service: Service) { + EcsExecUtils.updateExecuteCommandFlag(project, service, enabled = false) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/EcsExecUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/EcsExecUtils.kt new file mode 100644 index 0000000000..5650e2d1f6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/EcsExecUtils.kt @@ -0,0 +1,254 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecs.exec + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.ec2.Ec2Client +import software.amazon.awssdk.services.ecs.EcsClient +import software.amazon.awssdk.services.ecs.model.DeploymentRolloutState +import software.amazon.awssdk.services.ecs.model.DescribeServicesRequest +import software.amazon.awssdk.services.ecs.model.InvalidParameterException +import software.amazon.awssdk.services.ecs.model.LaunchType +import software.amazon.awssdk.services.ecs.model.Service +import software.amazon.awssdk.services.iam.IamClient +import software.amazon.awssdk.services.iam.model.PolicyEvaluationDecisionType +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.toEnvironmentVariables +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettingsOrThrow +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.core.getResourceNow +import software.aws.toolkits.jetbrains.core.tools.getOrInstallTool +import software.aws.toolkits.jetbrains.services.ecs.ContainerDetails +import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources +import software.aws.toolkits.jetbrains.services.ssm.SsmPlugin +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.jetbrains.utils.notifyWarn +import software.aws.toolkits.jetbrains.utils.runUnderProgressIfNeeded +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.EcsTelemetry +import software.aws.toolkits.telemetry.Result +import software.amazon.awssdk.services.ecs.model.Task as EcsTask + +object EcsExecUtils { + private val MAPPER = jacksonObjectMapper() + private const val SESSION_MANAGER_CREATE_CONTROL_CHANNEL_PERMISSION = "ssmmessages:CreateControlChannel" + private const val SESSION_MANAGER_CREATE_DATA_CHANNEL_PERMISSION = "ssmmessages:CreateDataChannel" + private const val SESSION_MANAGER_OPEN_CONTROL_CHANNEL_PERMISSION = "ssmmessages:OpenControlChannel" + private const val SESSION_MANAGER_OPEN_DATA_CHANNEL_PERMISSION = "ssmmessages:OpenDataChannel" + + fun updateExecuteCommandFlag(project: Project, service: Service, enabled: Boolean) { + if (ensureServiceIsInStableState(project, service)) { + try { + project.awsClient().updateService { + it.cluster(service.clusterArn()) + it.service(service.serviceName()) + it.enableExecuteCommand(enabled) + it.forceNewDeployment(true) + } + checkServiceState(project, service, enabled) + } catch (e: InvalidParameterException) { + runInEdt { + TaskRoleNotFoundWarningDialog(project).show() + EcsTelemetry.enableExecuteCommand(project, Result.Failed) + } + } + } else { + if (enabled) { + notifyWarn( + title = message("ecs.execute_command_enable"), + content = message("ecs.execute_command_enable_in_progress", service.serviceName()), + project = project + ) + } else { + notifyWarn( + title = message("ecs.execute_command_disable"), + content = message("ecs.execute_command_disable_in_progress", service.serviceName()), + project = project + ) + } + } + } + + private fun checkServiceState(project: Project, service: Service, enable: Boolean) { + val title = if (enable) { + message("ecs.execute_command_enable_progress_indicator_message", service.serviceName()) + } else { + message("ecs.execute_command_disable_progress_indicator_message", service.serviceName()) + } + + ProgressManager.getInstance().run( + object : Task.Backgroundable(project, title, false) { + override fun run(indicator: ProgressIndicator) { + val request = DescribeServicesRequest.builder().cluster(service.clusterArn()).services(service.serviceArn()).build() + val client = project.awsClient() + val waiter = client.waiter() + waiter.waitUntilServicesStable(request) + } + + override fun onSuccess() { + val currentConnectionSettings = project.getConnectionSettingsOrThrow() + project.refreshAwsTree(EcsResources.describeService(service.clusterArn(), service.serviceArn()), currentConnectionSettings) + + if (enable) { + notifyInfo( + title = message("ecs.execute_command_enable"), + content = message("ecs.execute_command_enable_success", service.serviceName()), + project = project + ) + EcsTelemetry.enableExecuteCommand(project, Result.Succeeded) + } else { + notifyInfo( + title = message("ecs.execute_command_disable"), + content = message("ecs.execute_command_disable_success", service.serviceName()), + project = project + ) + EcsTelemetry.disableExecuteCommand(project, Result.Succeeded) + } + } + + override fun onThrowable(error: Throwable) { + if (enable) { + notifyError( + title = message("ecs.execute_command_enable"), + content = message("ecs.execute_command_enable_failed", service.serviceName()), + project = project + ) + EcsTelemetry.enableExecuteCommand(project, Result.Failed) + } else { + notifyError( + title = message("ecs.execute_command_disable"), + content = message("ecs.execute_command_disable_failed", service.serviceName()), + project = project + ) + EcsTelemetry.disableExecuteCommand(project, Result.Failed) + } + } + } + ) + } + + private fun getEc2InstanceTaskRoleArn(project: Project, clusterArn: String, ecsClient: EcsClient, task: EcsTask): String? { + try { + val iamClient = project.awsClient() + val containerInstanceArn = task.containerInstanceArn() + val res = ecsClient.describeContainerInstances { + it.cluster(clusterArn) + it.containerInstances(containerInstanceArn) + } + val ec2InstanceId = res.containerInstances().first().ec2InstanceId() + val instanceProfileArn = project.awsClient().describeInstances { + it.instanceIds(ec2InstanceId) + }.reservations().first().instances().first().iamInstanceProfile().arn() ?: return null + val instanceProfileName = instanceProfileArn.substringAfter(":instance-profile/") + return iamClient.getInstanceProfile { it.instanceProfileName(instanceProfileName) }.instanceProfile().roles().first().arn() ?: null + } catch (e: Exception) { + return null + } + } + + fun getTaskRoleArn(project: Project, clusterArn: String, taskArn: String): String? { + val ecsClient = project.awsClient() + val task = ecsClient.describeTasks { + it.tasks(taskArn) + it.cluster(clusterArn) + }.tasks().first() + return if (task.overrides().taskRoleArn() != null) { + task.overrides().taskRoleArn() + } else { + project.getResourceNow(EcsResources.describeTaskDefinition(task.taskDefinitionArn())).taskRoleArn() + ?: when (task.launchType()) { + LaunchType.EC2 -> getEc2InstanceTaskRoleArn(project, clusterArn, ecsClient, task) + LaunchType.FARGATE -> null + else -> throw RuntimeException("Launch Type is not supported") + } + } + } + + fun checkRequiredPermissions(project: Project, clusterArn: String, taskArn: String): Boolean { + try { + val iamClient = project.awsClient() + val taskRoleArn = getTaskRoleArn(project, clusterArn, taskArn) ?: return false + + val permissions = listOf( + SESSION_MANAGER_CREATE_CONTROL_CHANNEL_PERMISSION, + SESSION_MANAGER_CREATE_DATA_CHANNEL_PERMISSION, + SESSION_MANAGER_OPEN_CONTROL_CHANNEL_PERMISSION, + SESSION_MANAGER_OPEN_DATA_CHANNEL_PERMISSION + ) + val response = iamClient.simulatePrincipalPolicy { + it.policySourceArn(taskRoleArn) + it.actionNames(permissions) + } + + val permissionResults = response.evaluationResults().map { it.evalDecision().name } + for (permission in permissionResults) { + if (permission != PolicyEvaluationDecisionType.ALLOWED.name) { + return false + } + } + } catch (e: Exception) { + notifyWarn( + title = message("ecs.execute_command_permissions_required_title"), + content = message("ecs.execute_command_permissions_not_verified"), + project = project + ) + } + return true + } + + fun ensureServiceIsInStableState(project: Project, service: Service): Boolean { + val response = project.awsClient().describeServices { + it.cluster(service.clusterArn()) + it.services(service.serviceArn()) + } + val deployment = response.services().first().deployments().first() + val serviceStateChangeInProgress = deployment.rolloutState() == DeploymentRolloutState.IN_PROGRESS || deployment.status() == "ACTIVE" + return !serviceStateChangeInProgress + } + + /** + * Start a session with ECS (calling ECS execute-command) and then pass the resulting + * session information (along with some other pieces) to the SSM Session Manager Plugin. + * + * This replicates logic from the AWS CLI: + * https://github.com/aws/aws-cli/blob/63f3fcf368805d14848769feae4bbf87cc359739/awscli/customizations/ecs/executecommand.py + */ + fun createCommand( + project: Project, + connection: ConnectionSettings, + container: ContainerDetails, + task: String, + command: String + ): GeneralCommandLine { + val client = connection.awsClient() + val path = SsmPlugin.getOrInstallTool(project).path.toAbsolutePath().toString() + + val session = runUnderProgressIfNeeded(project, message("ecs.execute_command_call_service"), cancelable = false) { + client.executeCommand { + it.cluster(container.service.clusterArn()) + it.task(task) + it.container(container.containerDefinition.name()) + it.interactive(true) + it.command(command) + } + } + + return GeneralCommandLine() + .withExePath(path) + .withParameters( + MAPPER.writeValueAsString(session.session().toBuilder()), + connection.region.id, + "StartSession" + ) + .withEnvironment(connection.toEnvironmentVariables()) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/EnableDisableExecuteCommandWarning.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/EnableDisableExecuteCommandWarning.kt new file mode 100644 index 0000000000..017116103e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/EnableDisableExecuteCommandWarning.kt @@ -0,0 +1,74 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecs.exec + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.settings.EcsExecCommandSettings +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.EcsTelemetry +import software.aws.toolkits.telemetry.Result +import javax.swing.JComponent + +class EnableDisableExecuteCommandWarning(private val project: Project, private val enable: Boolean, private val serviceName: String) : DialogWrapper(project) { + private val warningIcon = JBLabel(Messages.getWarningIcon()) + var dontDisplayWarning = false + var confirmNonProduction = false + private val settings = EcsExecCommandSettings.getInstance() + private val component by lazy { + panel { + row { + cell(warningIcon) + label(message("ecs.execute_command_enable_warning")).visible(enable) + label(message("ecs.execute_command_disable_warning")).visible(!enable) + } + + row { + checkBox( + message("ecs.execute_command.production_warning.checkbox_label", serviceName) + ) + .bindSelected({ confirmNonProduction }, { confirmNonProduction = it }) + .errorOnApply(message("general.confirm_proceed")) { !it.isSelected } + } + + row { + checkBox(message("general.notification.action.hide_forever")).bindSelected({ dontDisplayWarning }, { dontDisplayWarning = it }) + } + } + } + + init { + super.init() + title = if (enable) { + message("ecs.execute_command_enable_warning_title") + } else { + message("ecs.execute_command_disable_warning_title") + } + } + + override fun doOKAction() { + super.doOKAction() + if (dontDisplayWarning) { + settings.showExecuteCommandWarning = false + } + } + + override fun doCancelAction() { + super.doCancelAction() + if (enable) { + EcsTelemetry.enableExecuteCommand(project, Result.Cancelled) + } else { + EcsTelemetry.disableExecuteCommand(project, Result.Cancelled) + } + } + + override fun createCenterPanel(): JComponent = component + + override fun getHelpId(): String = HelpIds.ECS_EXEC_PERMISSIONS_REQUIRED.id +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/EnableEcsExecuteCommand.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/EnableEcsExecuteCommand.kt new file mode 100644 index 0000000000..2e533490e9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/EnableEcsExecuteCommand.kt @@ -0,0 +1,40 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecs.exec + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.ecs.model.Service +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.experiments.isEnabled +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.ecs.EcsExecExperiment +import software.aws.toolkits.jetbrains.services.ecs.EcsServiceNode +import software.aws.toolkits.jetbrains.settings.EcsExecCommandSettings +import software.aws.toolkits.resources.message + +class EnableEcsExecuteCommand : + SingleResourceNodeAction(message("ecs.execute_command_enable"), null) { + private val settings = EcsExecCommandSettings.getInstance() + + override fun actionPerformed(selected: EcsServiceNode, e: AnActionEvent) { + if (!settings.showExecuteCommandWarning || + EnableDisableExecuteCommandWarning(selected.nodeProject, enable = true, selected.value.serviceName()).showAndGet() + ) { + val coroutineScope = projectCoroutineScope(selected.nodeProject) + coroutineScope.launch { + enableExecuteCommand(selected.nodeProject, selected.value) + } + } + } + + override fun update(selected: EcsServiceNode, e: AnActionEvent) { + e.presentation.isVisible = !selected.executeCommandEnabled() && EcsExecExperiment.isEnabled() + } + + private fun enableExecuteCommand(project: Project, service: Service) { + EcsExecUtils.updateExecuteCommandFlag(project, service, enabled = true) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/OpenShellInContainerDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/OpenShellInContainerDialog.kt new file mode 100644 index 0000000000..af2fe8f472 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/OpenShellInContainerDialog.kt @@ -0,0 +1,123 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecs.exec + +import com.intellij.execution.configurations.PtyCommandLine +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.toNullableProperty +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.plugins.terminal.TerminalTabState +import org.jetbrains.plugins.terminal.TerminalView +import org.jetbrains.plugins.terminal.cloud.CloudTerminalProcess +import org.jetbrains.plugins.terminal.cloud.CloudTerminalRunner +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.map +import software.aws.toolkits.jetbrains.services.ecs.ContainerDetails +import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.EcsExecuteCommandType +import software.aws.toolkits.telemetry.EcsTelemetry +import software.aws.toolkits.telemetry.Result +import javax.swing.JComponent + +class OpenShellInContainerDialog( + private val project: Project, + private val container: ContainerDetails, + private val connectionSettings: ConnectionSettings +) : DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + private val shellList = listOf("/bin/bash", "/bin/sh", "/bin/zsh") + private val shellOption = CollectionComboBoxModel(shellList) + + var task: String? = null + var shell: String = "" + + val taskList = ResourceSelector.builder().resource( + EcsResources.listTasks(container.service.clusterArn(), container.service.serviceArn()).map { it } + ).awsConnection(project).build() + + init { + super.init() + title = message("ecs.execute_command_run_command_in_shell") + setOKButtonText(message("general.execute_button")) + } + + override fun createCenterPanel(): JComponent = panel { + row(message("ecs.execute_command_task.label")) { + cell(taskList) + .bindItem(::task.toNullableProperty()) + .errorOnApply(message("ecs.execute_command_task_comboBox_empty")) { + it.selected().isNullOrEmpty() + }.columns(30) + } + row(message("ecs.execute_command_shell.label")) { + comboBox(shellOption) + .bindItem({ shell }, { + if (it != null) { + shell = it + } + }) + .errorOnApply(message("ecs.execute_command_shell_comboBox_empty")) { it.editor.item.toString().isBlank() } + .applyToComponent { isEditable = true } + .align(AlignX.FILL) + } + } + + override fun doOKAction() { + super.doOKAction() + val task = task ?: throw IllegalStateException("Task not Selected") + coroutineScope.launch { + val taskRoleFound: Boolean = EcsExecUtils.checkRequiredPermissions(project, container.service.clusterArn(), task) + if (taskRoleFound) { + runExecCommand(task) + } else { + withContext(getCoroutineUiContext()) { + TaskRoleNotFoundWarningDialog(project).show() + } + } + } + } + + override fun doCancelAction() { + super.doCancelAction() + EcsTelemetry.runExecuteCommand(project, Result.Cancelled, EcsExecuteCommandType.Shell) + } + + private fun runExecCommand(task: String) { + try { + val commandLine = EcsExecUtils.createCommand(project, connectionSettings, container, task, shell) + val ptyProcess = PtyCommandLine(commandLine).createProcess() + val process = CloudTerminalProcess(ptyProcess.outputStream, ptyProcess.inputStream) + val runner = CloudTerminalRunner(project, container.containerDefinition.name(), process) + + runInEdt(ModalityState.any()) { + TerminalView.getInstance(project).createNewSession(runner, TerminalTabState().also { it.myTabName = container.containerDefinition.name() }) + } + EcsTelemetry.runExecuteCommand(project, Result.Succeeded, EcsExecuteCommandType.Shell) + } catch (e: Exception) { + e.notifyError(message("ecs.execute_command_failed")) + LOG.warn(e) { "Failed to start interactive shell" } + EcsTelemetry.runExecuteCommand(project, Result.Failed, EcsExecuteCommandType.Shell) + } + } + companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/RunCommandDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/RunCommandDialog.kt new file mode 100644 index 0000000000..366a9e0de0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/RunCommandDialog.kt @@ -0,0 +1,124 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecs.exec + +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.runners.ExecutionEnvironmentBuilder +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.toNullableProperty +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.map +import software.aws.toolkits.jetbrains.services.ecs.ContainerDetails +import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.EcsExecuteCommandType +import software.aws.toolkits.telemetry.EcsTelemetry +import software.aws.toolkits.telemetry.Result +import javax.swing.JComponent +import javax.swing.plaf.basic.BasicComboBoxEditor + +class RunCommandDialog(private val project: Project, private val container: ContainerDetails, private val connectionSettings: ConnectionSettings) : + DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + private val commandList = CollectionComboBoxModel(commandsEnteredPreviously.toMutableList()) + + var command = "" + var task: String? = null + val taskList = ResourceSelector.builder().resource( + EcsResources.listTasks(container.service.clusterArn(), container.service.serviceArn()).map { it } + ).awsConnection(project).build() + + init { + super.init() + title = message("ecs.execute_command_run") + setOKButtonText(message("general.execute_button")) + } + + override fun createCenterPanel(): JComponent = panel { + row(message("ecs.execute_command_task.label")) { + cell(taskList) + .bindItem(::task.toNullableProperty()) + .errorOnApply(message("ecs.execute_command_task_comboBox_empty")) { + it.selected().isNullOrEmpty() + }.columns(30) + } + row(message("ecs.execute_command.label")) { + comboBox(commandList).bindItem({ command }, { + if (it != null) { + command = it + } + }) + .errorOnApply(message("ecs.execute_command_no_command")) { it.item.isNullOrEmpty() } + .applyToComponent { + isEditable = true + selectedIndex = -1 + editor = object : BasicComboBoxEditor.UIResource() { + override fun createEditorComponent() = + JBTextField().also { it.emptyText.text = message("ecs.execute_command_run_command_default_text") } + } + }.align(AlignX.FILL) + } + } + + override fun doOKAction() { + super.doOKAction() + commandsEnteredPreviously.add(command) + val task = task ?: throw IllegalStateException("Task not Selected") + coroutineScope.launch { + val taskRoleFound: Boolean = EcsExecUtils.checkRequiredPermissions(project, container.service.clusterArn(), task) + if (taskRoleFound) { + runCommand(task) + } else { + withContext(getCoroutineUiContext()) { + TaskRoleNotFoundWarningDialog(project).show() + } + } + } + } + + override fun doCancelAction() { + super.doCancelAction() + EcsTelemetry.runExecuteCommand(project, Result.Cancelled, EcsExecuteCommandType.Command) + } + + private suspend fun runCommand(task: String) { + try { + val environment = ExecutionEnvironmentBuilder.create( + project, + DefaultRunExecutor.getRunExecutorInstance(), + RunCommandRunProfile(project, connectionSettings, container, task, command) + ).build() + + withContext(getCoroutineUiContext()) { + environment.runner.execute(environment) + } + + EcsTelemetry.runExecuteCommand(project, Result.Succeeded, EcsExecuteCommandType.Command) + } catch (e: Exception) { + e.notifyError(message("ecs.execute_command_failed")) + LOG.warn(e) { "Run command failed" } + EcsTelemetry.runExecuteCommand(project, Result.Failed, EcsExecuteCommandType.Command) + } + } + + companion object { + private val commandsEnteredPreviously = mutableSetOf() + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/RunCommandRunProfile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/RunCommandRunProfile.kt new file mode 100644 index 0000000000..12b26ff0b2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/RunCommandRunProfile.kt @@ -0,0 +1,63 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecs.exec + +import com.intellij.execution.Executor +import com.intellij.execution.configurations.CommandLineState +import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.filters.TextConsoleBuilderFactory +import com.intellij.execution.process.ColoredProcessHandler +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.execution.process.ProcessTerminatedListener +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.ProgramRunner +import com.intellij.execution.ui.RunContentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.services.ecs.ContainerDetails +import javax.swing.Icon + +class RunCommandRunProfile( + private val project: Project, + private val connection: ConnectionSettings, + private val container: ContainerDetails, + private val task: String, + private val command: String +) : RunProfile { + + override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState = RunCommandRunProfileState(environment) + + override fun getName(): String = container.containerDefinition.name() + + override fun getIcon(): Icon? = null + + inner class RunCommandRunProfileState(environment: ExecutionEnvironment) : CommandLineState(environment) { + init { + consoleBuilder = TextConsoleBuilderFactory.getInstance().createBuilder(project) + } + + override fun startProcess(): ProcessHandler { + val processHandler = ColoredProcessHandler(EcsExecUtils.createCommand(project, connection, container, task, command)) + ProcessTerminatedListener.attach(processHandler) + return processHandler + } + + override fun execute(executor: Executor, runner: ProgramRunner<*>) = super.execute(executor, runner).apply { + processHandler?.addProcessListener(object : ProcessAdapter() { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + if (outputType === ProcessOutputTypes.STDOUT || + outputType === ProcessOutputTypes.STDERR + ) { + RunContentManager.getInstance(project).toFrontRunContent(executor, processHandler) + } + } + }) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/TaskRoleNotFoundWarningDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/TaskRoleNotFoundWarningDialog.kt new file mode 100644 index 0000000000..8ca1bbc8d4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/exec/TaskRoleNotFoundWarningDialog.kt @@ -0,0 +1,36 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ecs.exec + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.resources.message +import javax.swing.Action +import javax.swing.JComponent + +class TaskRoleNotFoundWarningDialog(project: Project) : DialogWrapper(project) { + private val warningIcon = JBLabel(Messages.getWarningIcon()) + private val warningMessage = JBLabel(message("ecs.execute_command_task_role_invalid_warning")) + private val component by lazy { + panel { + row { + cell(warningIcon) + cell(warningMessage).also { it.component.setCopyable(true) } + } + } + } + + init { + super.init() + title = message("ecs.execute_command_task_role_invalid_warning_title") + } + + // Overriden to remove the Cancel button + override fun createActions(): Array = arrayOf(okAction) + + override fun createCenterPanel(): JComponent? = component +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ArtifactMappingsTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ArtifactMappingsTable.kt deleted file mode 100644 index ae1809a68c..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ArtifactMappingsTable.kt +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.execution.util.ListTableWithButtons -import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory -import com.intellij.openapi.project.Project -import com.intellij.util.ui.ListTableModel -import software.aws.toolkits.jetbrains.ui.LocalPathProjectBaseCellEditor -import software.aws.toolkits.resources.message -import javax.swing.table.TableCellEditor - -class ArtifactMappingsTable(project: Project) : ContainerMappingTable( - emptyTableMainText = message("cloud_debug.ecs.run_config.container.artifacts.empty.text"), - addNewEntryText = message("cloud_debug.ecs.run_config.container.artifacts.add") -) { - - override fun isEmpty(element: ArtifactMapping): Boolean = element.localPath.isNullOrEmpty() || element.remotePath.isNullOrEmpty() - - override fun cloneElement(variable: ArtifactMapping): ArtifactMapping = variable.copy() - - override fun createElement(): ArtifactMapping = ArtifactMapping() - - fun getArtifactMappings(): List = elements.toList() - - override fun createListModel(): ListTableModel<*> = ListTableModel( - StringColumnInfo( - message("cloud_debug.ecs.run_config.container.artifacts.local"), - { it.localPath }, - { mapping, value -> mapping.localPath = value }, - { pathCellEditor } - ), - StringColumnInfo( - message("cloud_debug.ecs.run_config.container.artifacts.remote"), - { it.remotePath }, - { mapping, value -> mapping.remotePath = value } - ) - ) - - private inner class StringColumnInfo( - name: String, - private val retrieveFunc: (ArtifactMapping) -> String?, - private val setFunc: (ArtifactMapping, String?) -> Unit, - private val editor: () -> TableCellEditor? = { null } - ) : ListTableWithButtons.ElementsColumnInfoBase(name) { - override fun valueOf(item: ArtifactMapping): String? = retrieveFunc.invoke(item) - - override fun setValue(item: ArtifactMapping, value: String?) { - if (value == valueOf(item)) { - return - } - setFunc.invoke(item, value) - setModified() - } - - override fun getDescription(item: ArtifactMapping): String? = null - - override fun isCellEditable(item: ArtifactMapping): Boolean = true - - override fun getEditor(item: ArtifactMapping): TableCellEditor? = editor() - } - - private val pathCellEditor = LocalPathProjectBaseCellEditor(project) - .normalizePath(true) - .fileChooserDescriptor(FileChooserDescriptorFactory.createSingleFileDescriptor()) -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ContainerMappingTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ContainerMappingTable.kt deleted file mode 100644 index 7b7a9ddd8e..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ContainerMappingTable.kt +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.execution.util.ListTableWithButtons -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.keymap.KeymapUtil -import com.intellij.ui.CommonActionsPanel -import com.intellij.ui.SimpleTextAttributes -import com.intellij.ui.TableUtil -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.StatusText - -abstract class ContainerMappingTable(emptyTableMainText: String, addNewEntryText: String) : ListTableWithButtons() { - - init { - tableView.apply { - emptyText.text = emptyTableMainText - - emptyText.appendSecondaryText( - addNewEntryText, - SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, JBUI.CurrentTheme.Link.linkColor()) - ) { - stopEditing() - - val listModel = tableView.listTableModel - listModel.addRow(createElement()) - - val index = listModel.rowCount - 1 - tableView.setRowSelectionInterval(index, index) - - ApplicationManager.getApplication().invokeLater { - TableUtil.scrollSelectionToVisible(tableView) - TableUtil.editCellAt(tableView, index, 0) - } - } - - val shortcutSet = CommonActionsPanel.getCommonShortcut(CommonActionsPanel.Buttons.ADD) - shortcutSet?.shortcuts?.firstOrNull()?.let { shortcut -> - emptyText.appendSecondaryText(" (${KeymapUtil.getShortcutText(shortcut)})", StatusText.DEFAULT_ATTRIBUTES, null) - } - } - } - - override fun canDeleteElement(selection: T): Boolean = true -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugLocation.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugLocation.kt deleted file mode 100644 index 507937753e..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugLocation.kt +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.execution.Location -import com.intellij.openapi.module.Module -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiElement -import software.amazon.awssdk.services.ecs.model.Service -import software.aws.toolkits.jetbrains.core.DummyPsiElement - -class EcsCloudDebugLocation(private val project: Project, val service: Service) : Location() { - private val element = DummyPsiElement(project) - - override fun getProject(): Project = project - - override fun getModule(): Module? = null - - override fun getAncestors( - ancestorClass: Class?, - strict: Boolean - ): MutableIterator> = mutableListOf>().iterator() - - override fun getPsiElement(): DummyPsiElement = element -} 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 deleted file mode 100644 index 9b08cf1de5..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfiguration.kt +++ /dev/null @@ -1,421 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.execution.ExecutionBundle -import com.intellij.execution.Executor -import com.intellij.execution.configurations.ConfigurationFactory -import com.intellij.execution.configurations.RunConfiguration -import com.intellij.execution.configurations.RunConfigurationWithSuppressedDefaultDebugAction -import com.intellij.execution.configurations.RuntimeConfigurationError -import com.intellij.execution.configurations.RuntimeConfigurationWarning -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.runners.RunConfigurationWithSuppressedDefaultRunAction -import com.intellij.openapi.options.SettingsEditor -import com.intellij.openapi.options.SettingsEditorGroup -import com.intellij.openapi.project.Project -import org.jdom.Element -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.services.clouddebug.CloudDebugConstants -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants.DEFAULT_REMOTE_DEBUG_PORT -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.jetbrains.services.clouddebug.actions.InstrumentResourceAction -import software.aws.toolkits.jetbrains.services.clouddebug.execution.CloudDebugRunState -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources -import software.aws.toolkits.jetbrains.ui.connection.AwsConnectionSettingsEditor -import software.aws.toolkits.jetbrains.ui.connection.AwsConnectionsRunConfigurationBase -import software.aws.toolkits.jetbrains.ui.connection.addAwsConnectionEditor -import software.aws.toolkits.resources.message - -class EcsCloudDebugRunConfiguration(project: Project, private val configFactory: ConfigurationFactory) : - AwsConnectionsRunConfigurationBase(project, configFactory, null), - RunConfigurationWithSuppressedDefaultRunAction, - RunConfigurationWithSuppressedDefaultDebugAction { - - override val serializableOptions = EcsServiceCloudDebuggingOptions() - - @Suppress("UNCHECKED_CAST") - override fun clone(): RunConfiguration { - val element = Element("toClone") - writeExternal(element) - - // Copy the config deeply to prevent aliasing - val copy = configFactory.createTemplateConfiguration(project) as EcsCloudDebugRunConfiguration - copy.name = name - copy.readExternal(element) - - return copy - } - - override fun getConfigurationEditor(): SettingsEditor { - val group = SettingsEditorGroup() - - val editor = EcsCloudDebugSettingsEditor(project) - group.addEditor(ExecutionBundle.message("run.configuration.configuration.tab.title"), editor) - group.addAwsConnectionEditor(AwsConnectionSettingsEditor(project, editor::awsConnectionUpdated)) - - return group - } - - override fun getState(executor: Executor, environment: ExecutionEnvironment): CloudDebugRunState? = - try { - val cloudDebugRunSettings = resolveEcsServiceCloudDebuggingRunSettings(deepCheck = false) - - CloudDebugRunState(environment, cloudDebugRunSettings) - } catch (e: Exception) { - LOG.error(e) { message("cloud_debug.run_configuration.getstate.failure") } - - null - } - - override fun suggestedName() = clusterArn()?.let { cluster -> - serviceArn()?.let { service -> - "[${EcsUtils.clusterArnToName(cluster)}] ${EcsUtils.serviceArnToName(service)} (beta)" - } - } - - override fun checkSettingsBeforeRun() { - val immutableSettings = resolveEcsServiceCloudDebuggingRunSettings(deepCheck = true) - - if (!EcsUtils.isInstrumented(immutableSettings.serviceArn)) { - throw RuntimeConfigurationError( - message("cloud_debug.run_configuration.not_instrumented.service", EcsUtils.serviceArnToName(immutableSettings.serviceArn)) - ) { - InstrumentResourceAction(clusterArn(), serviceArn()).performAction(project) - } - } - } - - override fun checkConfiguration() { - val immutableSettings = resolveEcsServiceCloudDebuggingRunSettings(deepCheck = false) - if (immutableSettings.containerOptions.isEmpty()) { - throw RuntimeConfigurationError(message("cloud_debug.run_configuration.missing.container")) - } - val localPortSet: MutableMap = mutableMapOf() - val remotePortSet: MutableMap = mutableMapOf() - immutableSettings.containerOptions.forEach { (name, options) -> - checkStartupCommand(platform = options.platform, command = options.startCommand, containerName = name) - checkArtifactsMapping(name = name, artifactMappings = options.artifactMappings) - checkPortMappings(options = options, name = name, localPortSet = localPortSet, remotePortSet = remotePortSet) - checkPerContainerWarnings(options = options, name = name) - } - } - - private fun checkStartupCommand(platform: CloudDebuggingPlatform, command: String, containerName: String) { - val commandHelper = DebuggerSupport.debugger(platform).startupCommand() - commandHelper.validateStartupCommand(command, containerName) - } - - private fun checkArtifactsMapping(name: String, artifactMappings: List) { - artifactMappings.groupBy { it.remotePath }.values.forEach { - if (it.size > 1) { - throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.duplicate_remote_artifact", - name, - it.first().remotePath - ) - ) - } - } - } - - private fun checkPortMappings( - options: ImmutableContainerOptions, - name: String, - localPortSet: MutableMap, - remotePortSet: MutableMap - ) { - // Check for remote debug port - options.remoteDebugPorts.forEach { remoteDebugPort -> - if (remoteDebugPort > 65535 || remoteDebugPort < 1) { - throw RuntimeConfigurationError(message("cloud_debug.run_configuration.missing.valid_debug_port", remoteDebugPort.toString(), name)) - } - } - - options.portMappings.forEach { portMapping -> - // Validate local debug port is not set twice - when (localPortSet[portMapping.localPort]) { - null -> localPortSet[portMapping.localPort] = name - name -> - throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.duplicate_local_port_same_container", - name, - portMapping.localPort.toString() - ) - ) - else -> - throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.duplicate_local_port", - localPortSet[portMapping.localPort].toString(), - name, - portMapping.localPort.toString() - ) - ) - } - // validate remote debug port is not set twice - when (remotePortSet[portMapping.remotePort]) { - null -> remotePortSet[portMapping.remotePort] = name - "remote" -> throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.duplicate_remote_debug_port", - name, - portMapping.remotePort.toString() - ) - ) - name -> throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.duplicate_remote_port_same_container", - name, - portMapping.remotePort.toString() - ) - ) - else -> throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.duplicate_remote_port", - remotePortSet[portMapping.remotePort].toString(), - name, - portMapping.remotePort.toString() - ) - ) - } - } - // Add remote debug port to the remote port map and check - options.remoteDebugPorts.forEach { remoteDebugPort -> - when (remotePortSet[remoteDebugPort]) { - null -> remotePortSet[remoteDebugPort] = "remote" - else -> throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.duplicate_remote_debug_port", - name, - remoteDebugPort.toString() - ) - ) - } - } - } - - private fun checkPerContainerWarnings(options: ImmutableContainerOptions, name: String) { - // TODO should we build up a warnings string so that we can display multiple warnings? - options.artifactMappings.forEach { artifactMapping -> - if (artifactMapping.remotePath.trim() == "/") { - throw RuntimeConfigurationWarning( - message( - "cloud_debug.run_configuration.potential_data_loss", - name - ) - ) - } - } - // Check if we need a before task - if (options.platform in CloudDebugConstants.RUNTIMES_REQUIRING_BEFORE_TASK && this.beforeRunTasks.isEmpty()) { - throw RuntimeConfigurationWarning(message("cloud_debug.run_configuration.missing.before_task", name, options.platform.toString())) - } - } - - fun clusterArn(arn: String?) { - serializableOptions.clusterArn = arn - } - - fun clusterArn(): String? = serializableOptions.clusterArn - - fun serviceArn(arn: String?) { - serializableOptions.serviceArn = arn - } - - fun serviceArn(): String? = serializableOptions.serviceArn - - fun containerOptions(options: Map) { - serializableOptions.containerOptions = options.toSortedMap() - } - - fun containerOptions(): Map = serializableOptions.containerOptions - - private fun resolveEcsServiceCloudDebuggingRunSettings(deepCheck: Boolean): EcsServiceCloudDebuggingRunSettings { - val region = resolveRegion() - val credentialProvider = resolveCredentials() - val clusterArn = clusterArn() - ?: throw RuntimeConfigurationError(message("cloud_debug.run_configuration.missing.cluster_arn")) - - val serviceArn = serviceArn() - ?: throw RuntimeConfigurationError(message("cloud_debug.run_configuration.missing.service_arn")) - - val usedRemotePorts = containerOptions().flatMap { containerOptions -> - containerOptions.value.portMappings.map { mapping -> - mapping.remotePort - }.also { - containerOptions.value.remoteDebugPorts?.let { ports -> - it.plus(ports) - } - } - }.filterNotNull().toMutableSet() - - val resourceCache = AwsResourceCache.getInstance(project) - - 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 (deepCheck && !validContainerNames.contains(containerName)) { - throw RuntimeConfigurationError( - message( - "cloud_debug.ecs.run_config.container.doesnt_exist_in_service", - containerName, - EcsUtils.serviceArnToName(serviceArn) - ) - ) - } - resolveContainerOptions(containerName, containerOptions, usedRemotePorts) - }.toSortedMap() - return EcsServiceCloudDebuggingRunSettings( - clusterArn, - serviceArn, - containerOptionsMap, - credentialProvider, - region - ) - } - - private fun resolveContainerOptions( - containerName: String, - containerOptions: ContainerOptions, - usedRemoteDebugPorts: MutableSet - ): ImmutableContainerOptions { - val platform = containerOptions.platform - ?: throw RuntimeConfigurationError(message("cloud_debug.run_configuration.missing.platform", containerName)) - - val requiredNumOfPorts = DebuggerSupport.debugger(platform).numberOfDebugPorts - val configuredRemoteDebugPorts = containerOptions.remoteDebugPorts - - val remoteDebugPorts = if (configuredRemoteDebugPorts != null) { - if (configuredRemoteDebugPorts.size != requiredNumOfPorts) { - throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.incorrect_number_of_ports", - containerName, - platform, - requiredNumOfPorts, - configuredRemoteDebugPorts.size - ) - ) - } - - configuredRemoteDebugPorts - } else { - val selectedPorts = mutableListOf() - - for (i in 0..requiredNumOfPorts) { - var port = DEFAULT_REMOTE_DEBUG_PORT - while (usedRemoteDebugPorts.contains(port)) { - port++ - if (port > 65535) { - throw RuntimeConfigurationError(message("cloud_debug.run_configuration.impressive_number_of_ports", containerName)) - } - } - usedRemoteDebugPorts.add(port) - selectedPorts.add(port) - } - - selectedPorts - } - - val startCommand = containerOptions.startCommand - ?: throw RuntimeConfigurationError(message("cloud_debug.run_configuration.missing.start_command", containerName)) - - val debugSupport = - DebuggerSupport.debuggers()[platform] ?: throw RuntimeConfigurationError(message("cloud_debug.run_configuration.bad_runtime", platform)) - val augmentedStartCommand = if (debugSupport.automaticallyAugmentable(startCommand)) { - debugSupport.augmentStatement(startCommand, remoteDebugPorts, debugSupport.debuggerPath?.getDebuggerEntryPoint() ?: "") - } else { - startCommand - } - - return ImmutableContainerOptions( - platform, - augmentedStartCommand, - remoteDebugPorts, - containerOptions.artifactMappings.mapNotNull { resolveArtifactMappings(containerName, it) }.toList(), - containerOptions.portMappings.mapNotNull { resolvePortMappings(containerName, it) }.toList() - ) - } - - private fun resolveArtifactMappings( - containerName: String, - artifactMapping: ArtifactMapping - ): ImmutableArtifactMapping? { - // Skip empty entries - if (artifactMapping.localPath == null && artifactMapping.remotePath == null) { - return null - } - val localPath = artifactMapping.localPath - ?: throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.missing.local_path", - containerName - ) - ) - - val remotePath = artifactMapping.remotePath - ?: throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.missing.remote_path", - containerName - ) - ) - - return ImmutableArtifactMapping(localPath, remotePath) - } - - private fun resolvePortMappings(containerName: String, portMapping: PortMapping): ImmutablePortMapping? { - // Skip empty entries - if (portMapping.localPort == null && portMapping.remotePort == null) { - return null - } - val localPort = portMapping.localPort - ?: throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.missing.local_port", - containerName - ) - ) - - val remotePort = portMapping.remotePort - ?: throw RuntimeConfigurationError( - message( - "cloud_debug.run_configuration.missing.remote_port", - containerName - ) - ) - - return ImmutablePortMapping(localPort, remotePort) - } - - private companion object { - val LOG = getLogger() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfigurationProducer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfigurationProducer.kt deleted file mode 100644 index 0d40ec4f51..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfigurationProducer.kt +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.execution.actions.ConfigurationContext -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.activeCredentialProvider -import software.aws.toolkits.jetbrains.core.credentials.activeRegion -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils - -class EcsCloudDebugRunConfigurationProducer : LazyRunConfigurationProducer() { - override fun isConfigurationFromContext( - configuration: EcsCloudDebugRunConfiguration, - context: ConfigurationContext - ): Boolean { - val location = context.location as? EcsCloudDebugLocation ?: return false - val service = location.service - val project = context.project - - return configuration.clusterArn() == service.clusterArn() && - configuration.serviceArn() == service.serviceArn() && - configuration.regionId() == project.activeRegion().id && - configuration.credentialProviderId() == project.activeCredentialProvider().id - } - - override fun setupConfigurationFromContext( - configuration: EcsCloudDebugRunConfiguration, - context: ConfigurationContext, - sourceElement: Ref - ): Boolean { - val location = context.location as? EcsCloudDebugLocation ?: return false - val service = location.service - val project = context.project - - if (!EcsUtils.isInstrumented(service.serviceArn())) { - return false - } - - configuration.clusterArn(service.clusterArn()) - configuration.serviceArn(service.serviceArn()) - configuration.regionId(context.project.activeRegion().id) - configuration.credentialProviderId(project.activeCredentialProvider().id) - configuration.setGeneratedName() - - return true - } - - override fun getConfigurationFactory(): ConfigurationFactory = getFactory() - - companion object { - fun getFactory(): ConfigurationFactory = EcsCloudDebugRunConfigurationType.getInstance() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfigurationType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfigurationType.kt deleted file mode 100644 index 870133d629..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugRunConfigurationType.kt +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.execution.configurations.ConfigurationTypeUtil -import com.intellij.execution.configurations.RunConfiguration -import com.intellij.execution.configurations.SimpleConfigurationType -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.NotNullLazyValue -import icons.AwsIcons -import software.aws.toolkits.jetbrains.core.help.HelpIds -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.resources.message - -class EcsCloudDebugRunConfigurationType : SimpleConfigurationType( - "aws.ecs.cloud-debug", - message("cloud_debug.ecs.run_config.name"), - message("cloud_debug.ecs.run_config.description"), - NotNullLazyValue.createConstantValue(AwsIcons.Resources.Ecs.ECS_SERVICE) -) { - override fun createTemplateConfiguration(project: Project): RunConfiguration = - EcsCloudDebugRunConfiguration(project, this) - - override fun getHelpTopic(): String? = HelpIds.CLOUD_DEBUG_RUN_CONFIGURATION.id - override fun isApplicable(project: Project): Boolean = DebuggerSupport.debuggers().isNotEmpty() - - companion object { - fun getInstance() = ConfigurationTypeUtil.findConfigurationType(EcsCloudDebugRunConfigurationType::class.java) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditor.kt deleted file mode 100644 index 9dbaac4f80..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditor.kt +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.openapi.options.SettingsEditor -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer -import software.aws.toolkits.core.region.AwsRegion -import javax.swing.JComponent - -class EcsCloudDebugSettingsEditor(project: Project) : SettingsEditor() { - private val view = EcsCloudDebugSettingsEditorPanel(project) - - override fun createEditor(): JComponent = view.component - - override fun resetEditorFrom(configuration: EcsCloudDebugRunConfiguration) { - view.resetFrom(configuration) - } - - override fun applyEditorTo(configuration: EcsCloudDebugRunConfiguration) { - view.applyTo(configuration) - } - - override fun disposeEditor() { - Disposer.dispose(view) - } - - fun awsConnectionUpdated(region: AwsRegion?, credentialProviderId: String?) { - view.awsConnectionUpdated(region, credentialProviderId) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditorPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditorPanel.form deleted file mode 100644 index ce08c44bf1..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditorPanel.form +++ /dev/null @@ -1,100 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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 deleted file mode 100644 index 5533fe8cce..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsCloudDebugSettingsEditorPanel.kt +++ /dev/null @@ -1,407 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.icons.AllIcons -import com.intellij.openapi.Disposable -import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.actionSystem.ActionPlaces -import com.intellij.openapi.actionSystem.ActionToolbar -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.project.DumbAwareAction -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.ui.SimpleTextAttributes -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBLoadingPanel -import com.intellij.ui.components.JBPanelWithEmptyText -import com.intellij.ui.components.panels.Wrapper -import com.intellij.ui.tabs.JBTabsFactory -import com.intellij.ui.tabs.TabInfo -import com.intellij.util.ExceptionUtil -import com.intellij.util.IconUtil -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.JBUI.CurrentTheme.Validator.errorBackgroundColor -import com.intellij.util.ui.JBUI.CurrentTheme.Validator.errorBorderColor -import software.amazon.awssdk.services.ecs.model.ContainerDefinition -import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider -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.CredentialManager -import software.aws.toolkits.jetbrains.core.filter -import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants.CLOUD_DEBUG_RESOURCE_PREFIX -import software.aws.toolkits.jetbrains.services.ecs.EcsUtils -import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources -import software.aws.toolkits.jetbrains.ui.ResourceSelector -import software.aws.toolkits.resources.message -import java.awt.BorderLayout -import java.util.concurrent.atomic.AtomicReference -import javax.swing.BorderFactory -import javax.swing.JComponent -import javax.swing.JPanel - -class EcsCloudDebugSettingsEditorPanel(private val project: Project) : Disposable { - private var selectedCluster: String? = null - private var selectedService: String? = null - - private lateinit var panel: JPanel - private lateinit var clusterSelector: ResourceSelector - private lateinit var serviceSelector: ResourceSelector - private lateinit var containerLoadingIndicator: JBLoadingPanel - private lateinit var perContainerSettings: JPanel - private lateinit var emptyStatusIndicator: JBPanelWithEmptyText - private lateinit var containerSettingsToolbarHolder: Wrapper - private lateinit var containerSettingsTabHolder: Wrapper - private lateinit var errorLabel: JBLabel - - private val tabs = JBTabsFactory.createEditorTabs(project, this) - private var tabInfoHolder = TabInfoHolder(null, null) - private val addAction = AddContainerAction() - private val toolbar = createToolbar() - private var containerNames: Set = emptySet() - private val credentialManager = CredentialManager.getInstance() - private val credentialSettings = AtomicReference>() - - val component: JComponent - get() = panel - - init { - postUIComponents() - showMissingServiceContainerMessage() - } - - private fun createUIComponents() { - containerLoadingIndicator = JBLoadingPanel(BorderLayout(), project) - containerLoadingIndicator.setLoadingText(message("cloud_debug.ecs.run_config.container.loading")) - containerLoadingIndicator.border = JBUI.Borders.empty() - - clusterSelector = ResourceSelector.builder(project) - .resource(EcsResources.LIST_CLUSTER_ARNS) - .customRenderer { value, component -> component.append(EcsUtils.clusterArnToName(value)); component } - .disableAutomaticLoading() - .awsConnection { credentialSettings.get() ?: throw IllegalStateException("clusterSelector.reload() called before region/credentials set") } - .build() - - clusterSelector.addActionListener { this.onClusterSelectionChange() } - - serviceSelector = ResourceSelector.builder(project).resource { - val selectedCluster = selectedCluster - if (selectedCluster != null) { - EcsResources.listServiceArns(selectedCluster).filter { EcsUtils.isInstrumented(it) } - } else { - null - } - }.customRenderer { value, component -> component.append(EcsUtils.serviceArnToName(value)); component } - .disableAutomaticLoading() - .awsConnection { credentialSettings.get() ?: throw IllegalStateException("serviceSelector.reload() called before region/credentials set") } - .build() - - serviceSelector.isEnabled = false - serviceSelector.addActionListener { this.onServiceSelectionChange() } - } - - private fun postUIComponents() { - perContainerSettings.isVisible = false - errorLabel.setAllowAutoWrapping(true) - errorLabel.isVisible = false - errorLabel.isOpaque = true - errorLabel.background = errorBackgroundColor() - errorLabel.border = BorderFactory.createLineBorder(errorBorderColor()) - - containerSettingsToolbarHolder.setContent(toolbar.component) - containerSettingsTabHolder.setContent(tabs.component) - } - - private fun createToolbar(): ActionToolbar { - val actionGroup = DefaultActionGroup(addAction) - val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.UNKNOWN, actionGroup, true) - toolbar.setReservePlaceAutoPopupIcon(false) - toolbar.setMiniMode(true) - - val toolbarComponent = toolbar.component - toolbarComponent.border = JBUI.Borders.empty() - - return toolbar - } - - private fun onClusterSelectionChange() { - if (clusterSelector.isLoading) { - return - } - - val newSelection = clusterSelector.selected() - - serviceSelector.isEnabled = newSelection != null - - if (selectedCluster != newSelection) { - selectedCluster = newSelection - serviceSelector.reload() - } - } - - private fun onServiceSelectionChange() { - if (serviceSelector.isLoading) { - return - } - - val newSelection = serviceSelector.selected() - if (newSelection != null && newSelection != selectedService) { - if (serviceSelector.model.size != 0) { - selectedService = newSelection - - val localSelectedCluster = selectedCluster - val localSelectedService = selectedService - if (localSelectedCluster != null && localSelectedService != null) { - loadContainers(localSelectedCluster, localSelectedService) - } - } else { - showMissingServiceContainerMessage() - } - } - } - - private fun showMissingServiceContainerMessage() { - // No service selected, hide the per container settings - perContainerSettings.isVisible = false - emptyStatusIndicator.emptyText.setText( - message("cloud_debug.ecs.run_config.container.empty_text"), - SimpleTextAttributes.REGULAR_ATTRIBUTES - ) - } - - private fun loadContainers(clusterArn: String, serviceArn: String) { - startLoadingContainers() - - // If the saved tab infos were for the cluster and service, and not empty, use them, else we start from scratch - val initialTabs = if (tabInfoHolder.clusterArn == clusterArn && tabInfoHolder.serviceArn == serviceArn && tabInfoHolder.size > 0) { - tabInfoHolder.sortedBy { it.text } - } else { - tabInfoHolder.serviceArn = serviceArn - tabInfoHolder.clusterArn = clusterArn - tabInfoHolder.clear() - - emptyList() - } - - val (awsRegion, credentialProvider) = credentialSettings.get() - - val resourceCache = AwsResourceCache.getInstance(project) - resourceCache.getResource( - EcsResources.describeService(clusterArn, serviceArn), - awsRegion, - credentialProvider - ).thenCompose { service -> - resourceCache.getResource( - EcsResources.listContainers(service.taskDefinition()), - awsRegion, - credentialProvider - ) - }.whenComplete { containers, error -> - runInEdt(ModalityState.any()) { - stopLoadingContainers(containers, initialTabs, error) - } - } - } - - private fun startLoadingContainers() { - emptyStatusIndicator.isVisible = false - containerLoadingIndicator.startLoading() - - containerNames = emptySet() - tabs.removeAllTabs() - } - - private fun stopLoadingContainers( - containers: List?, - initialTabs: List, - error: Throwable? - ) { - when { - containers != null -> { - containerNames = containers - .map { it.name() } - // Skip showing the sidecar container - .filter { !it.startsWith(CLOUD_DEBUG_RESOURCE_PREFIX) } - .toSet() - - // If we only have one container, and there are no containers in the model, add it by default - if (tabInfoHolder.isEmpty() && containerNames.size == 1) { - val tabInfo = createTabInfo(containerNames.first()) - tabInfoHolder.add(tabInfo) - } - - // this can be called twice with the same initial tabs sometimes (why) so make sure it is not - // in the list of tabs already - initialTabs.forEach { - if (it.text in containerNames && tabs.tabs.none { tab -> tab.text.contains(it.text) }) { - tabs.addTab(it) - } - } - - tabInfoHolder.forEach { - tabs.addTab(it) - } - - perContainerSettings.isVisible = containers.isNotEmpty() - errorLabel.isVisible = false - } - error != null -> { - val errorText = message( - "cloud_debug.ecs.run_config.container.loading.error", - ExceptionUtil.getMessage(error) - ?: message("cloud_debug.ecs.run_config.container.loading.error.unknown_error") - ) - errorLabel.isVisible = true - errorLabel.text = "$errorText" - } - } - - emptyStatusIndicator.isVisible = error == null - containerLoadingIndicator.stopLoading() - } - - private fun createTabInfo(containerName: String): TabInfo { - val containerSettings = PerContainerSettings(project, containerName, this) - return TabInfo(containerSettings.panel).apply { - text = containerName - `object` = containerSettings - setTabLabelActions(DefaultActionGroup(CloseTabAction(this)), ActionPlaces.UNKNOWN) - } - } - - fun resetFrom(configuration: EcsCloudDebugRunConfiguration) { - val clusterArn = configuration.clusterArn() - val serviceArn = configuration.serviceArn() - val containerSettings = configuration.containerOptions() - val credentialProviderId = configuration.credentialProviderId() ?: return - val region = configuration.regionId()?.let { AwsRegionProvider.getInstance()[it] } ?: return - val credentialIdentifier = credentialManager.getCredentialIdentifierById(credentialProviderId) ?: return - val credentialProvider = credentialManager.getAwsCredentialProvider(credentialIdentifier, region) - - // Set initial state before telling UI to update - credentialSettings.set(region to credentialProvider) - selectedCluster = clusterArn - selectedService = serviceArn - - if (clusterArn != null && serviceArn != null) { - tabInfoHolder.clusterArn = clusterArn - tabInfoHolder.serviceArn = serviceArn - - tabInfoHolder.clear() - containerSettings.forEach { - val initialTab = createTabInfo(it.key) - tabInfoHolder.add(initialTab) - val perContainerSettings = initialTab.`object` as PerContainerSettings - perContainerSettings.resetFrom(it.value) - } - - loadContainers(clusterArn, serviceArn) - } else { - stopLoadingContainers(emptyList(), emptyList(), null) - } - - clusterSelector.selectedItem = clusterArn - serviceSelector.selectedItem = serviceArn - - clusterSelector.reload() - serviceSelector.reload() - } - - fun applyTo(configuration: EcsCloudDebugRunConfiguration) { - val (region, credentialsProvider) = credentialSettings.get() ?: return - configuration.regionId(region.id) - configuration.credentialProviderId(credentialsProvider.id) - configuration.clusterArn(selectedCluster) - configuration.serviceArn(selectedService) - - configuration.containerOptions( - tabInfoHolder.map { - val containerOptions = ContainerOptions() - (it.`object` as PerContainerSettings).applyTo(containerOptions) - - it.text to containerOptions - }.toMap() - ) - } - - override fun dispose() {} - - private inner class TabInfoHolder(var clusterArn: String?, var serviceArn: String?) : - MutableSet by mutableSetOf() - - private inner class AddContainerAction : DumbAwareAction( - message("cloud_debug.ecs.run_config.container.add"), - null, - IconUtil.getAddIcon() - ) { - override fun actionPerformed(e: AnActionEvent) { - val containerCandidates = containerNames - .minus(tabInfoHolder.map { it.text }) - .sorted() - - val addActions = containerCandidates.map { - object : AnAction(it) { - override fun actionPerformed(e: AnActionEvent) { - val newTab = createTabInfo(it) - tabInfoHolder.add(newTab) - tabs.addTab(newTab) - } - } - } - - JBPopupFactory.getInstance() - .createActionGroupPopup( - message("cloud_debug.ecs.run_config.container.select_container"), - DefaultActionGroup(addActions), - e.dataContext, - null, - false - ) - .showUnderneathOf(e.inputEvent.component) - } - - override fun displayTextInToolbar(): Boolean = true - - override fun update(e: AnActionEvent) { - e.presentation.isEnabled = containerNames.size != tabs.tabCount - } - } - - inner class CloseTabAction(private val tabInfo: TabInfo) : AnAction(AllIcons.Actions.Close) { - override fun actionPerformed(e: AnActionEvent) { - tabs.removeTab(tabInfo) - tabInfoHolder.remove(tabInfo) - } - - override fun update(e: AnActionEvent) { - e.presentation.icon = AllIcons.Actions.Close - e.presentation.hoveredIcon = AllIcons.Actions.CloseHovered - } - } - - internal fun awsConnectionUpdated(region: AwsRegion?, credentialProviderId: String?) { - region ?: return - credentialProviderId ?: return - - val credentialIdentifier = credentialManager.getCredentialIdentifierById(credentialProviderId) ?: return - val credentialProvider = tryOrNull { credentialManager.getAwsCredentialProvider(credentialIdentifier, region) } ?: return - - val oldSettings = credentialSettings.getAndUpdate { region to credentialProvider } - if (oldSettings?.first == region && oldSettings.second == credentialProvider) return - - // Clear out settings on region change - containerNames = emptySet() - tabs.removeAllTabs() - tabInfoHolder.clusterArn = null - tabInfoHolder.serviceArn = null - - clusterSelector.reload() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsServiceCloudDebuggingOptions.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsServiceCloudDebuggingOptions.kt deleted file mode 100644 index e7b8dfb470..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/EcsServiceCloudDebuggingOptions.kt +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.util.xmlb.annotations.Tag -import software.amazon.awssdk.services.ecs.model.LaunchType -import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider -import software.aws.toolkits.core.region.AwsRegion -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform -import software.aws.toolkits.jetbrains.ui.connection.BaseAwsConnectionOptions -import java.util.SortedMap - -@Tag("EcsServiceCloudDebuggingOptions") -class EcsServiceCloudDebuggingOptions : BaseAwsConnectionOptions() { - var clusterArn: String? = null - var serviceArn: String? = null - var containerOptions: SortedMap = sortedMapOf() -} - -data class EcsServiceCloudDebuggingRunSettings( - val clusterArn: String, - val serviceArn: String, - val containerOptions: SortedMap, - val credentialProvider: ToolkitCredentialsProvider, - val region: AwsRegion -) - -@Tag("ContainerOptions") -class ContainerOptions { - var platform: CloudDebuggingPlatform? = null - var startCommand: String? = null - var remoteDebugPorts: List? = null - var artifactMappings: List = emptyList() - var portMappings: List = emptyList() -} - -data class ImmutableContainerOptions( - val platform: CloudDebuggingPlatform, - val startCommand: String, - var remoteDebugPorts: List, - val artifactMappings: List, - val portMappings: List -) - -@Tag("ArtifactMapping") -data class ArtifactMapping( - var localPath: String? = null, - var remotePath: String? = null -) - -data class ImmutableArtifactMapping( - val localPath: String, - val remotePath: String -) - -@Tag("PortMapping") -data class PortMapping( - var localPort: Int? = null, - var remotePort: Int? = null -) - -data class ImmutablePortMapping( - val localPort: Int, - val remotePort: Int -) - -enum class EcsLaunchType(val sdkType: LaunchType) { - EC2(LaunchType.EC2), FARGATE(LaunchType.FARGATE); -} 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 deleted file mode 100644 index 41a66a462d..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/ImportFromDockerfile.kt +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -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.openapi.fileChooser.FileChooser -import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory -import com.intellij.openapi.project.Project -import com.intellij.openapi.project.guessProjectDir -import com.intellij.openapi.ui.Messages -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.plugins.docker.dockerFile.parser.psi.DockerFileAddOrCopyCommand -import com.intellij.plugins.docker.dockerFile.parser.psi.DockerFileCmdCommand -import com.intellij.plugins.docker.dockerFile.parser.psi.DockerFileExposeCommand -import com.intellij.plugins.docker.dockerFile.parser.psi.DockerFileFromCommand -import com.intellij.plugins.docker.dockerFile.parser.psi.DockerFileWorkdirCommand -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 -import java.io.File - -object DockerUtil { - @JvmStatic - fun dockerPluginAvailable() = pluginIsInstalledAndEnabled("Docker") -} - -class ImportFromDockerfile @JvmOverloads constructor( - private val project: Project, - private val view: PerContainerSettings, - private val dockerfileParser: DockerfileParser = DockerfileParser(project) -) : ActionListener { - override fun actionPerformed(e: ActionEvent?) { - val file = FileChooser.chooseFile( - FileChooserDescriptorFactory.createSingleFileDescriptor(DockerFileType.DOCKER_FILE_TYPE), - project, - project.guessProjectDir() - ) ?: return - val details = tryOrNull { dockerfileParser.parse(file) } - if (details == null) { - Messages.showWarningDialog( - view.component, - message("cloud_debug.ecs.run_config.unsupported_dockerfile", file), - message("cloud_debug.ecs.run_config.unsupported_dockerfile.title") - ) - return - } - - details.command?.let { view.startCommand.command = it } - details.exposePorts.map { PortMapping(remotePort = it) }.takeIf { it.isNotEmpty() }?.let { view.portMappingsTable.setValues(it) } - details.copyDirectives.map { ArtifactMapping(it.from, it.to) }.takeIf { it.isNotEmpty() }?.let { view.artifactMappingsTable.setValues(it) } - } -} - -class DockerfileParser(private val project: Project) { - fun parse(virtualFile: VirtualFile): DockerfileDetails? { - val psiFile = PsiManager.getInstance(project).findFile(virtualFile)!! - val contextDirectory = virtualFile.parent.path - - val lastFromCommand = psiFile.children.filterIsInstance().lastOrNull() ?: return null - val commandsAfterLastFrom = psiFile.children.dropWhile { it != lastFromCommand } - if (commandsAfterLastFrom.isEmpty()) { - return null - } - - val command = commandsAfterLastFrom.filterIsInstance().lastOrNull()?.text?.substringAfter("CMD ") - val portMappings = commandsAfterLastFrom.filterIsInstance().mapNotNull { - it.listChildren().find { child -> (child as? LeafPsiElement)?.elementType?.toString() == "INTEGER_LITERAL" }?.text?.toIntOrNull() - } - - val copyDirectives = groupByWorkDir(commandsAfterLastFrom).flatMap { (workDir, commands) -> - commands.filterIsInstance() - .filter { it.copyKeyword != null } - .mapNotNull { cmd -> cmd.fileOrUrlList.takeIf { it.size == 2 }?.let { it.first().text to it.last().text } } - .map { (rawLocal, rawRemote) -> - val local = if (rawLocal.startsWith("/") || rawLocal.startsWith(File.separatorChar)) { - rawLocal - } else { - "${contextDirectory.normalizeDirectory(true)}$rawLocal" - } - val remote = if (rawRemote.startsWith("/") || workDir == null) { - rawRemote - } else { - "${workDir.normalizeDirectory()}$rawRemote" - } - CopyDirective(local, remote) - } - } - - return DockerfileDetails(command, portMappings, copyDirectives) - } - - private fun groupByWorkDir(commands: List): List>> { - val list = mutableListOf>>() - var workDir: String? = null - val elements = mutableListOf() - commands.forEach { - when (it) { - is DockerFileWorkdirCommand -> { - if (elements.isNotEmpty()) { - list.add(workDir to elements.toList()) - elements.clear() - } - workDir = it.fileOrUrlList.first().text - } - is DockerPsiCommand -> elements.add(it) - } - } - if (elements.isNotEmpty()) { - list.add(workDir to elements.toList()) - } - return list - } - - private fun PsiElement.listChildren(): List { - var child: PsiElement? = firstChild ?: return emptyList() - val children = mutableListOf() - while (child != null) { - children.add(child) - child = child.nextSibling - } - return children.toList() - } -} - -data class DockerfileDetails(val command: String?, val exposePorts: List, val copyDirectives: List) - -data class CopyDirective(val from: String, val to: String) - -fun String.normalizeDirectory(matchPlatform: Boolean = false): String { - val ch = if (matchPlatform) File.separatorChar else '/' - return "${trimEnd(ch)}$ch" -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/PerContainerSettings.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/PerContainerSettings.form deleted file mode 100644 index f66a82ac68..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/PerContainerSettings.form +++ /dev/null @@ -1,91 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/PerContainerSettings.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/PerContainerSettings.java deleted file mode 100644 index 97053f9c47..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/PerContainerSettings.java +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution; - -import static software.aws.toolkits.resources.Localization.message; - -import com.intellij.openapi.Disposable; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.ComboBox; -import com.intellij.ui.SortedComboBoxModel; -import com.intellij.ui.tabs.JBTabs; -import com.intellij.ui.tabs.JBTabsFactory; -import com.intellij.ui.tabs.TabInfo; -import java.awt.BorderLayout; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import javax.swing.JButton; -import javax.swing.JComponent; -import javax.swing.JPanel; -import org.jetbrains.annotations.NotNull; -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebugConstants; -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform; -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport; -import software.aws.toolkits.jetbrains.ui.clouddebug.StartupCommandWithAutoFill; - -public class PerContainerSettings { - private final SortedComboBoxModel platformModel; - - JPanel panel; - ComboBox platform; - @NotNull StartupCommandWithAutoFill startCommand; - JPanel containerSettingsTabsPanel; - JBTabs containerSettingsTabs; - RemoteDebugPort remoteDebugPort; - JButton importFromDockerfile; - ArtifactMappingsTable artifactMappingsTable; - PortMappingsTable portMappingsTable; - private Project project; - private String containerName; - - PerContainerSettings(Project project, String containerName, Disposable parent) { - this.project = project; - this.containerName = containerName; - this.platformModel = new SortedComboBoxModel<>(Comparator.comparing(Enum::name)); - this.platform.setModel(platformModel); - this.artifactMappingsTable = new ArtifactMappingsTable(project); - this.portMappingsTable = new PortMappingsTable(); - this.containerSettingsTabs = JBTabsFactory.createEditorTabs(project, parent); - - initStartupCommandField(); - initPlatformComboBox(); - initArtifactMappingTable(); - - containerSettingsTabs.addTab(new TabInfo(artifactMappingsTable.getComponent()).setText(message("cloud_debug.ecs.run_config.container.artifacts.tab_name")).setTooltipText(message("cloud_debug.ecs.run_config.container.artifacts.tooltip"))); - containerSettingsTabs.addTab(new TabInfo(portMappingsTable.getComponent()).setText(message("cloud_debug.ecs.run_config.container.ports.tab_name")).setTooltipText(message("cloud_debug.ecs.run_config.container.ports.tooltip"))); - - platformModel.setAll(DebuggerSupport.debuggers().keySet()); - platformModel.setSelectedItem(platformModel.get(0)); - - containerSettingsTabsPanel.add(containerSettingsTabs.getComponent(), BorderLayout.CENTER); - if (DockerUtil.dockerPluginAvailable()) { - importFromDockerfile.addActionListener(new ImportFromDockerfile(project, this)); - } else { - importFromDockerfile.setEnabled(false); - importFromDockerfile.setVisible(false); - } - } - - private void createUIComponents() { - startCommand = new StartupCommandWithAutoFill(project, containerName); - } - - public JComponent getComponent() { - return panel; - } - - public void applyTo(@NotNull ContainerOptions containerOptions) { - containerOptions.setPlatform(platformModel.getSelectedItem()); - containerOptions.setPortMappings(portMappingsTable.getPortMappings()); - containerOptions.setArtifactMappings(artifactMappingsTable.getArtifactMappings()); - containerOptions.setStartCommand(startCommand.getCommand()); - containerOptions.setRemoteDebugPorts(remoteDebugPort.getPorts()); - } - - public void resetFrom(@NotNull ContainerOptions containerOptions) { - platformModel.setSelectedItem(containerOptions.getPlatform()); - portMappingsTable.setValues(containerOptions.getPortMappings()); - artifactMappingsTable.setValues(containerOptions.getArtifactMappings()); - String command = containerOptions.getStartCommand(); - startCommand.setCommand(command == null ? "" : command); - remoteDebugPort.setIfNotDefault(containerOptions.getRemoteDebugPorts()); - } - - private void initStartupCommandField() { - this.startCommand.setAutoFillPopupContent(() -> artifactMappingsTable.getArtifactMappings()); - } - - private void initArtifactMappingTable() { - artifactMappingsTable.getTableView().getListTableModel().addTableModelListener( - tableModelEvent -> startCommand.setAutoFillLinkEnabled( - artifactMappingsTable.getArtifactMappings().stream().anyMatch( - artifactMapping -> { - String localPath = artifactMapping.getLocalPath(); - String remotePath = artifactMapping.getRemotePath(); - return localPath != null && !localPath.isEmpty() && remotePath != null && !remotePath.isEmpty(); - } - ) - ) - ); - } - - private void initPlatformComboBox() { - this.platform.addActionListener(event -> { - if (remoteDebugPort.isEmpty()) { - DebuggerSupport debugger = DebuggerSupport.debugger(platformModel.getSelectedItem()); - - List ports = new ArrayList<>(); - for (int i = 0; i < debugger.getNumberOfDebugPorts(); i++) { - ports.add(CloudDebugConstants.DEFAULT_REMOTE_DEBUG_PORT + i); - } - remoteDebugPort.setDefaultPorts(ports); - } - - int platformIndex = this.platform.getSelectedIndex(); - if (platformIndex < 0) return; - - CloudDebuggingPlatform platform = this.platform.getItemAt(platformIndex); - startCommand.setPlatform(platform); - }); - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/PortMappingsTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/PortMappingsTable.kt deleted file mode 100644 index 5d48a547b3..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/PortMappingsTable.kt +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.execution.util.ListTableWithButtons -import com.intellij.util.ui.ListTableModel -import software.aws.toolkits.resources.message - -class PortMappingsTable : ContainerMappingTable( - emptyTableMainText = message("cloud_debug.ecs.run_config.container.ports.empty.text"), - addNewEntryText = message("cloud_debug.ecs.run_config.container.ports.add") -) { - - override fun isEmpty(element: PortMapping): Boolean = element.localPort == null || element.remotePort == null - - override fun cloneElement(variable: PortMapping): PortMapping = variable.copy() - - override fun createElement(): PortMapping = PortMapping() - - fun getPortMappings(): List = elements.toList() - - override fun createListModel(): ListTableModel<*> = ListTableModel( - NumericColumnInfo( - message("cloud_debug.ecs.run_config.container.ports.local"), - { it.localPort }, - { mapping, value -> mapping.localPort = value }), - NumericColumnInfo( - message("cloud_debug.ecs.run_config.container.ports.remote"), - { it.remotePort }, - { mapping, value -> mapping.remotePort = value }) - ) - - private inner class NumericColumnInfo( - name: String, - private val retrieveFunc: (PortMapping) -> Int?, - private val setFunc: (PortMapping, Int?) -> Unit - ) : ListTableWithButtons.ElementsColumnInfoBase(name) { - override fun valueOf(item: PortMapping): String? = retrieveFunc.invoke(item).let { - it?.toString() ?: "" - } - - override fun setValue(item: PortMapping, value: String?) { - if (value == valueOf(item)) { - return - } - - val trimmedInput = value?.trim() - if (trimmedInput?.isNotEmpty() == true) { - val valueInt = try { - Integer.parseInt(trimmedInput) - } catch (_: Exception) { - return - } - - if (valueInt > 0) { - setFunc.invoke(item, valueInt) - setModified() - } - } - } - - override fun getDescription(item: PortMapping?): String? = null - - override fun isCellEditable(item: PortMapping?): Boolean = true - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/RemoteDebugPort.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/RemoteDebugPort.kt deleted file mode 100644 index 618186c808..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/execution/RemoteDebugPort.kt +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.ecs.execution - -import com.intellij.ui.components.fields.CommaSeparatedIntegersField -import com.intellij.ui.components.fields.valueEditors.CommaSeparatedIntegersValueEditor - -class RemoteDebugPort : CommaSeparatedIntegersField(null, 1, 65535, null) { - init { - this.columns = 5 - } - - fun setDefaultPorts(ports: List) { - emptyText.text = CommaSeparatedIntegersValueEditor.intListToString(ports) - } - - fun getPorts(): List? = if (text.isNullOrEmpty()) { - null - } else { - value - } - - fun setIfNotDefault(remoteDebugPorts: List?) { - if (remoteDebugPorts == null) { - text = null - return - } - val portsString = CommaSeparatedIntegersValueEditor.intListToString(remoteDebugPorts) - if (portsString != emptyText.text) { - text = portsString - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/resources/EcsResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/resources/EcsResources.kt index 04c48f7ed5..83844791c1 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/resources/EcsResources.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ecs/resources/EcsResources.kt @@ -8,7 +8,6 @@ import software.amazon.awssdk.services.ecs.model.ContainerDefinition import software.amazon.awssdk.services.ecs.model.Service import software.amazon.awssdk.services.ecs.model.ServiceNotFoundException import software.amazon.awssdk.services.ecs.model.TaskDefinition -import software.amazon.awssdk.services.ecs.model.TaskDefinitionFamilyStatus import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource import software.aws.toolkits.jetbrains.core.Resource import software.aws.toolkits.jetbrains.core.map @@ -20,11 +19,6 @@ object EcsResources { listClustersPaginator().clusterArns().toList() } - val LIST_ACTIVE_TASK_DEFINITION_FAMILIES: Resource.Cached> = - ClientBackedCachedResource(EcsClient::class, "ecs.list_task_definition_families") { - listTaskDefinitionFamiliesPaginator { it.status(TaskDefinitionFamilyStatus.ACTIVE) }.families().toList() - } - fun listServiceArns(clusterArn: String): Resource.Cached> = ClientBackedCachedResource(EcsClient::class, "ecs.list_services.$clusterArn") { listServicesPaginator { it.cluster(clusterArn) }.serviceArns().toList() @@ -33,7 +27,7 @@ object EcsResources { fun describeService(clusterArn: String, serviceArn: String): Resource.Cached = ClientBackedCachedResource(EcsClient::class, "ecs.describe_service.$clusterArn.$serviceArn") { describeServices { it.cluster(clusterArn).services(serviceArn) }.services().firstOrNull() - ?: throw ServiceNotFoundException.builder().message(message("cloud_debug.ecs.service.not_found", serviceArn, clusterArn)).build() + ?: throw ServiceNotFoundException.builder().message(message("ecs.service.not_found", serviceArn, clusterArn)).build() } fun describeTaskDefinition(familyName: String): Resource.Cached = @@ -49,5 +43,5 @@ object EcsResources { fun listTaskIds(clusterArn: String, serviceArn: String) = listTasks(clusterArn, serviceArn).map { it.substringAfterLast("/") } fun listContainers(taskDefinitionArn: String): Resource> = - Resource.View(describeTaskDefinition(taskDefinitionArn)) { containerDefinitions() } + Resource.view(describeTaskDefinition(taskDefinitionArn)) { containerDefinitions() } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/AwsConsoleUrlFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/AwsConsoleUrlFactory.kt new file mode 100644 index 0000000000..ffd313b5f7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/AwsConsoleUrlFactory.kt @@ -0,0 +1,191 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.federation + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import org.apache.http.client.entity.UrlEncodedFormEntity +import org.apache.http.client.methods.HttpPost +import org.apache.http.impl.client.HttpClientBuilder +import org.apache.http.message.BasicNameValuePair +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.amazon.awssdk.services.sts.StsClient +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettings +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyNoActiveCredentialsError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.DeeplinkTelemetry +import software.aws.toolkits.telemetry.Result +import java.net.URLEncoder +import java.time.Duration + +object AwsConsoleUrlFactory { + private val defaultHttpClientBuilder: HttpClientBuilder by lazy { HttpClientBuilder.create() } + + fun federationUrl(region: AwsRegion): String { + // https://docs.aws.amazon.com/general/latest/gr/signin-service.html + // https://docs.amazonaws.cn/en_us/aws/latest/userguide/endpoints-Beijing.html + // TODO: pull this into our endpoints generator somehow + + val signinTld = when (region.partitionId) { + // special case cn since signin.amazonaws.com.cn does not resolve + "aws-cn" -> "signin.amazonaws.cn" + else -> "signin.${consoleTld(region)}" + } + + val subdomain = when (region.id) { + "us-east-1", "us-gov-west-1", "cn-north-1" -> "" + else -> "${region.id}." + } + + return "https://$subdomain$signinTld/federation" + } + + fun consoleTld(region: AwsRegion) = when (region.partitionId) { + // needs to be these; for example, redirecting to "amazonaws.com" is not allowed + "aws" -> { + "aws.amazon.com" + } + // TODO: gov is not supported for POST-based federation yet +// "aws-us-gov" -> { +// "amazonaws-us-gov.com" +// } + "aws-cn" -> { + "amazonaws.com.cn" + } + else -> throw IllegalStateException("Partition '${region.partitionId}' is not supported") + } + + fun consoleUrl(fragment: String? = null, region: AwsRegion): String { + val consoleHome = "https://${region.id}.console.${consoleTld(region)}" + + return "$consoleHome${fragment ?: "/"}" + } + + fun getSigninUrl(connectionSettings: ConnectionSettings, destination: String?, httpClientBuilder: HttpClientBuilder = defaultHttpClientBuilder): String = + getSigninUrl(getSigninToken(connectionSettings, httpClientBuilder), destination, connectionSettings.region) + + fun getSigninToken(connectionSettings: ConnectionSettings, httpClientBuilder: HttpClientBuilder = defaultHttpClientBuilder): String { + val resolvedCreds = connectionSettings.credentials.resolveCredentials() + val sessionCredentials = if (resolvedCreds !is AwsSessionCredentials) { + val stsClient = AwsClientManager.getInstance().getClient(connectionSettings) + + val tokenResponse = stsClient.use { client -> + client.getFederationToken { + it.durationSeconds(Duration.ofMinutes(15).toSeconds().toInt()) + it.name("FederationViaAWSJetBrainsToolkit") + // policy is required otherwise resulting session has no permissions + // session will have the intersection of role permissions and this policy + it.policyArns({ builder -> + builder.arn("arn:aws:iam::aws:policy/AdministratorAccess") + }) + } + } + + tokenResponse.credentials().let { AwsSessionCredentials.create(it.accessKeyId(), it.secretAccessKey(), it.sessionToken()) } + } else { + resolvedCreds + } + + val sessionJson = mapper.writeValueAsString( + GetSigninTokenRequest( + sessionId = sessionCredentials.accessKeyId(), + sessionKey = sessionCredentials.secretAccessKey(), + sessionToken = sessionCredentials.sessionToken() + ) + ) + + val params = mapOf( + "Action" to "getSigninToken", + "SessionType" to "json", + "Session" to sessionJson + ).map { BasicNameValuePair(it.key, it.value) } + + val request = HttpPost(federationUrl(connectionSettings.region)) + .apply { + entity = UrlEncodedFormEntity(params) + } + + val result = httpClientBuilder + .setUserAgent(AwsClientManager.userAgent) + .build().use { c -> + c.execute( + request + ).use { resp -> + if (resp.statusLine.statusCode !in 200..399) { + throw RuntimeException("getSigninToken request to AWS Signin endpoint failed: ${resp.statusLine}") + } + resp.entity.content.readAllBytes().decodeToString() + } + } + + return mapper.readValue(result).signinToken + } + + private fun getSigninUrl(token: String, destination: String? = null, region: AwsRegion): String { + val params = mapOf( + "Action" to "login", + "SigninToken" to token, + "Destination" to consoleUrl(fragment = destination, region = region) + ).map { BasicNameValuePair(it.key, it.value) } + + return "${federationUrl(region)}?${UrlEncodedFormEntity(params).toUrlEncodedString()}" + } + + fun openArnInConsole(project: Project, place: String, arn: String) { + val connectionSettings = project.getConnectionSettings() + + if (connectionSettings == null) { + notifyNoActiveCredentialsError(project) + return + } + + ApplicationManager.getApplication().executeOnPooledThread { + try { + val encodedArn = URLEncoder.encode(arn, Charsets.UTF_8) + val encodedUa = URLEncoder.encode(AwsClientManager.userAgent, Charsets.UTF_8) + val url = AwsConsoleUrlFactory.getSigninUrl( + connectionSettings, + "/go/view?arn=$encodedArn&source=$encodedUa" + ) + BrowserUtil.browse(url) + DeeplinkTelemetry.open(project, source = place, passive = false, result = Result.Succeeded) + } catch (e: Exception) { + val message = message("general.open_in_aws_console.error") + notifyError(content = message, project = project) + LOG.error(e) { message } + DeeplinkTelemetry.open(project, source = place, passive = false, result = Result.Failed) + } + } + } + + private val mapper = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + private val LOG = getLogger() +} + +private data class GetSigninTokenRequest( + @JsonProperty("sessionId") + val sessionId: String, + @JsonProperty("sessionKey") + val sessionKey: String, + @JsonProperty("sessionToken") + val sessionToken: String +) + +private data class GetSigninTokenResponse( + @JsonProperty("SigninToken") + val signinToken: String +) + +private fun UrlEncodedFormEntity.toUrlEncodedString() = this.content.bufferedReader().readText() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/OpenArnInConsoleEditorPopupAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/OpenArnInConsoleEditorPopupAction.kt new file mode 100644 index 0000000000..cc627dd6f6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/OpenArnInConsoleEditorPopupAction.kt @@ -0,0 +1,34 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.federation + +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ex.util.EditorUtil +import com.intellij.openapi.project.DumbAwareAction + +class OpenArnInConsoleEditorPopupAction : DumbAwareAction() { + override fun actionPerformed(e: AnActionEvent) { + val editor = e.getData(CommonDataKeys.EDITOR) + val selection = editor.selection() ?: return + val project = e.getData(CommonDataKeys.PROJECT) ?: return + + AwsConsoleUrlFactory.openArnInConsole(project, ActionPlaces.EDITOR_POPUP, selection) + } + + override fun update(e: AnActionEvent) { + val editor = e.getData(CommonDataKeys.EDITOR) + val isAvailable = if (editor == null || !EditorUtil.isRealFileEditor(editor)) { + false + } else { + editor.selection()?.startsWith("arn:", ignoreCase = true) == true + } + + e.presentation.isEnabledAndVisible = isAvailable + } + + private fun Editor?.selection() = this?.selectionModel?.selectedText +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnPsiReferenceContributor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnPsiReferenceContributor.kt new file mode 100644 index 0000000000..0f667f9b6e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnPsiReferenceContributor.kt @@ -0,0 +1,27 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.federation.psireferences + +import com.intellij.patterns.PsiElementPattern +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiLiteralValue +import com.intellij.psi.PsiReferenceContributor +import com.intellij.psi.PsiReferenceRegistrar +import com.intellij.util.ProcessingContext + +class ArnPsiReferenceContributor : PsiReferenceContributor() { + override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { + registrar.registerReferenceProvider( + object : PsiElementPattern.Capture( + PsiElement::class.java + ) { + override fun accepts(o: Any?, context: ProcessingContext): Boolean = + // this is lower fidelity than we'd like but avoids resolving the entire PSI tree + o is PsiLiteralValue && o.value is String + }, + ArnPsiReferenceProvider(), + PsiReferenceRegistrar.LOWER_PRIORITY + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnPsiReferenceProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnPsiReferenceProvider.kt new file mode 100644 index 0000000000..e43764a8bc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnPsiReferenceProvider.kt @@ -0,0 +1,45 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.federation.psireferences + +import com.intellij.openapi.util.TextRange +import com.intellij.psi.ElementManipulators +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiReference +import com.intellij.psi.PsiReferenceProvider +import com.intellij.util.ProcessingContext + +class ArnPsiReferenceProvider : PsiReferenceProvider() { + // these results should probably be cached with [CachedValuesManager] + override fun getReferencesByElement(element: PsiElement, context: ProcessingContext): Array { + val manipulator = ElementManipulators.getManipulator(element) + if (manipulator != null) { + val range = manipulator.getRangeInElement(element) + // TODO: we can definitely make this more robust + // assume this was a string that was quoted + if (range.length == element.textRange.length - 2) { + val substring = range.substring(element.text) + if (substring.startsWith("arn:")) { + // don't do anything fancy and just treat it as a full match + return arrayOf(ArnReference(element, range, substring)) + } + } + } + + val matches = ARN_REGEX.findAll(element.text) + return matches.map { + ArnReference( + element, + TextRange.from(it.range.start, it.value.length), + it.value + ) + }.toList().toTypedArray() + } + + companion object { + // partition service region account (optional) + // v v v v resource-type resource + val ARN_REGEX = "arn:aws[^/:]*:[^/:]*:[^:]*:[^/:]*:(?:[^:\\s\\/]*[:\\/])?(?:[^\\s'\\\"\\\\]*)".toRegex() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnReference.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnReference.kt new file mode 100644 index 0000000000..9e236e8deb --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnReference.kt @@ -0,0 +1,27 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.federation.psireferences + +import com.intellij.openapi.paths.WebReference +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement +import com.intellij.psi.SyntheticElement +import com.intellij.psi.impl.FakePsiElement +import software.aws.toolkits.jetbrains.ToolkitPlaces +import software.aws.toolkits.jetbrains.services.federation.AwsConsoleUrlFactory + +class ArnReference(element: PsiElement, textRange: TextRange, private val arn: String) : WebReference(element, textRange) { + inner class MyFakePsiElement : FakePsiElement(), SyntheticElement { + override fun getName() = arn + override fun getParent() = element + override fun getPresentableText() = arn + + override fun navigate(requestFocus: Boolean) { + val project = element.project + + AwsConsoleUrlFactory.openArnInConsole(project, ToolkitPlaces.EDITOR_PSI_REFERENCE, arn) + } + } + override fun resolve() = MyFakePsiElement() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnReferenceDocumentationProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnReferenceDocumentationProvider.kt new file mode 100644 index 0000000000..7044cc7c24 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/ArnReferenceDocumentationProvider.kt @@ -0,0 +1,18 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.federation.psireferences + +import com.intellij.lang.documentation.AbstractDocumentationProvider +import com.intellij.psi.PsiElement +import software.aws.toolkits.resources.message + +class ArnReferenceDocumentationProvider : AbstractDocumentationProvider() { + override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? { + if (element is ArnReference.MyFakePsiElement) { + return message("general.open_in_aws_console") + } + + return null + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/JsonArnPsiReferenceContributor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/JsonArnPsiReferenceContributor.kt new file mode 100644 index 0000000000..7e4576574f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/federation/psireferences/JsonArnPsiReferenceContributor.kt @@ -0,0 +1,19 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.federation.psireferences + +import com.intellij.json.psi.JsonStringLiteral +import com.intellij.patterns.PlatformPatterns +import com.intellij.psi.PsiReferenceContributor +import com.intellij.psi.PsiReferenceRegistrar + +class JsonArnPsiReferenceContributor : PsiReferenceContributor() { + override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { + registrar.registerReferenceProvider( + PlatformPatterns.psiElement(JsonStringLiteral::class.java), + ArnPsiReferenceProvider(), + PsiReferenceRegistrar.LOWER_PRIORITY + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateIamRoleDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateIamRoleDialog.kt index 991486558e..b1669f4632 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateIamRoleDialog.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateIamRoleDialog.kt @@ -12,29 +12,30 @@ import com.intellij.openapi.ui.ValidationInfo import org.intellij.lang.annotations.Language import org.jetbrains.annotations.TestOnly import software.amazon.awssdk.services.iam.IamClient +import software.amazon.awssdk.services.iam.model.Role import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.iam.Iam.createRoleWithPolicy import software.aws.toolkits.jetbrains.utils.ui.formatAndSet import software.aws.toolkits.resources.message import java.awt.Component import javax.swing.JComponent class CreateIamRoleDialog( - private val project: Project, + project: Project, private val iamClient: IamClient, - private val parent: Component? = null, + parent: Component? = null, @Language("JSON") defaultPolicyDocument: String, @Language("JSON") defaultAssumeRolePolicyDocument: String ) : DialogWrapper(project, parent, false, IdeModalityType.PROJECT) { private val view = CreateRolePanel(project) - var iamRole: IamRole? = null - private set + var iamRole: Role? = null init { title = message("iam.create.role.title") - setOKButtonText(message("iam.create.role.create")) + setOKButtonText(message("general.create_button")) view.policyDocument.formatAndSet(defaultPolicyDocument, JsonLanguage.INSTANCE) view.assumeRolePolicyDocument.formatAndSet(defaultAssumeRolePolicyDocument, JsonLanguage.INSTANCE) @@ -56,19 +57,22 @@ class CreateIamRoleDialog( override fun doOKAction() { if (okAction.isEnabled) { - setOKButtonText(message("iam.create.role.in_progress")) + setOKButtonText(message("general.create_in_progress")) isOKActionEnabled = false ApplicationManager.getApplication().executeOnPooledThread { try { - createIamRole(roleName(), policyDocument(), assumeRolePolicy()) - ApplicationManager.getApplication().invokeLater({ - close(OK_EXIT_CODE) - }, ModalityState.stateForComponent(view.component)) + createIamRole() + ApplicationManager.getApplication().invokeLater( + { + close(OK_EXIT_CODE) + }, + ModalityState.stateForComponent(view.component) + ) } catch (e: Exception) { LOG.warn(e) { "Failed to create IAM role '${roleName()}'" } setErrorText(e.message) - setOKButtonText(message("iam.create.role.create")) + setOKButtonText(message("general.create_button")) isOKActionEnabled = true } } @@ -81,35 +85,13 @@ class CreateIamRoleDialog( private fun assumeRolePolicy() = view.assumeRolePolicyDocument.text.trim() - private fun createIamRole(roleName: String, policy: String, assumeRolePolicy: String) { - val role = iamClient.createRole { - it.roleName(roleName) - it.assumeRolePolicyDocument(assumeRolePolicy) - }.role() - - try { - iamClient.putRolePolicy { - it.roleName(roleName) - .policyName(roleName) - .policyDocument(policy) - } - } catch (exception: Exception) { - try { - iamClient.deleteRole { - it.roleName(role.roleName()) - } - } catch (deleteException: Exception) { - LOG.warn(deleteException) { "Failed to delete IAM role $roleName" } - } - throw exception - } - - iamRole = IamRole(role.arn()) + private fun createIamRole() { + iamRole = iamClient.createRoleWithPolicy(roleName(), assumeRolePolicy(), policyDocument()) } @TestOnly internal fun createIamRoleForTesting() { - createIamRole(roleName(), policyDocument(), assumeRolePolicy()) + createIamRole() } @TestOnly diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateIamServiceRoleDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateIamServiceRoleDialog.kt new file mode 100644 index 0000000000..e40282dbbc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateIamServiceRoleDialog.kt @@ -0,0 +1,99 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.iam + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import software.amazon.awssdk.services.iam.IamClient +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.resources.message +import java.awt.Component +import javax.swing.JComponent +import kotlin.math.max + +class CreateIamServiceRoleDialog( + project: Project, + private val iamClient: IamClient, + private val serviceUri: String, + private val managedPolicyName: String, + name: String = "", + parent: Component? = null, +) : DialogWrapper(project, parent, false, IdeModalityType.PROJECT) { + private val coroutineScope = projectCoroutineScope(project) + var name: String = name + private set + internal val view = panel { + // make the width the widest string. Columns don't map entirely to text width (since text is variable width) but it looks better + val size = max(serviceUri.length, managedPolicyName.length) + row(message("iam.create.role.name.label")) { + textField().bindText(::name).columns(size).errorOnApply(message("iam.create.role.missing.role.name")) { it.text.isNullOrBlank() } + } + row(message("iam.create.role.managed_policies")) { + textField().bindText({ managedPolicyName }, {}).columns(size).apply { component.isEditable = false } + } + row(message("iam.create.role.trust.editor.name")) { + textField().bindText({ serviceUri }, {}).columns(size).apply { component.isEditable = false } + } + } + + init { + title = message("iam.create.role.title") + setOKButtonText(message("general.create_button")) + + init() + } + + override fun createCenterPanel(): JComponent = view + + override fun doOKAction() { + if (!okAction.isEnabled) { + return + } + setOKButtonText(message("general.create_in_progress")) + isOKActionEnabled = false + view.apply() + + coroutineScope.launch { + try { + createIamRole() + runBlocking(getCoroutineUiContext()) { + close(OK_EXIT_CODE) + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to create IAM role '$name'" } + setErrorText(e.message) + setOKButtonText(message("general.create_button")) + isOKActionEnabled = true + } + } + } + + internal fun createIamRole() { + val role = iamClient.createRole { it.roleName(name).assumeRolePolicyDocument(assumeRolePolicy(serviceUri)) }.role() + try { + iamClient.attachRolePolicy { it.roleName(role.roleName()).policyArn(managedPolicyNameToArn(managedPolicyName)) } + } catch (exception: Exception) { + try { + iamClient.deleteRole { + it.roleName(role.roleName()) + } + } catch (deleteException: Exception) { + LOG.warn(deleteException) { "Failed to delete IAM role ${role.roleName()}" } + } + throw exception + } + } + + private companion object { + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.form index fe2986b512..8f7e8c3e7a 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.form @@ -14,7 +14,7 @@ - +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.java deleted file mode 100644 index 4f8b8ef060..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.java +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.iam; - -import com.intellij.json.JsonLanguage; -import com.intellij.openapi.project.Project; -import com.intellij.ui.EditorTextField; -import com.intellij.ui.EditorTextFieldProvider; -import com.intellij.ui.IdeBorderFactory; -import com.intellij.util.ui.JBUI; - -import java.awt.Insets; -import java.util.Collections; -import javax.swing.JPanel; -import javax.swing.JTextField; - -import org.jetbrains.annotations.NotNull; -import software.aws.toolkits.resources.Localization; - -public class CreateRolePanel { - private final Project project; - - JTextField roleName; - EditorTextField policyDocument; - EditorTextField assumeRolePolicyDocument; - JPanel component; - - public CreateRolePanel(@NotNull Project project) { - this.project = project; - } - - private void createUIComponents() { - EditorTextFieldProvider textFieldProvider = EditorTextFieldProvider.getInstance(); - Insets insets = JBUI.emptyInsets(); - - policyDocument = textFieldProvider.getEditorField(JsonLanguage.INSTANCE, project, Collections.emptyList()); - policyDocument.setBorder(IdeBorderFactory.createTitledBorder(Localization.message("iam.create.role.policy.editor.name"), false, insets)); - - assumeRolePolicyDocument = textFieldProvider.getEditorField(JsonLanguage.INSTANCE, project, Collections.emptyList()); - assumeRolePolicyDocument.setBorder(IdeBorderFactory.createTitledBorder(Localization.message("iam.create.role.trust.editor.name"), false, insets)); - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.kt new file mode 100644 index 0000000000..9e6c62794c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/CreateRolePanel.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.services.iam + +import com.intellij.json.JsonLanguage +import com.intellij.openapi.project.Project +import com.intellij.ui.EditorTextField +import com.intellij.ui.EditorTextFieldProvider +import com.intellij.ui.IdeBorderFactory +import com.intellij.util.ui.JBUI +import software.aws.toolkits.resources.message +import javax.swing.JPanel +import javax.swing.JTextField + +class CreateRolePanel(private val project: Project) { + lateinit var component: JPanel + private set + lateinit var roleName: JTextField + private set + lateinit var policyDocument: EditorTextField + private set + lateinit var assumeRolePolicyDocument: EditorTextField + private set + + private fun createUIComponents() { + val textFieldProvider = EditorTextFieldProvider.getInstance() + val insets = JBUI.emptyInsets() + policyDocument = textFieldProvider.getEditorField(JsonLanguage.INSTANCE, project, emptyList()) + policyDocument.border = IdeBorderFactory.createTitledBorder(message("iam.create.role.policy.editor.name"), false, insets) + assumeRolePolicyDocument = textFieldProvider.getEditorField(JsonLanguage.INSTANCE, project, emptyList()) + assumeRolePolicyDocument.border = IdeBorderFactory.createTitledBorder(message("iam.create.role.trust.editor.name"), false, insets) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/Iam.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/Iam.kt index 6d22174eb0..dee7d86494 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/Iam.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/iam/Iam.kt @@ -3,9 +3,15 @@ package software.aws.toolkits.jetbrains.services.iam +import org.intellij.lang.annotations.Language import software.amazon.awssdk.services.iam.IamClient +import software.amazon.awssdk.services.iam.model.Role +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource import software.aws.toolkits.jetbrains.core.Resource +import software.aws.toolkits.jetbrains.core.filter +import software.aws.toolkits.jetbrains.core.map import software.aws.toolkits.jetbrains.services.lambda.upload.LAMBDA_PRINCIPAL data class IamRole(val arn: String) { @@ -22,19 +28,70 @@ data class IamRole(val arn: String) { object IamResources { - private val LIST_RAW_ROLES = ClientBackedCachedResource(IamClient::class, "iam.list_roles") { + val LIST_RAW_ROLES = ClientBackedCachedResource(IamClient::class, "iam.list_roles") { listRolesPaginator().roles().toList() } @JvmField - val LIST_ALL: Resource> = Resource.View(LIST_RAW_ROLES) { + val LIST_ALL: Resource> = Resource.view(LIST_RAW_ROLES) { map { IamRole(it.arn()) }.toList() } @JvmField - val LIST_LAMBDA_ROLES: Resource> = Resource.View(LIST_RAW_ROLES) { + val LIST_LAMBDA_ROLES: Resource> = Resource.view(LIST_RAW_ROLES) { filter { it.assumeRolePolicyDocument().contains(LAMBDA_PRINCIPAL) } .map { IamRole(it.arn()) } .toList() } } + +fun managedPolicyNameToArn(policyName: String) = "arn:aws:iam::aws:policy/$policyName" + +@Language("JSON") +fun assumeRolePolicy(servicePrincipal: String) = + """ + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "$servicePrincipal" + }, + "Action": "sts:AssumeRole" + } + ] + } + """.trimIndent() + +object Iam { + private val LOG = getLogger() + + fun IamClient.createRoleWithPolicy(roleName: String, assumeRolePolicy: String, policy: String? = null): Role { + val role = this.createRole { + it.roleName(roleName) + it.assumeRolePolicyDocument(assumeRolePolicy) + }.role() + + policy?.let { + try { + this.putRolePolicy { + it.roleName(roleName) + .policyName(roleName) + .policyDocument(policy) + } + } catch (exception: Exception) { + try { + this.deleteRole { + it.roleName(role.roleName()) + } + } catch (deleteException: Exception) { + LOG.warn(deleteException) { "Failed to delete IAM role $roleName" } + } + throw exception + } + } + + return role + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/Lambda.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/Lambda.kt index ed75596996..2875614724 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/Lambda.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/Lambda.kt @@ -12,10 +12,11 @@ import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.NavigatablePsiElement import com.intellij.psi.search.GlobalSearchScope -import software.amazon.awssdk.services.lambda.model.CreateFunctionResponse import software.amazon.awssdk.services.lambda.model.FunctionConfiguration +import software.amazon.awssdk.services.lambda.model.PackageType import software.amazon.awssdk.services.lambda.model.Runtime import software.amazon.awssdk.services.lambda.model.TracingMode +import software.aws.toolkits.core.lambda.LambdaRuntime import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.services.iam.IamRole @@ -28,64 +29,51 @@ import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.MIN_MEMORY import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.MIN_TIMEOUT import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon import software.aws.toolkits.jetbrains.ui.SliderPanel -import java.util.concurrent.TimeUnit object Lambda { private val LOG = getLogger() + fun findPsiElementsForHandler(project: Project, runtime: LambdaRuntime, handler: String): Array { + runtime.toSdkRuntime()?.let { return findPsiElementsForHandler(project, it, handler) } ?: return emptyArray() + } + fun findPsiElementsForHandler(project: Project, runtime: Runtime, handler: String): Array { - val resolver = runtime.runtimeGroup?.let { LambdaHandlerResolver.getInstance(it) } ?: return emptyArray() + val resolver = runtime.runtimeGroup?.let { LambdaHandlerResolver.getInstanceOrNull(it) } ?: return emptyArray() // Don't search through ".aws-sam" folders val samBuildFileScopes = GlobalSearchScope.filesScope(project, findSamBuildContents(project)) val excludeSamBuildFileScopes = GlobalSearchScope.notScope(samBuildFileScopes) - val scope = GlobalSearchScope.allScope(project).intersectWith(excludeSamBuildFileScopes) + // only search within project content roots + val scope = GlobalSearchScope.projectScope(project).intersectWith(excludeSamBuildFileScopes) val elements = resolver.findPsiElements(project, handler, scope) - logHandlerPsiElements(handler, elements) + LOG.debug { + elements.joinToString( + prefix = "Found ${elements.size} PsiElements for Handler: $handler\n", + separator = "\n" + ) { it.containingFile.virtualFile.path } + } return elements } fun isHandlerValid(project: Project, runtime: Runtime, handler: String): Boolean = ReadAction.compute { runtime.runtimeGroup?.let { - LambdaHandlerResolver.getInstance(it) + LambdaHandlerResolver.getInstanceOrNull(it) }?.isHandlerValid(project, handler) == true } + @Suppress("MissingRecentApi") private fun findSamBuildContents(project: Project): Collection = ModuleManager.getInstance(project).modules.flatMap { findSamBuildContents(it) } private fun findSamBuildContents(module: Module): Collection = - ModuleRootManager.getInstance(module).contentRoots.map { + ModuleRootManager.getInstance(module).contentRoots.mapNotNull { it.findChild(SamCommon.SAM_BUILD_DIR) - }.filterNotNull() - .flatMap { - VfsUtil.collectChildrenRecursively(it) - } - - private fun logHandlerPsiElements(handler: String, elements: Array) { - LOG.debug { - elements.joinToString( - prefix = "Found ${elements.size} PsiElements for Handler: $handler\n", - separator = "\n" - ) { it.containingFile.virtualFile.path } + }.flatMap { + VfsUtil.collectChildrenRecursively(it) } - } -} - -// @see https://docs.aws.amazon.com/lambda/latest/dg/limits.html -object LambdaLimits { - const val MIN_MEMORY = 128 - const val MAX_MEMORY = 3008 - const val MEMORY_INCREMENT = 64 - const val DEFAULT_MEMORY_SIZE = 128 - const val MIN_TIMEOUT = 1 - @JvmField - val MAX_TIMEOUT = TimeUnit.MINUTES.toSeconds(15).toInt() - @JvmField - val DEFAULT_TIMEOUT = TimeUnit.MINUTES.toSeconds(5).toInt() } object LambdaWidgets { @@ -95,16 +83,17 @@ object LambdaWidgets { @JvmStatic fun lambdaMemory(): SliderPanel = - SliderPanel(MIN_MEMORY, MAX_MEMORY, DEFAULT_MEMORY_SIZE, MIN_MEMORY, MAX_MEMORY, MEMORY_INCREMENT, MEMORY_INCREMENT * 5, true) + SliderPanel(MIN_MEMORY, MAX_MEMORY, DEFAULT_MEMORY_SIZE, MIN_MEMORY, MAX_MEMORY, MEMORY_INCREMENT, MEMORY_INCREMENT * 15, true) } data class LambdaFunction( val name: String, val description: String?, val arn: String, + val packageType: PackageType, val lastModified: String, - val handler: String, - val runtime: Runtime, + val handler: String?, + val runtime: Runtime?, val envVariables: Map?, val timeout: Int, val memorySize: Int, @@ -115,21 +104,7 @@ data class LambdaFunction( fun FunctionConfiguration.toDataClass() = LambdaFunction( name = this.functionName(), description = this.description(), - arn = this.functionArn(), - lastModified = this.lastModified(), - handler = this.handler(), - runtime = this.runtime(), - envVariables = this.environment()?.variables(), - timeout = this.timeout(), - memorySize = this.memorySize(), - // TODO: make non-nullable when available in all partitions - xrayEnabled = this.tracingConfig()?.mode() == TracingMode.ACTIVE, - role = IamRole(this.role()) -) - -fun CreateFunctionResponse.toDataClass() = LambdaFunction( - name = this.functionName(), - description = this.description(), + packageType = this.packageType() ?: PackageType.ZIP, arn = this.functionArn(), lastModified = this.lastModified(), handler = this.handler(), 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 ab89307ca2..69df54e3cf 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,32 +3,20 @@ package software.aws.toolkits.jetbrains.services.lambda -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.configurations.RuntimeConfigurationError -import com.intellij.execution.process.CapturingProcessRunner -import com.intellij.execution.process.ColoredProcessHandler -import com.intellij.execution.process.ProcessHandler -import com.intellij.openapi.application.ReadAction import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.project.Project import com.intellij.openapi.project.rootManager -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.jetbrains.core.executables.ExecutableInstance -import software.aws.toolkits.jetbrains.core.executables.ExecutableManager -import software.aws.toolkits.jetbrains.core.executables.getExecutable +import com.intellij.psi.PsiFile +import software.aws.toolkits.jetbrains.core.utils.buildList import software.aws.toolkits.jetbrains.services.PathMapping -import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.DEFAULT_MEMORY_SIZE -import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.DEFAULT_TIMEOUT -import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.HandlerRunSettings +import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import software.aws.toolkits.jetbrains.services.lambda.steps.BuildLambdaRequest import software.aws.toolkits.resources.message import java.nio.file.Path import java.nio.file.Paths @@ -37,199 +25,81 @@ abstract class LambdaBuilder { /** * Returns the base directory of the Lambda handler + * + * @throws IllegalStateException if we cant determine a valid base directory for the handler element */ - abstract fun baseDirectory(module: Module, handlerElement: PsiElement): String - - /** - * Creates a package for the given lambda including source files archived in the correct format. - */ - fun buildLambda( - module: Module, - handlerElement: PsiElement, - handler: String, - runtime: Runtime, - timeout: Int, - memorySize: Int, - envVars: Map, - samOptions: SamOptions, - onStart: (ProcessHandler) -> Unit = {} - ): BuiltLambda { - val baseDir = baseDirectory(module, handlerElement) - - val customTemplate = getBuildDirectory(module).resolve("template.yaml") - - val logicalId = "Function" - SamTemplateUtils.writeDummySamTemplate(customTemplate, logicalId, runtime, baseDir, handler, timeout, memorySize, envVars) - - return buildLambdaFromTemplate(module, customTemplate, logicalId, samOptions, onStart) - } - - 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 functions = SamTemplateUtils.findFunctionsFromTemplate( - module.project, - templateLocation.toFile() + abstract fun handlerBaseDirectory(module: Module, handlerElement: PsiElement): Path + + open fun handlerForDummyTemplate(settings: HandlerRunSettings, handlerElement: PsiElement): String = settings.handler + + open fun buildFromHandler(project: Project, settings: HandlerRunSettings): BuildLambdaRequest { + val dummyLogicalId = "Function" + val samOptions = settings.samOptions + val runtime = settings.runtime + val handler = settings.handler + + val element = Lambda.findPsiElementsForHandler(project, runtime, handler).first() + val module = getModule(element.containingFile) + + val buildDirectory = getBuildDirectory(module) + val dummyTemplate = buildDirectory.parent.resolve("temp-template.yaml") + + SamTemplateUtils.writeDummySamTemplate( + tempFile = dummyTemplate, + logicalId = dummyLogicalId, + runtime = runtime.toSdkRuntime() ?: throw IllegalStateException("Cannot map runtime $runtime to SDK runtime."), + architecture = settings.architecture.toSdkArchitecture(), + handler = handlerForDummyTemplate(settings, element), + timeout = settings.timeout, + memorySize = settings.memorySize, + codeUri = handlerBaseDirectory(module, element).toAbsolutePath().toString(), + envVars = settings.environmentVariables ) - val codeLocation = ReadAction.compute { - functions.find { it.logicalName == logicalId } - ?.codeLocation() - ?: throw RuntimeConfigurationError( - message( - "lambda.run_configuration.sam.no_such_function", - logicalId, - templateLocation - ) - ) - } - - val buildDir = getBuildDirectory(module) - - 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 = ColoredProcessHandler(commandLine) - - onStart.invoke(processHandler) - - val processOutput = CapturingProcessRunner(processHandler).runProcess() - if (processOutput.exitCode != 0) { - throw IllegalStateException(message("sam.build.failed")) - } - - 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) - } - } - - fun packageLambda( - module: Module, - handlerElement: PsiElement, - handler: String, - runtime: Runtime, - samOptions: SamOptions, - onStart: (ProcessHandler) -> Unit = {} - ): Path { - val builtLambda = buildLambda(module, handlerElement, handler, runtime, DEFAULT_TIMEOUT, DEFAULT_MEMORY_SIZE, emptyMap(), samOptions, onStart) - val zipLocation = FileUtil.createTempFile("builtLambda", "zip", true) - Compressor.Zip(zipLocation).use { - it.addDirectory(builtLambda.codeLocation.toFile()) - } - return zipLocation.toPath() + return BuildLambdaRequest( + dummyTemplate, + dummyLogicalId, + buildDirectory, + additionalBuildEnvironmentVariables(project, module, samOptions), + samOptions + ) } /** * Returns the build directory of the project. Create this if it doesn't exist yet. */ - protected open fun getBuildDirectory(module: Module): Path { + open fun getBuildDirectory(module: Module): Path { val contentRoot = module.rootManager.contentRoots.firstOrNull() ?: throw IllegalStateException(message("lambda.build.module_with_no_content_root", module.name)) - return Paths.get(contentRoot.path, ".aws-sam", "build") + return Paths.get(contentRoot.path, SamCommon.SAM_BUILD_DIR, "build") } - protected open fun additionalEnvironmentVariables(module: Module, samOptions: SamOptions): Map = emptyMap() - - companion object : RuntimeGroupExtensionPointObject(ExtensionPointName("aws.toolkit.lambda.builder")) -} - -/** - * Represents the result of building a Lambda - * - * @param templateLocation The path to the build generated template - * @param codeLocation The path to the built lambda directory - * @param mappings Source mappings from original codeLocation to the path inside of the archive - */ -data class BuiltLambda( - val templateLocation: Path, - val codeLocation: Path, - val mappings: List = emptyList() -) - -// TODO Use these in this class -sealed class BuildLambdaRequest + /** + * Returns a set of default path mappings for the specified built function + * + * @param sourceTemplate The original template file + * @param logicalId The logical ID of the function + * @param buildDir The root directory where SAM built the function into + */ + open fun defaultPathMappings(sourceTemplate: Path, logicalId: String, buildDir: Path): List = buildList { + val codeLocation = SamTemplateUtils.getCodeLocation(sourceTemplate, logicalId) + // First one wins, so code needs to go before build + add(PathMapping(sourceTemplate.resolveSibling(codeLocation).normalize().toString(), TASK_PATH)) + add(PathMapping(buildDir.resolve(logicalId).normalize().toString(), TASK_PATH)) + } -data class BuildLambdaFromTemplate( - val templateLocation: Path, - val logicalId: String, - val samOptions: SamOptions -) : BuildLambdaRequest() + /** + * Returns a set of additional environment variables that should be passed to SAM build + */ + open fun additionalBuildEnvironmentVariables(project: Project, module: Module?, samOptions: SamOptions): Map = emptyMap() -data class BuildLambdaFromHandler( - val handlerElement: PsiElement, - val handler: String, - val runtime: Runtime, - val timeout: Int, - val memorySize: Int, - val envVars: Map, - val samOptions: SamOptions -) : BuildLambdaRequest() + companion object : RuntimeGroupExtensionPointObject(ExtensionPointName("aws.toolkit.lambda.builder")) { + /* + * The default path to the task. The default is consistent across both Zip and Image based functions. + */ + const val TASK_PATH = "/var/task" -data class PackageLambdaFromHandler( - val handlerElement: PsiElement, - val handler: String, - val runtime: Runtime, - val samOptions: SamOptions -) + fun getModule(psiFile: PsiFile): Module = ModuleUtil.findModuleForFile(psiFile) + ?: throw IllegalStateException("Failed to locate module for ${psiFile.virtualFile}") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaBuilderUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaBuilderUtils.kt deleted file mode 100644 index 63bf51b42a..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaBuilderUtils.kt +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda - -import com.intellij.build.BuildProgressListener -import com.intellij.build.BuildViewManager -import com.intellij.build.DefaultBuildDescriptor -import com.intellij.build.events.impl.FailureResultImpl -import com.intellij.build.events.impl.FinishBuildEventImpl -import com.intellij.build.events.impl.OutputBuildEventImpl -import com.intellij.build.events.impl.StartBuildEventImpl -import com.intellij.build.events.impl.SuccessResultImpl -import com.intellij.execution.process.ProcessAdapter -import com.intellij.execution.process.ProcessEvent -import com.intellij.execution.process.ProcessListener -import com.intellij.execution.process.ProcessOutputTypes -import com.intellij.openapi.components.ServiceManager -import com.intellij.openapi.module.Module -import com.intellij.openapi.progress.PerformInBackgroundOption -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task -import com.intellij.openapi.roots.ModuleRootManager -import com.intellij.openapi.util.Key -import software.aws.toolkits.resources.message -import java.nio.file.Path -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CompletionStage - -object LambdaBuilderUtils { - fun buildAndReport( - module: Module, - runtimeGroup: RuntimeGroup, - request: BuildLambdaRequest, - lambdaBuilder: LambdaBuilder = LambdaBuilder.getInstanceOrThrow(runtimeGroup) - ): CompletionStage { - val buildViewManager = ServiceManager.getService(module.project, BuildViewManager::class.java) - - return runSamBuildInBackground(buildViewManager, module, request) { - runSamBuild( - lambdaBuilder, - module, - request, - BuildProcessListener(request, buildViewManager) - ) - } - } - - fun packageAndReport( - module: Module, - runtimeGroup: RuntimeGroup, - request: PackageLambdaFromHandler, - lambdaBuilder: LambdaBuilder = LambdaBuilder.getInstanceOrThrow(runtimeGroup) - ): CompletionStage { - val buildViewManager = ServiceManager.getService(module.project, BuildViewManager::class.java) - - return runSamBuildInBackground(buildViewManager, module, request) { - lambdaBuilder.packageLambda( - module, - request.handlerElement, - request.handler, - request.runtime, - request.samOptions - ) { it.addProcessListener(BuildProcessListener(request, buildViewManager)) } - } - } - - private inline fun runSamBuildInBackground( - buildViewManager: BuildViewManager, - module: Module, - request: Any, - crossinline task: () -> T - ): CompletionStage { - val future = CompletableFuture() - - try { - val project = module.project - - val workingDir = ModuleRootManager.getInstance(module).contentRoots.getOrNull(0)?.path ?: "" - val descriptor = DefaultBuildDescriptor( - request, - message("sam.build.title"), - workingDir, - System.currentTimeMillis() - ) - - buildViewManager.onEvent(request, StartBuildEventImpl(descriptor, message("sam.build.title"))) - - // TODO: Make cancellable - ProgressManager.getInstance().run( - object : Task.Backgroundable( - project, - message("sam.build.running"), - false, - PerformInBackgroundOption.ALWAYS_BACKGROUND - ) { - // This call needs to block so the progress bar is alive the entire time - override fun run(indicator: ProgressIndicator) { - try { - future.complete(task.invoke()) - } catch (e: Throwable) { - future.completeExceptionally(e) - } - } - } - ) - } catch (e: Exception) { - future.completeExceptionally(e) - } - - return future - } - - private fun runSamBuild( - lambdaBuilder: LambdaBuilder, - module: Module, - request: BuildLambdaRequest, - processListener: ProcessListener - ): BuiltLambda = when (request) { - is BuildLambdaFromTemplate -> { - lambdaBuilder.buildLambdaFromTemplate( - module, - request.templateLocation, - request.logicalId, - request.samOptions - ) { it.addProcessListener(processListener) } - } - is BuildLambdaFromHandler -> { - lambdaBuilder.buildLambda( - module, - request.handlerElement, - request.handler, - request.runtime, - request.timeout, - request.memorySize, - request.envVars, - request.samOptions - ) { it.addProcessListener(processListener) } - } - } - - private class BuildProcessListener( - private val request: Any, - private val progressListener: BuildProgressListener - ) : ProcessAdapter() { - - override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { - val stdError = outputType == ProcessOutputTypes.STDERR - progressListener.onEvent(request, OutputBuildEventImpl(request, event.text, !stdError)) - } - - override fun processTerminated(event: ProcessEvent) { - val buildEvent = if (event.exitCode == 0) { - FinishBuildEventImpl( - request, - null, - System.currentTimeMillis(), - message("sam.build.succeeded"), - SuccessResultImpl() - ) - } else { - FinishBuildEventImpl( - request, - null, - System.currentTimeMillis(), - message("sam.build.failed"), - FailureResultImpl(IllegalStateException(message("sam.build.failed"))) - ) - } - - progressListener.onEvent(request, buildEvent) - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaExplorerNodes.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaExplorerNodes.kt index e4297ac2d9..57e799f816 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaExplorerNodes.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaExplorerNodes.kt @@ -15,9 +15,11 @@ import software.aws.toolkits.jetbrains.core.explorer.nodes.CacheBackedAwsExplore import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceLocationNode import software.aws.toolkits.jetbrains.services.lambda.execution.remote.RemoteLambdaLocation import software.aws.toolkits.jetbrains.services.lambda.resources.LambdaResources +import software.aws.toolkits.resources.message class LambdaServiceNode(project: Project, service: AwsExplorerServiceNode) : CacheBackedAwsExplorerServiceRootNode(project, service, LambdaResources.LIST_FUNCTIONS) { + override fun displayName(): String = message("explorer.node.lambda") override fun toNode(child: FunctionConfiguration): AwsExplorerNode<*> = LambdaFunctionNode(nodeProject, child.toDataClass()) } @@ -29,7 +31,8 @@ open class LambdaFunctionNode( LambdaClient.SERVICE_NAME, function, AwsIcons.Resources.LAMBDA_FUNCTION -), ResourceLocationNode { +), + ResourceLocationNode { override fun resourceType() = "function" @@ -43,6 +46,9 @@ open class LambdaFunctionNode( fun functionName(): String = value.name - fun handlerPsi(): Array = - Lambda.findPsiElementsForHandler(nodeProject, value.runtime, value.handler) + fun handlerPsi(): Array { + val runtime = value.runtime ?: return emptyArray() + val handler = value.handler ?: return emptyArray() + return Lambda.findPsiElementsForHandler(nodeProject, runtime, handler) + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaHandlerResolver.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaHandlerResolver.kt index 32b32009d4..3bfb6917e9 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaHandlerResolver.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaHandlerResolver.kt @@ -16,11 +16,6 @@ import software.amazon.awssdk.services.lambda.model.Runtime * Used to expose Lambda handler information for different [Language]s / [Runtime]s */ interface LambdaHandlerResolver { - /** - * The version of this indexer. It should be incremented with the indexing logic has been modified - */ - fun version(): Int - /** * Converts the handler string into PSI elements that represent it. I.e. if the Handler points to a file, return the * class, or if a method return the method. diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaLimits.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaLimits.kt new file mode 100644 index 0000000000..806fa18fc9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaLimits.kt @@ -0,0 +1,23 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda + +import java.util.concurrent.TimeUnit + +// @see https://docs.aws.amazon.com/lambda/latest/dg/limits.html +object LambdaLimits { + const val MIN_MEMORY = 128 + const val MAX_MEMORY = 10240 + const val MAX_FUNCTION_NAME_LENGTH = 64 + val FUNCTION_NAME_PATTERN = "[a-zA-Z0-9-_]+".toRegex() + const val MEMORY_INCREMENT = 64 + const val DEFAULT_MEMORY_SIZE = 128 + const val MIN_TIMEOUT = 1 + + @JvmField + val MAX_TIMEOUT = TimeUnit.MINUTES.toSeconds(15).toInt() + + @JvmField + val DEFAULT_TIMEOUT = TimeUnit.MINUTES.toSeconds(5).toInt() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaUtils.kt new file mode 100644 index 0000000000..293e38ba7c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/LambdaUtils.kt @@ -0,0 +1,30 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda + +import com.intellij.util.text.SemVer +import software.amazon.awssdk.services.lambda.LambdaClient +import software.aws.toolkits.core.lambda.LambdaArchitecture +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable +import software.aws.toolkits.jetbrains.utils.assertIsNonDispatchThread + +fun LambdaRuntime.minSamDebuggingVersion(): SemVer = + minSamDebugging?.let { SemVer.parseFromText(it) ?: throw IllegalStateException("$this has bad minSamDebuggingVersion! It should be a semver string!") } + ?: SamExecutable.minVersion + +fun LambdaRuntime.minSamInitVersion(): SemVer = + minSamInit?.let { SemVer.parseFromText(it) ?: throw IllegalStateException("$this has bad minSamInitVersion! It should be a semver string!") } + ?: SamExecutable.minVersion + +fun LambdaArchitecture.minSamVersion(): SemVer = + minSam?.let { SemVer.parseFromText(it) ?: throw IllegalStateException("$this has bad minSamInitVersion! It should be a semver string!") } + ?: SamExecutable.minVersion + +fun LambdaClient.waitForUpdatableState(functionName: String) { + assertIsNonDispatchThread() + // wait until function is both active and not being updated + waiter().waitUntilFunctionActive { it.functionName(functionName) } + waiter().waitUntilFunctionUpdated { it.functionName(functionName) } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/RuntimeGroup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/RuntimeGroup.kt index de580db9c0..71059f2750 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/RuntimeGroup.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/RuntimeGroup.kt @@ -7,7 +7,6 @@ package software.aws.toolkits.jetbrains.services.lambda import com.intellij.lang.Language import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.LangDataKeys -import com.intellij.openapi.extensions.AbstractExtensionPointBean import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.module.Module import com.intellij.openapi.module.ModuleType @@ -17,113 +16,86 @@ import com.intellij.openapi.projectRoots.SdkType import com.intellij.openapi.roots.ModuleRootManager import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.util.KeyedExtensionCollector -import com.intellij.openapi.util.LazyInstance -import com.intellij.util.KeyedLazyInstance -import com.intellij.util.xmlb.annotations.Attribute +import com.intellij.util.text.SemVer import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.core.IdBasedExtensionPoint +import software.aws.toolkits.resources.message + +/** + * IDs for built-in runtime groups that ship with toolkit + */ +object BuiltInRuntimeGroups { + const val Python = "PYTHON" + const val Dotnet = "DOTNET" + const val Java = "JAVA" + const val NodeJs = "NODEJS" + const val Go = "GO" +} /** * Grouping of Lambda [Runtime] by parent language. * - * A Lambda [Runtime] belongs to a single [RuntimeGroup], a [RuntimeGroup] may have several - * Lambda [Runtime]s, [Language]s or [Sdk]s. + * A Lambda [Runtime] belongs to a single [RuntimeGroup], a [RuntimeGroup] may have several Lambda [Runtime]s, [Language]s or [Sdk]s. */ -enum class RuntimeGroup { - JAVA, - PYTHON, - NODEJS, - DOTNET; - - private val info by lazy { - RuntimeGroupInformation.getInstances(this) +abstract class RuntimeGroup { + abstract val id: String + abstract val languageIds: Set + abstract val supportsPathMappings: Boolean + + val supportedSdkRuntimes: List by lazy { + supportedRuntimes.mapNotNull { it.toSdkRuntime() } } - val runtimes: Set by lazy { info.flatMap { it.runtimes }.toSet() } - val languageIds: Set by lazy { info.flatMap { it.languageIds }.toSet() } + abstract val supportedRuntimes: List + + open fun determineRuntime(project: Project): LambdaRuntime? = null + open fun determineRuntime(module: Module): LambdaRuntime? = null + open fun getModuleType(): ModuleType<*>? = null + open fun getIdeSdkType(): SdkType? = null - fun determineRuntime(project: Project): Runtime? = info.asSequence().mapNotNull { it.determineRuntime(project) }.firstOrNull() - fun determineRuntime(module: Module): Runtime? = info.asSequence().mapNotNull { it.determineRuntime(module) }.firstOrNull() - fun getModuleType(): ModuleType<*>? = info.asSequence().mapNotNull { it.getModuleType() }.firstOrNull() - fun getIdeSdkType(): SdkType? = info.asSequence().mapNotNull { it.getIdeSdkType() }.firstOrNull() - fun supportsSamBuild(): Boolean = info.asSequence().all { it.supportsSamBuild() } + // This only works with Zip and is only called on that path since image is based on what debugger EPs we have + fun validateSamVersionForZipDebugging(runtime: LambdaRuntime, samVersion: SemVer) { + val minVersion = supportedRuntimes.first { it == runtime }.minSamDebuggingVersion() + if (samVersion < minVersion) { + throw RuntimeException(message("sam.executable.minimum_too_low_runtime", runtime, minVersion)) + } + } + + companion object { + private val EP_NAM = ExtensionPointName.create("aws.toolkit.lambda.runtimeGroup") - internal companion object { - /** - * Lazily apply the predicate to each [RuntimeGroup] and return the first match (or null) - */ @JvmStatic - fun find(predicate: (RuntimeGroup) -> Boolean): RuntimeGroup? = RuntimeGroup.values().asSequence().filter(predicate).firstOrNull() + fun find(predicate: (RuntimeGroup) -> Boolean): RuntimeGroup? = registeredRuntimeGroups().firstOrNull(predicate) + + fun getById(id: String?): RuntimeGroup = id?.let { find { it.id == id } } ?: throw IllegalStateException("No RuntimeGroup with id '$id' is registered") - fun determineRuntime(project: Project?): Runtime? = project?.let { _ -> - values().asSequence().mapNotNull { it.determineRuntime(project) }.firstOrNull() + fun determineRuntime(project: Project?): LambdaRuntime? = project?.let { _ -> + registeredRuntimeGroups().asSequence().mapNotNull { it.determineRuntime(project) }.firstOrNull() } - fun determineRuntime(module: Module?): Runtime? = module?.let { _ -> - values().asSequence().mapNotNull { it.determineRuntime(module) }.firstOrNull() + fun determineRuntime(module: Module?): LambdaRuntime? = module?.let { _ -> + registeredRuntimeGroups().asSequence().mapNotNull { it.determineRuntime(module) }.firstOrNull() } fun determineRuntimeGroup(project: Project?): RuntimeGroup? = project?.let { _ -> - values().asSequence().find { it.determineRuntime(project) != null } + registeredRuntimeGroups().find { it.determineRuntime(project) != null } } - } -} -/** - * Represents information about a specific [Runtime] or [RuntimeGroup]. A single [RuntimeGroup] can have more than one RuntimeGroupInformation - * registered. - */ -interface RuntimeGroupInformation { - val runtimes: Set - val languageIds: Set - - /** - * Attempt to determine the runtime from the [project] level scope. - */ - fun determineRuntime(project: Project): Runtime? - - /** - * Attempt to determine the runtime from the [module] level scope. - * Do not fall back to [Project] level scope; logic controlling fallback to [Project] scope should be done at the call-site. - */ - fun determineRuntime(module: Module): Runtime? - - /** - * The IDE module type that should be associated with this runtime group - */ - fun getModuleType(): ModuleType<*>? - - /** - * The IDE SDK type that this runtime group supports - */ - fun getIdeSdkType(): SdkType? - - /** - * Whether this runtime group supports SAM build so that SAM template with runtimes of this type could be deployed to AWS. - */ - fun supportsSamBuild(): Boolean - - companion object : RuntimeGroupExtensionPointObject(ExtensionPointName("aws.toolkit.lambda.runtimeGroup")) { - fun getInstances(runtimeGroup: RuntimeGroup): List = collector.forKey(runtimeGroup) + fun registeredRuntimeGroups(): List = EP_NAM.extensionList } } -abstract class SdkBasedRuntimeGroupInformation : RuntimeGroupInformation { - protected abstract fun runtimeForSdk(sdk: Sdk): Runtime? - - override fun determineRuntime(project: Project): Runtime? = ProjectRootManager.getInstance(project).projectSdk?.let { runtimeForSdk(it) } +abstract class SdkBasedRuntimeGroup : RuntimeGroup() { + protected abstract fun runtimeForSdk(sdk: Sdk): LambdaRuntime? - override fun determineRuntime(module: Module): Runtime? = ModuleRootManager.getInstance(module).sdk?.let { runtimeForSdk(it) } + override fun determineRuntime(project: Project): LambdaRuntime? = ProjectRootManager.getInstance(project).projectSdk?.let { runtimeForSdk(it) } - override fun getModuleType(): ModuleType<*>? = null - - override fun getIdeSdkType(): SdkType? = null - - override fun supportsSamBuild(): Boolean = false + override fun determineRuntime(module: Module): LambdaRuntime? = ModuleRootManager.getInstance(module).sdk?.let { runtimeForSdk(it) } } -val Runtime?.validOrNull: Runtime? get() = this?.takeUnless { it == Runtime.UNKNOWN_TO_SDK_VERSION } - -val Runtime.runtimeGroup: RuntimeGroup? get() = RuntimeGroup.find { this in it.runtimes } +val Runtime.runtimeGroup: RuntimeGroup? get() = RuntimeGroup.find { this in it.supportedSdkRuntimes } +val LambdaRuntime.runtimeGroup: RuntimeGroup? get() = RuntimeGroup.find { this in it.supportedRuntimes } /** * For a given [com.intellij.lang.Language] determine the corresponding Lambda [RuntimeGroup] @@ -133,44 +105,30 @@ val Language.runtimeGroup: RuntimeGroup? get() = RuntimeGroup.find { this.id in /** * Given [AnActionEvent] attempt to determine the [Runtime] */ -fun AnActionEvent.runtime(): Runtime? { +fun AnActionEvent.runtime(): LambdaRuntime? { val runtimeGroup = getData(LangDataKeys.LANGUAGE)?.runtimeGroup ?: return null return getData(LangDataKeys.MODULE)?.let { runtimeGroup.determineRuntime(it) } ?: getData(LangDataKeys.PROJECT)?.let { runtimeGroup.determineRuntime(it) } } /** - * A bean that represents an extension point based on a [RuntimeGroup] + * To be implemented on a companion object of the extension point object to expose factory methods. + * See [software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder] */ -class RuntimeGroupExtensionPoint : AbstractExtensionPointBean(), KeyedLazyInstance { - - @Attribute("implementation") - lateinit var implementation: String +abstract class RuntimeGroupExtensionPointObject(val extensionPointName: ExtensionPointName>) { + private val collector = KeyedExtensionCollector(extensionPointName.name) - /** - * The [RuntimeGroup] that this extension point refers to - */ - @Attribute("runtimeGroup") - lateinit var runtimeGroup: RuntimeGroup + fun getInstanceOrNull(runtimeGroup: RuntimeGroup): T? = collector.findSingle(runtimeGroup.id) + fun getInstance(runtimeGroup: RuntimeGroup): T = getInstanceOrNull(runtimeGroup) + ?: throw IllegalStateException("Attempted to retrieve feature for unsupported runtime group $runtimeGroup") - private val instance = object : LazyInstance() { - override fun getInstanceClass(): Class = findClass(implementation) + fun supportedRuntimeGroups(): Set { + val alRuntimeGroups = RuntimeGroup.registeredRuntimeGroups() + val supportedIds = extensionPointName.extensions.map { it.id } + return alRuntimeGroups.filter { supportedIds.contains(it.id) }.toSet() } - override fun getKey(): String = runtimeGroup.name - - override fun getInstance(): T = instance.value -} - -/** - * To be implemented on a companion object of the extension point object to expose factory methods. - * See [software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder] - */ -abstract class RuntimeGroupExtensionPointObject(private val extensionPointName: ExtensionPointName>) { - protected val collector = KeyedExtensionCollector(extensionPointName.name) - fun getInstance(runtimeGroup: RuntimeGroup): T? = collector.findSingle(runtimeGroup) - fun getInstanceOrThrow(runtimeGroup: RuntimeGroup): T = - getInstance(runtimeGroup) ?: throw IllegalStateException("Attempted to retrieve feature for unsupported runtime group $runtimeGroup") - - val supportedRuntimeGroups: Set by lazy { extensionPointName.extensions.map { it.runtimeGroup }.toSet() } - val supportedLanguages: Set by lazy { supportedRuntimeGroups.flatMap { it.languageIds }.mapNotNull { Language.findLanguageByID(it) }.toSet() } + fun supportedLanguages(): Set { + val supportedRuntimeGroups = supportedRuntimeGroups() + return supportedRuntimeGroups.asSequence().flatMap { it.languageIds.asSequence() }.mapNotNull { Language.findLanguageByID(it) }.toSet() + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/SamProjectWizard.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/SamProjectWizard.kt deleted file mode 100644 index a53de60744..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/SamProjectWizard.kt +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda - -import com.intellij.execution.RunManager -import com.intellij.openapi.application.runWriteAction -import com.intellij.openapi.extensions.ExtensionPointName -import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.openapi.fileEditor.OpenFileDescriptor -import com.intellij.openapi.fileEditor.TextEditorWithPreview -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.project.Project -import com.intellij.openapi.projectRoots.Sdk -import com.intellij.openapi.roots.ModifiableRootModel -import com.intellij.openapi.roots.ProjectRootManager -import com.intellij.openapi.vfs.VfsUtil -import com.intellij.openapi.vfs.VirtualFile -import icons.AwsIcons -import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.services.lambda.execution.local.LocalLambdaRunConfiguration -import software.aws.toolkits.jetbrains.services.lambda.execution.local.LocalLambdaRunConfigurationProducer -import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon -import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils -import software.aws.toolkits.jetbrains.services.schemas.SchemaTemplateParameters -import software.aws.toolkits.jetbrains.services.schemas.resources.SchemasResources.AWS_EVENTS_REGISTRY -import software.aws.toolkits.jetbrains.ui.wizard.AwsModuleType -import software.aws.toolkits.jetbrains.ui.wizard.SamInitRunner -import software.aws.toolkits.jetbrains.ui.wizard.SamProjectGenerator -import software.aws.toolkits.jetbrains.ui.wizard.SchemaSelectionPanel -import software.aws.toolkits.jetbrains.ui.wizard.SdkSelectionPanel -import software.aws.toolkits.telemetry.SamTelemetry -import software.aws.toolkits.telemetry.Runtime as TelemetryRuntime - -/** - * Used to manage SAM project information for different [RuntimeGroup]s - */ -interface SamProjectWizard { - - /** - * Return a collection of templates supported by the [RuntimeGroup] - */ - fun listTemplates(): Collection - - /** - * Return an instance of UI section for selecting SDK for the [RuntimeGroup] - */ - fun createSdkSelectionPanel(generator: SamProjectGenerator): SdkSelectionPanel - - /** - * Return an instance of UI section for selecting Schema for the [RuntimeGroup] - */ - fun createSchemaSelectionPanel( - generator: SamProjectGenerator - ): SchemaSelectionPanel - - companion object : RuntimeGroupExtensionPointObject(ExtensionPointName("aws.toolkit.lambda.sam.projectWizard")) -} - -data class SamNewProjectSettings( - val runtime: Runtime, - val schemaParameters: SchemaTemplateParameters?, - val template: SamProjectTemplate, - val sdkSettings: SdkSettings -) - -interface SdkSettings - -/** - * Sdk settings that supports [Sdk] as the language's SDK, such as Java, Python. - */ -data class SdkBasedSdkSettings( - val sdk: Sdk? -) : SdkSettings - -sealed class TemplateParameters { - data class LocationBasedTemplate(val location: String) : TemplateParameters() - data class AppBasedTemplate(val appTemplate: String, val dependencyManager: String) : TemplateParameters() -} - -abstract class SamProjectTemplate { - abstract fun getName(): String - - open fun getDescription(): String? = null - - open fun functionName(): String = "HelloWorldFunction" - - override fun toString() = getName() - - open fun setupSdk(rootModel: ModifiableRootModel, settings: SamNewProjectSettings) { - val sdkSettings = settings.sdkSettings - - if (sdkSettings is SdkBasedSdkSettings) { - // project sdk - runWriteAction { - ProjectRootManager.getInstance(rootModel.project).projectSdk = sdkSettings.sdk - } - // module sdk - rootModel.inheritSdk() - } - } - - open fun postCreationAction( - settings: SamNewProjectSettings, - contentRoot: VirtualFile, - rootModel: ModifiableRootModel, - sourceCreatingProject: Project, - indicator: ProgressIndicator - ) { - SamCommon.excludeSamDirectory(contentRoot, rootModel) - val project = rootModel.project - openReadmeFile(contentRoot, project) - createRunConfigurations(contentRoot, project) - } - - private fun openReadmeFile(contentRoot: VirtualFile, project: Project) { - VfsUtil.findRelativeFile(contentRoot, "README.md")?.let { readme -> - // it's only available since the first non-EAP version of intellij, so it is fine - readme.putUserData(TextEditorWithPreview.DEFAULT_LAYOUT_FOR_FILE, TextEditorWithPreview.Layout.SHOW_PREVIEW) - - val fileEditorManager = FileEditorManager.getInstance(project) - fileEditorManager.openTextEditor(OpenFileDescriptor(project, readme), true) ?: LOG.warn { "Failed to open README.md" } - } - } - - private fun createRunConfigurations(contentRoot: VirtualFile, project: Project) { - val template = SamCommon.getTemplateFromDirectory(contentRoot) ?: return - - val factory = LocalLambdaRunConfigurationProducer.getFactory() - val runManager = RunManager.getInstance(project) - SamTemplateUtils.findFunctionsFromTemplate(project, template).forEach { - val runConfigurationAndSettings = runManager.createConfiguration(it.logicalName, factory) - val runConfiguration = runConfigurationAndSettings.configuration as LocalLambdaRunConfiguration - runConfiguration.useTemplate(template.path, it.logicalName) - runConfiguration.setGeneratedName() - runManager.addConfiguration(runConfigurationAndSettings) - if (runManager.selectedConfiguration == null) { - runManager.selectedConfiguration = runConfigurationAndSettings - } - } - } - - fun getIcon() = AwsIcons.Resources.SERVERLESS_APP - - fun build(project: Project?, runtime: Runtime, schemaParameters: SchemaTemplateParameters?, outputDir: VirtualFile) { - var success = true - try { - doBuild(runtime, schemaParameters, outputDir) - } catch (e: Throwable) { - success = false - throw e - } finally { - SamTelemetry.init( - project, - name = getName(), - success = success, - runtime = TelemetryRuntime.from(runtime.toString()), - version = SamCommon.getVersionString(), - templatename = this.javaClass.simpleName, - eventbridgeschema = if (schemaParameters?.schema?.registryName == AWS_EVENTS_REGISTRY) schemaParameters.schema.name else null - ) - } - } - - private fun doBuild(runtime: Runtime, schemaParameters: SchemaTemplateParameters?, outputDir: VirtualFile) { - SamInitRunner.execute( - AwsModuleType.ID, - outputDir, - runtime, - templateParameters(), - if (supportsDynamicSchemas()) schemaParameters else null - ) - } - - protected abstract fun templateParameters(): TemplateParameters - - abstract fun supportedRuntimes(): Set - - // Gradual opt-in for Schema support on a template by-template basis. - // All SAM templates should support schema selection, but for launch include only EventBridge for most optimal customer experience - open fun supportsDynamicSchemas(): Boolean = false - - companion object { - private val LOG = getLogger() - - @JvmField - val SAM_TEMPLATES = - SamProjectWizard.supportedRuntimeGroups.flatMap { - SamProjectWizard.getInstanceOrThrow(it).listTemplates() - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/SyncServerlessAppWarningDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/SyncServerlessAppWarningDialog.kt new file mode 100644 index 0000000000..6f1c5749c0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/SyncServerlessAppWarningDialog.kt @@ -0,0 +1,46 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.jetbrains.settings.SamDisplayDevModeWarningSettings +import software.aws.toolkits.resources.message +import javax.swing.JComponent + +class SyncServerlessAppWarningDialog(private val project: Project) : DialogWrapper(project) { + private val settings = SamDisplayDevModeWarningSettings.getInstance() + private val dontDisplayWarning = JBCheckBox(message("general.notification.action.hide_forever")).also { + it.isSelected = false + } + private val component by lazy { + panel { + row { + label( + message("serverless.application.sync.dev.mode.warning.text") + ) + } + row { + cell(dontDisplayWarning) + } + } + } + + init { + super.init() + title = message("serverless.application.sync.confirm.dev.stack.title") + setOKButtonText(message("general.confirm")) + } + + override fun createCenterPanel(): JComponent? = component + + override fun doOKAction() { + super.doOKAction() + if (dontDisplayWarning.isSelected) { + settings.showDevModeWarning = false + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/DeleteFunctionAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/DeleteFunctionAction.kt index bce073d8df..b50db8b094 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/DeleteFunctionAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/DeleteFunctionAction.kt @@ -4,19 +4,19 @@ package software.aws.toolkits.jetbrains.services.lambda.actions import software.amazon.awssdk.services.lambda.LambdaClient -import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.awsClient import software.aws.toolkits.jetbrains.core.explorer.actions.DeleteResourceAction import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree import software.aws.toolkits.jetbrains.services.lambda.LambdaFunctionNode import software.aws.toolkits.jetbrains.services.lambda.resources.LambdaResources -import software.aws.toolkits.jetbrains.utils.TaggingResourceType import software.aws.toolkits.resources.message -class DeleteFunctionAction : DeleteResourceAction(message("lambda.function.delete.action"), TaggingResourceType.LAMBDA_FUNCTION) { +class DeleteFunctionAction : DeleteResourceAction(message("lambda.function.delete.action")) { override fun performDelete(selected: LambdaFunctionNode) { val project = selected.nodeProject - val client: LambdaClient = AwsClientManager.getInstance(project).getClient() + + val client: LambdaClient = project.awsClient() client.deleteFunction { it.functionName(selected.functionName()) } - selected.nodeProject.refreshAwsTree(LambdaResources.LIST_FUNCTIONS) + project.refreshAwsTree(LambdaResources.LIST_FUNCTIONS) } } 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 deleted file mode 100644 index 1e2f666655..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/DeployServerlessApplicationAction.kt +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.actions - -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.LangDataKeys -import com.intellij.openapi.actionSystem.PlatformDataKeys -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.application.runReadAction -import com.intellij.openapi.fileEditor.FileDocumentManager -import com.intellij.openapi.module.ModuleUtil -import com.intellij.openapi.project.Project -import com.intellij.openapi.roots.ModuleRootManager -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.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 -import software.aws.toolkits.jetbrains.services.cloudformation.describeStack -import software.aws.toolkits.jetbrains.services.cloudformation.executeChangeSetAndWait -import software.aws.toolkits.jetbrains.services.cloudformation.stack.StackWindowManager -import software.aws.toolkits.jetbrains.services.cloudformation.validateSamTemplateHasResources -import software.aws.toolkits.jetbrains.services.cloudformation.validateSamTemplateLambdaRuntimes -import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver -import software.aws.toolkits.jetbrains.services.lambda.deploy.DeployServerlessApplicationDialog -import software.aws.toolkits.jetbrains.services.lambda.deploy.SamDeployDialog -import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable -import software.aws.toolkits.jetbrains.settings.DeploySettings -import software.aws.toolkits.jetbrains.settings.relativeSamPath -import software.aws.toolkits.jetbrains.utils.Operation -import software.aws.toolkits.jetbrains.utils.TaggingResourceType -import software.aws.toolkits.jetbrains.utils.notifyError -import software.aws.toolkits.jetbrains.utils.notifyInfo -import software.aws.toolkits.jetbrains.utils.notifyNoActiveCredentialsError -import software.aws.toolkits.jetbrains.utils.notifySamCliNotValidError -import software.aws.toolkits.jetbrains.utils.warnResourceOperationAgainstCodePipeline -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.Result -import software.aws.toolkits.telemetry.SamTelemetry - -class DeployServerlessApplicationAction : AnAction( - message("serverless.application.deploy"), - null, - AwsIcons.Resources.SERVERLESS_APP -) { - private val templateYamlRegex = Regex("template\\.y[a]?ml", RegexOption.IGNORE_CASE) - - override fun actionPerformed(e: AnActionEvent) { - val project = e.getRequiredData(PlatformDataKeys.PROJECT) - - if (!AwsConnectionManager.getInstance(project).isValidConnectionSettings()) { - notifyNoActiveCredentialsError(project = project) - return - } - - ExecutableManager.getInstance().getExecutable().thenAccept { samExecutable -> - when (samExecutable) { - is ExecutableInstance.InvalidExecutable, is ExecutableInstance.UnresolvedExecutable -> { - notifySamCliNotValidError( - project = project, - content = (samExecutable as ExecutableInstance.BadExecutable).validationError - ) - return@thenAccept - } - } - - val templateFile = getSamTemplateFile(e) - if (templateFile == null) { - Exception(message("serverless.application.deploy.toast.template_file_failure")) - .notifyError(message("aws.notification.title"), project) - return@thenAccept - } - - validateTemplateFile(project, templateFile)?.let { - notifyError(content = it, project = project) - return@thenAccept - } - - runInEdt { - // Force save before we deploy - FileDocumentManager.getInstance().saveAllDocuments() - - val stackDialog = DeployServerlessApplicationDialog(project, templateFile) - stackDialog.show() - if (!stackDialog.isOK) { - SamTelemetry.deploy(project, Result.Cancelled) - return@runInEdt - } - - saveSettings(project, templateFile, stackDialog) - - val stackName = stackDialog.stackName - val stackId = stackDialog.stackId - - if (stackId == null) { - continueDeployment(project, stackName, templateFile, stackDialog) - } else { - warnResourceOperationAgainstCodePipeline(project, stackName, stackId, TaggingResourceType.CLOUDFORMATION_STACK, Operation.DEPLOY) { - continueDeployment(project, stackName, templateFile, stackDialog) - } - } - } - } - } - - private fun continueDeployment(project: Project, stackName: String, templateFile: VirtualFile, stackDialog: DeployServerlessApplicationDialog) { - val deployDialog = SamDeployDialog( - project, - stackName, - templateFile, - stackDialog.parameters, - stackDialog.bucket, - stackDialog.autoExecute, - stackDialog.useContainer, - stackDialog.capabilities - ) - - deployDialog.show() - if (!deployDialog.isOK) return - - val cfnClient = project.awsClient() - - cfnClient.describeStack(stackName) { - it?.run { - runInEdt { - StackWindowManager.getInstance(project).openStack(stackName(), stackId()) - } - } - } - ApplicationManager.getApplication().executeOnPooledThread { - try { - cfnClient.executeChangeSetAndWait(stackName, deployDialog.changeSetName) - notifyInfo( - message("cloudformation.execute_change_set.success.title"), - message("cloudformation.execute_change_set.success", stackName), - project - ) - SamTelemetry.deploy(project, Result.Succeeded) - } catch (e: Exception) { - e.notifyError(message("cloudformation.execute_change_set.failed", stackName), project) - SamTelemetry.deploy(project, Result.Failed) - } - } - } - - override fun update(e: AnActionEvent) { - super.update(e) - - // If there are no supported runtime groups, it will never succeed so don't show it - e.presentation.isVisible = if (LambdaHandlerResolver.supportedRuntimeGroups.isEmpty()) { - false - } else { - getSamTemplateFile(e) != null - } - } - - /** - * Determines the relevant Sam Template, returns null if one can't be found. - */ - private fun getSamTemplateFile(e: AnActionEvent): VirtualFile? = runReadAction { - val virtualFiles = e.getData(PlatformDataKeys.VIRTUAL_FILE_ARRAY) ?: return@runReadAction null - val virtualFile = virtualFiles.singleOrNull() ?: return@runReadAction null - - if (templateYamlRegex.matches(virtualFile.name)) { - return@runReadAction virtualFile - } - - // If the module node was selected, see if there is a template file in the top level folder - val module = e.getData(LangDataKeys.MODULE_CONTEXT) - if (module != null) { - // It is only acceptable if one template file is found - val childTemplateFiles = ModuleRootManager.getInstance(module).contentRoots.flatMap { root -> - root.children.filter { child -> templateYamlRegex.matches(child.name) } - } - - if (childTemplateFiles.size == 1) { - return@runReadAction childTemplateFiles.single() - } - } - - return@runReadAction null - } - - private fun saveSettings(project: Project, templateFile: VirtualFile, stackDialog: DeployServerlessApplicationDialog) { - ModuleUtil.findModuleForFile(templateFile, project)?.let { module -> - relativeSamPath(module, templateFile)?.let { samPath -> - DeploySettings.getInstance(module)?.apply { - setSamStackName(samPath, stackDialog.stackName) - setSamBucketName(samPath, stackDialog.bucket) - setSamAutoExecute(samPath, stackDialog.autoExecute) - setSamUseContainer(samPath, stackDialog.useContainer) - setEnabledCapabilities(samPath, stackDialog.capabilities) - } - } - } - } - - private fun validateTemplateFile(project: Project, templateFile: VirtualFile): String? = - try { - project.validateSamTemplateHasResources(templateFile) ?: project.validateSamTemplateLambdaRuntimes(templateFile) - } catch (e: Exception) { - message("serverless.application.deploy.error.bad_parse", templateFile.path, e) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/LambdaLogGroupAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/LambdaLogGroupAction.kt index dcca489edf..5878bb59fa 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/LambdaLogGroupAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/LambdaLogGroupAction.kt @@ -6,27 +6,26 @@ package software.aws.toolkits.jetbrains.services.lambda.actions import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.project.DumbAware import icons.AwsIcons -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope import software.aws.toolkits.jetbrains.core.explorer.actions.SingleExplorerNodeAction import software.aws.toolkits.jetbrains.services.cloudwatch.logs.CloudWatchLogWindow import software.aws.toolkits.jetbrains.services.cloudwatch.logs.checkIfLogGroupExists import software.aws.toolkits.jetbrains.services.lambda.LambdaFunctionNode -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.resources.message class LambdaLogGroupAction : SingleExplorerNodeAction(message("cloudwatch.logs.view_log_streams"), null, AwsIcons.Resources.CloudWatch.LOGS), - CoroutineScope by ApplicationThreadPoolScope("LambdaLogGroupAction"), DumbAware { override fun actionPerformed(selected: LambdaFunctionNode, e: AnActionEvent) { - launch { - val project = selected.nodeProject - val client = project.awsClient() - val logGroup = "/aws/lambda/${selected.functionName()}" + val project = selected.nodeProject + val client = project.awsClient() + val logGroup = "/aws/lambda/${selected.functionName()}" + val scope = projectCoroutineScope(project) + scope.launch { if (client.checkIfLogGroupExists(logGroup)) { val window = CloudWatchLogWindow.getInstance(project) window.showLogGroup(logGroup) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/SyncServerlessAppAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/SyncServerlessAppAction.kt new file mode 100644 index 0000000000..3699108fac --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/SyncServerlessAppAction.kt @@ -0,0 +1,287 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.actions + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.runners.ExecutionEnvironmentBuilder +import com.intellij.execution.util.ExecUtil +import com.intellij.ide.BrowserUtil +import com.intellij.notification.NotificationAction +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.text.SemVer +import icons.AwsIcons +import software.amazon.awssdk.services.cloudformation.model.StackSummary +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettingsOrThrow +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.getResourceNow +import software.aws.toolkits.jetbrains.services.cloudformation.SamFunction +import software.aws.toolkits.jetbrains.services.cloudformation.resources.CloudFormationResources +import software.aws.toolkits.jetbrains.services.lambda.SyncServerlessAppWarningDialog +import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon +import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateFileUtils.retrieveSamTemplate +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateFileUtils.validateTemplateFile +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import software.aws.toolkits.jetbrains.services.lambda.sam.sync.SyncApplicationRunProfile +import software.aws.toolkits.jetbrains.services.lambda.sam.sync.SyncServerlessApplicationDialog +import software.aws.toolkits.jetbrains.services.lambda.sam.sync.SyncServerlessApplicationSettings +import software.aws.toolkits.jetbrains.settings.SamDisplayDevModeWarningSettings +import software.aws.toolkits.jetbrains.settings.SyncSettings +import software.aws.toolkits.jetbrains.settings.relativeSamPath +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyNoActiveCredentialsError +import software.aws.toolkits.jetbrains.utils.notifySamCliNotValidError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.LambdaPackageType +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SamTelemetry +import software.aws.toolkits.telemetry.SyncedResources +import java.net.URI + +class SyncServerlessAppAction : AnAction( + message("serverless.application.sync"), + null, + AwsIcons.Resources.SERVERLESS_APP +) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(PlatformDataKeys.PROJECT) + + if (!AwsConnectionManager.getInstance(project).isValidConnectionSettings()) { + notifyNoActiveCredentialsError(project = project) + return + } + + ExecutableManager.getInstance().getExecutable().thenAccept { samExecutable -> + if (samExecutable is ExecutableInstance.InvalidExecutable || samExecutable is ExecutableInstance.UnresolvedExecutable) { + notifySamCliNotValidError( + project = project, + content = (samExecutable as ExecutableInstance.BadExecutable).validationError + ) + LOG.warn { "Invalid SAM CLI Executable" } + SamTelemetry.sync( + project = project, + result = Result.Failed, + syncedResources = SyncedResources.AllResources, + reason = "InvalidSamCli" + ) + return@thenAccept + } + + val execVersion = SemVer.parseFromText(samExecutable.version) ?: error("SAM CLI version could not detected") + val minVersion = SemVer("1.78.0", 1, 78, 0) + + if (!execVersion.isGreaterOrEqualThan(minVersion)) { + notifyError( + message("sam.cli.version.warning"), + message( + "sam.cli.version.upgrade.required", + execVersion.parsedVersion, + minVersion.parsedVersion + ), + project = project, + listOf( + NotificationAction.createSimple(message("sam.cli.version.upgrade.reason")) { + BrowserUtil.browse( + URI( + "https://aws.amazon.com/about-aws/whats-new/2023/03/aws-toolkits-jetbrains-vs-code-sam-accelerate/" + ) + ) + }, + NotificationAction.createSimple(message("sam.cli.version.upgrade.instructions")) { + BrowserUtil.browse( + URI( + "https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/" + + "manage-sam-cli-versions.html#manage-sam-cli-versions-upgrade" + ) + ) + } + ) + ) + SamTelemetry.sync( + project = project, + result = Result.Failed, + syncedResources = SyncedResources.AllResources, + reason = "OldSamCliVersion" + ) + return@thenAccept + } + + val templateFile = retrieveSamTemplate(e, project) ?: return@thenAccept + + validateTemplateFile(project, templateFile)?.let { + notifyError(content = it, project = project) + LOG.warn { it } + SamTelemetry.sync( + project = project, + result = Result.Failed, + syncedResources = SyncedResources.AllResources, + reason = "UnparseableTemplateFile" + ) + return@thenAccept + } + + val templateFunctions = SamTemplateUtils.findFunctionsFromTemplate(project, templateFile) + val hasImageFunctions: Boolean = templateFunctions.any { (it as? SamFunction)?.packageType() == PackageType.IMAGE } + val lambdaType = if (hasImageFunctions) LambdaPackageType.Image else LambdaPackageType.Zip + val syncedResourceType = SyncedResources.AllResources + + ProgressManager.getInstance().run( + object : Task.WithResult( + project, + message("serverless.application.sync.fetch.stacks.progress.bar"), + false + ) { + override fun compute(indicator: ProgressIndicator): PreSyncRequirements { + val dockerDoesntExist = try { + val processOutput = ExecUtil.execAndGetOutput(GeneralCommandLine("docker", "ps")) + processOutput.exitCode != 0 + } catch (e: Exception) { + LOG.warn(e) { "Docker could not be found" } + true + } + + val activeStacks = project.getResourceNow(CloudFormationResources.ACTIVE_STACKS, forceFetch = true, useStale = false) + return PreSyncRequirements(dockerDoesntExist, activeStacks) + } + + override fun onFinished() { + val warningSettings = SamDisplayDevModeWarningSettings.getInstance() + runInEdt { + if (warningSettings.showDevModeWarning) { + if (!SyncServerlessAppWarningDialog(project).showAndGet()) { + SamTelemetry.sync( + project = project, + result = Result.Cancelled, + syncedResources = syncedResourceType, + lambdaPackageType = lambdaType, + version = SamCommon.getVersionString() + ) + + return@runInEdt + } + } + + FileDocumentManager.getInstance().saveAllDocuments() + val parameterDialog = SyncServerlessApplicationDialog(project, templateFile, result.activeStacks) + + if (!parameterDialog.showAndGet()) { + SamTelemetry.sync( + project = project, + result = Result.Cancelled, + syncedResources = syncedResourceType, + lambdaPackageType = lambdaType, + version = SamCommon.getVersionString() + ) + return@runInEdt + } + val settings = parameterDialog.settings() + + saveSettings(project, templateFile, settings) + + if (settings.useContainer) { + when (result.dockerDoesntExist) { + null -> return@runInEdt + true -> { + Messages.showWarningDialog(message("lambda.debug.docker.not_connected"), message("docker.not.found")) + SamTelemetry.sync( + project = project, + result = Result.Failed, + syncedResources = syncedResourceType, + lambdaPackageType = lambdaType, + version = SamCommon.getVersionString(), + reason = "DockerNotFound" + ) + return@runInEdt + } + + else -> {} + } + } + + syncApp(templateFile, project, settings, syncedResourceType, lambdaType) + } + } + } + + ) + } + } + + private fun syncApp( + templateFile: VirtualFile, + project: Project, + settings: SyncServerlessApplicationSettings, + syncedResources: SyncedResources, + lambdaPackageType: LambdaPackageType + ) { + try { + val templatePath = templateFile.toNioPath() + val environment = ExecutionEnvironmentBuilder.create( + project, + DefaultRunExecutor.getRunExecutorInstance(), + SyncApplicationRunProfile(project, settings, project.getConnectionSettingsOrThrow(), templatePath) + ).build() + + environment.runner.execute(environment) + SamTelemetry.sync( + project = project, + result = Result.Succeeded, + syncedResources = syncedResources, + lambdaPackageType = lambdaPackageType, + version = SamCommon.getVersionString() + ) + } catch (e: Exception) { + SamTelemetry.sync( + project = project, + result = Result.Failed, + syncedResources = syncedResources, + lambdaPackageType = lambdaPackageType, + version = SamCommon.getVersionString() + ) + } + } + + private fun saveSettings(project: Project, templateFile: VirtualFile, settings: SyncServerlessApplicationSettings) { + ModuleUtil.findModuleForFile(templateFile, project)?.let { module -> + relativeSamPath(module, templateFile)?.let { samPath -> + SyncSettings.getInstance(module)?.apply { + setSamStackName(samPath, settings.stackName) + setSamBucketName(samPath, settings.bucket) + setSamEcrRepoUri(samPath, settings.ecrRepo) + setSamUseContainer(samPath, settings.useContainer) + setEnabledCapabilities(samPath, settings.capabilities) + setSamTags(samPath, settings.tags) + setSamTempParameterOverrides(samPath, settings.parameters) + } + } + } + } + + companion object { + private val LOG = getLogger() + } +} + +data class PreSyncRequirements( + val dockerDoesntExist: Boolean? = null, + val activeStacks: List +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/SyncServerlessApplicationAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/SyncServerlessApplicationAction.kt new file mode 100644 index 0000000000..9781a4b7d0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/SyncServerlessApplicationAction.kt @@ -0,0 +1,23 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import icons.AwsIcons +import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateFileUtils.getSamTemplateFile +import software.aws.toolkits.resources.message + +class SyncServerlessApplicationAction : AnAction(message("serverless.application.sync"), null, AwsIcons.Resources.SERVERLESS_APP) { + override fun actionPerformed(e: AnActionEvent) { + SyncServerlessAppAction().actionPerformed(e) + } + + override fun update(e: AnActionEvent) { + super.update(e) + e.presentation.isEnabledAndVisible = getSamTemplateFile(e) != null && + LambdaHandlerResolver.supportedRuntimeGroups().isNotEmpty() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/UpdateFunctionAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/UpdateFunctionAction.kt index 4fa721eda8..8d2ba5baf5 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/UpdateFunctionAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/actions/UpdateFunctionAction.kt @@ -6,28 +6,27 @@ package software.aws.toolkits.jetbrains.services.lambda.actions import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.PlatformDataKeys import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project import software.amazon.awssdk.services.lambda.LambdaClient -import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.jetbrains.core.awsClient import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder +import software.aws.toolkits.jetbrains.services.lambda.LambdaFunction import software.aws.toolkits.jetbrains.services.lambda.LambdaFunctionNode import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup import software.aws.toolkits.jetbrains.services.lambda.toDataClass -import software.aws.toolkits.jetbrains.services.lambda.upload.EditFunctionDialog -import software.aws.toolkits.jetbrains.services.lambda.upload.EditFunctionMode -import software.aws.toolkits.jetbrains.utils.Operation -import software.aws.toolkits.jetbrains.utils.TaggingResourceType -import software.aws.toolkits.jetbrains.utils.warnResourceOperationAgainstCodePipeline +import software.aws.toolkits.jetbrains.services.lambda.upload.UpdateFunctionCodeDialog +import software.aws.toolkits.jetbrains.services.lambda.upload.UpdateFunctionConfigDialog import software.aws.toolkits.resources.message -abstract class UpdateFunctionAction(private val mode: EditFunctionMode, title: String) : SingleResourceNodeAction(title) { +abstract class UpdateFunctionAction(title: String) : SingleResourceNodeAction(title) { override fun actionPerformed(selected: LambdaFunctionNode, e: AnActionEvent) { val project = e.getRequiredData(PlatformDataKeys.PROJECT) ApplicationManager.getApplication().executeOnPooledThread { - val selectedFunction = selected.value - - val client: LambdaClient = AwsClientManager.getInstance(project).getClient() + val client: LambdaClient = project.awsClient() // Fetch latest version just in case val functionConfiguration = client.getFunction { @@ -35,27 +34,33 @@ abstract class UpdateFunctionAction(private val mode: EditFunctionMode, title: S }.configuration() val lambdaFunction = functionConfiguration.toDataClass() - - warnResourceOperationAgainstCodePipeline( - project, - selectedFunction.name, - selectedFunction.arn, - TaggingResourceType.LAMBDA_FUNCTION, - Operation.UPDATE - ) { - EditFunctionDialog(project, lambdaFunction, mode = mode).show() + runInEdt { + showDialog(project, lambdaFunction) } } } + + protected abstract fun showDialog(project: Project, lambdaFunction: LambdaFunction) } -class UpdateFunctionConfigurationAction : UpdateFunctionAction(EditFunctionMode.UPDATE_CONFIGURATION, message("lambda.function.updateConfiguration.action")) +class UpdateFunctionConfigurationAction : UpdateFunctionAction(message("lambda.function.updateConfiguration.action")) { + override fun showDialog(project: Project, lambdaFunction: LambdaFunction) { + UpdateFunctionConfigDialog(project, lambdaFunction).show() + } +} -class UpdateFunctionCodeAction : UpdateFunctionAction(EditFunctionMode.UPDATE_CODE, message("lambda.function.updateCode.action")) { +class UpdateFunctionCodeAction : UpdateFunctionAction(message("lambda.function.updateCode.action")) { override fun update(selected: LambdaFunctionNode, e: AnActionEvent) { - if (selected.value.runtime.runtimeGroup?.let { LambdaBuilder.getInstance(it) } != null) { + if (selected.value.packageType == PackageType.IMAGE) { + return + } + if (selected.value.runtime?.runtimeGroup?.let { LambdaBuilder.getInstanceOrNull(it) } != null) { return } e.presentation.isVisible = false } + + override fun showDialog(project: Project, lambdaFunction: LambdaFunction) { + UpdateFunctionCodeDialog(project, lambdaFunction).show() + } } 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 87712775a8..e91b3999dc 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 @@ -8,22 +8,20 @@ import com.intellij.codeInsight.completion.CompletionResultSet 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 +import software.aws.toolkits.core.lambda.LambdaRuntime 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 { +class HandlerCompletionProvider(private val project: Project, runtime: LambdaRuntime?) : TextCompletionProvider { private val logger = getLogger() private val handlerCompletion: HandlerCompletion? by lazy { - val runtimeGroup = runtime?.runtimeGroup ?: RuntimeGroup.determineRuntime(project)?.runtimeGroup ?: return@lazy null + val runtimeGroup = runtime?.runtimeGroup ?: return@lazy null - return@lazy HandlerCompletion.getInstance(runtimeGroup) ?: let { - logger.info { "Lambda handler completion provider is not registered for runtime: ${runtimeGroup.name}. Completion is not supported." } + return@lazy HandlerCompletion.getInstanceOrNull(runtimeGroup) ?: let { + logger.info { "Lambda handler completion provider is not registered for runtime: ${runtimeGroup.id}. Completion is not supported." } null } } @@ -42,7 +40,7 @@ class HandlerCompletionProvider(private val project: Project, runtime: Runtime?) override fun getAdvertisement(): String? = null - override fun getPrefix(text: String, offset: Int): String? = text + override fun getPrefix(text: String, offset: Int): String = text override fun fillCompletionVariants(parameters: CompletionParameters, prefix: String, result: CompletionResultSet) { if (!isCompletionSupported) return diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/CreateCapabilities.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/CreateCapabilities.kt index 00da852f73..27fecb1ae3 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/CreateCapabilities.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/CreateCapabilities.kt @@ -25,7 +25,7 @@ enum class CreateCapabilities(val capability: String, val text: String, val tool message("cloudformation.capabilities.auto_expand"), message("cloudformation.capabilities.auto_expand.toolTipText"), false - ); + ) } class CapabilitiesEnumCheckBoxes { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationDialog.kt deleted file mode 100644 index 4c8593133a..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationDialog.kt +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.deploy - -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.module.ModuleUtilCore.findModuleForFile -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.DialogWrapper -import com.intellij.openapi.ui.ValidationInfo -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.util.text.nullize -import software.amazon.awssdk.services.cloudformation.CloudFormationClient -import software.amazon.awssdk.services.s3.S3Client -import software.aws.toolkits.jetbrains.core.Resource -import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.jetbrains.core.help.HelpIds -import software.aws.toolkits.jetbrains.core.map -import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationTemplate -import software.aws.toolkits.jetbrains.services.cloudformation.describeStack -import software.aws.toolkits.jetbrains.services.cloudformation.mergeRemoteParameters -import software.aws.toolkits.jetbrains.services.cloudformation.resources.CloudFormationResources -import software.aws.toolkits.jetbrains.services.s3.CreateS3BucketDialog -import software.aws.toolkits.jetbrains.settings.DeploySettings -import software.aws.toolkits.jetbrains.settings.relativeSamPath -import software.aws.toolkits.jetbrains.utils.ui.find -import software.aws.toolkits.jetbrains.utils.ui.validationInfo -import software.aws.toolkits.resources.message -import javax.swing.JComponent - -class DeployServerlessApplicationDialog( - private val project: Project, - private val templateFile: VirtualFile -) : DialogWrapper(project) { - - private val module = findModuleForFile(templateFile, project) - private val settings: DeploySettings? = module?.let { DeploySettings.getInstance(it) } - private val samPath: String = module?.let { relativeSamPath(it, templateFile) } ?: templateFile.name - - private val view = DeployServerlessApplicationPanel(project) - private val validator = DeploySamApplicationValidator(view) - private val s3Client: S3Client = project.awsClient() - private val cloudFormationClient: CloudFormationClient = project.awsClient() - private val templateParameters = CloudFormationTemplate.parse(project, templateFile).parameters().toList() - - init { - super.init() - - title = message("serverless.application.deploy.title") - setOKButtonText(message("serverless.application.deploy.action.name")) - setOKButtonTooltip(message("serverless.application.deploy.action.description")) - - view.createStack.addChangeListener { - updateStackEnabledStates() - updateTemplateParameters() - } - - view.updateStack.addChangeListener { - updateStackEnabledStates() - updateTemplateParameters() - } - - // If the module has been deployed once, select the updateStack radio instead - if (settings?.samStackName(samPath) != null) { - view.updateStack.isSelected = true - updateStackEnabledStates() - } - - view.stacks.addActionListener { - updateTemplateParameters() - } - - settings?.samStackName(samPath)?.let { - view.stacks.selectedItem { s: Stack -> it == s.name } - } - - updateTemplateParameters() - - view.s3Bucket.selectedItem = settings?.samBucketName(samPath) - - view.createS3BucketButton.addActionListener { - val bucketDialog = CreateS3BucketDialog( - project = project, - s3Client = s3Client, - parent = view.content - ) - - if (bucketDialog.showAndGet()) { - bucketDialog.bucketName().let { - view.s3Bucket.reload(forceFetch = true) - view.s3Bucket.selectedItem = it - } - } - } - - view.requireReview.isSelected = !(settings?.samAutoExecute(samPath) ?: true) - - view.useContainer.isSelected = (settings?.samUseContainer(samPath) ?: false) - view.capabilities.selected = settings?.enabledCapabilities(samPath) ?: CreateCapabilities.values().filter { it.defaultEnabled } - } - - override fun createCenterPanel(): JComponent? = view.content - - override fun getPreferredFocusedComponent(): JComponent? = - if (settings?.samStackName(samPath) == null) view.newStackName else view.updateStack - - override fun doValidate(): ValidationInfo? = validator.validateSettings() - - override fun getHelpId(): String? = HelpIds.DEPLOY_SERVERLESS_APPLICATION_DIALOG.id - - val stackName: String - get() = if (view.createStack.isSelected) { - view.newStackName.text.nullize() - } else { - view.stacks.selected()?.name - } ?: throw RuntimeException(message("serverless.application.deploy.validation.stack.missing")) - - val stackId: String? - get() = if (view.createStack.isSelected) { - null - } else { - view.stacks.selected()?.let { stack -> - // selected stack id will be null in case it was restored from DeploySettings - // DeploySettings doesn't store stack id because it doesn't have access to stack id - // at times when deployment happens with createStack selected - stack.id ?: view.stacks.model.find { it.name == stack.name }?.id - } - } - - val bucket: String - get() = view.s3Bucket.selected() - ?: throw RuntimeException(message("serverless.application.deploy.validation.s3.bucket.empty")) - - val autoExecute: Boolean - get() = !view.requireReview.isSelected - - val parameters: Map - get() = view.templateParameters - - val useContainer: Boolean - get() = view.useContainer.isSelected - - val capabilities: List - get() = view.capabilities.selected - - private fun updateStackEnabledStates() { - view.newStackName.isEnabled = view.createStack.isSelected - view.stacks.isEnabled = view.updateStack.isSelected - } - - private fun updateTemplateParameters() { - if (view.createStack.isSelected) { - view.withTemplateParameters(templateParameters) - } else if (view.updateStack.isSelected) { - val stackName = view.stacks.selected()?.name - if (stackName == null) { - view.withTemplateParameters(emptyList()) - } else { - cloudFormationClient.describeStack(stackName) { - it?.let { - runInEdt(ModalityState.any()) { - // This check is here in-case createStack was selected before we got this update back - // TODO: should really create a queuing pattern here so we can cancel on user-action - if (view.updateStack.isSelected) { - view.withTemplateParameters(templateParameters.mergeRemoteParameters(it.parameters())) - } - } - } - } - } - } - } - - companion object { - @JvmField - internal val ACTIVE_STACKS: Resource> = CloudFormationResources.ACTIVE_STACKS.map { Stack(it.stackName(), it.stackId()) } - } -} - -class DeploySamApplicationValidator(private val view: DeployServerlessApplicationPanel) { - - fun validateSettings(): ValidationInfo? { - if (view.createStack.isSelected) { - validateStackName(view.newStackName.text)?.let { - return view.newStackName.validationInfo(it) - } - } else if (view.updateStack.isSelected && view.stacks.selected() == null) { - return view.stacks.validationInfo(message("serverless.application.deploy.validation.stack.missing")) - } - - // Are any Template Parameters missing - validateParameters(view)?.let { - return it - } - - // Has the user selected a bucket - view.s3Bucket.selected() ?: return view.s3Bucket.validationInfo(message("serverless.application.deploy.validation.s3.bucket.empty")) - - return null - } - - private fun validateParameters(view: DeployServerlessApplicationPanel): ValidationInfo? { - val parameters = view.templateParameters - - val unsetParameters = parameters.entries - .filter { it.value.isNullOrBlank() } - .map { it.key } - .toList() - - if (unsetParameters.any()) { - return ValidationInfo( - message( - "serverless.application.deploy.validation.template.values.missing", - unsetParameters.joinToString(", ") - ) - ) - } - - return null - } - - private fun validateStackName(name: String?): String? { - if (name == null || name.isEmpty()) { - return message("serverless.application.deploy.validation.new.stack.name.missing") - } - if (!STACK_NAME_PATTERN.matches(name)) { - return message("serverless.application.deploy.validation.new.stack.name.invalid") - } - if (name.length > MAX_STACK_NAME_LENGTH) { - return message("serverless.application.deploy.validation.new.stack.name.too.long", MAX_STACK_NAME_LENGTH) - } - // Check if the new stack name is same as an existing stack name - view.stacks.model.find { it.name == name }?.let { - return message("serverless.application.deploy.validation.new.stack.name.duplicate") - } - return null - } - - companion object { - // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-using-console-create-stack-parameters.html - // A stack name can contain only alphanumeric characters (case-sensitive) and hyphens. It must start with an alphabetic character and can't be longer than 128 characters. - private val STACK_NAME_PATTERN = "[a-zA-Z][a-zA-Z0-9-]*".toRegex() - const val MAX_STACK_NAME_LENGTH = 128 - } -} - -data class Stack(val name: String, val id: String? = null) { - override fun toString() = name -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationPanel.form deleted file mode 100644 index febe700447..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationPanel.form +++ /dev/null @@ -1,144 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationPanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationPanel.java deleted file mode 100644 index 3e2e4fef36..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationPanel.java +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.deploy; - -import static software.aws.toolkits.jetbrains.services.lambda.deploy.DeployServerlessApplicationDialog.ACTIVE_STACKS; -import static software.aws.toolkits.resources.Localization.message; - -import com.intellij.execution.util.EnvVariablesTable; -import com.intellij.execution.util.EnvironmentVariable; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.project.Project; -import com.intellij.ui.AnActionButton; -import com.intellij.ui.IdeBorderFactory; -import com.intellij.ui.ToolbarDecorator; -import com.intellij.ui.components.panels.Wrapper; -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; -import java.util.stream.Collectors; -import javax.swing.JButton; -import javax.swing.JCheckBox; -import javax.swing.JComponent; -import javax.swing.JPanel; -import javax.swing.JRadioButton; -import javax.swing.JTextField; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import software.aws.toolkits.jetbrains.services.cloudformation.Parameter; -import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources; -import software.aws.toolkits.jetbrains.ui.ResourceSelector; - -@SuppressWarnings("NullableProblems") -public class DeployServerlessApplicationPanel { - @NotNull JTextField newStackName; - @NotNull JButton createS3BucketButton; - private EnvVariablesTable environmentVariablesTable; - @NotNull JPanel content; - @NotNull ResourceSelector s3Bucket; - @NotNull ResourceSelector stacks; - @NotNull Wrapper stackParameters; - @NotNull JRadioButton updateStack; - @NotNull JRadioButton createStack; - @NotNull JCheckBox requireReview; - @NotNull JPanel parametersPanel; - @NotNull JCheckBox useContainer; - @NotNull JPanel capabilitiesPanel; - final CapabilitiesEnumCheckBoxes capabilities; - private final Project project; - - public DeployServerlessApplicationPanel(Project project) { - this.project = project; - this.capabilities = new CapabilitiesEnumCheckBoxes(); - this.capabilities.getCheckboxes().forEach(it -> capabilitiesPanel.add(it)); - } - - public DeployServerlessApplicationPanel withTemplateParameters(final Collection parameters) { - parametersPanel.setBorder( - IdeBorderFactory.createTitledBorder(message("serverless.application.deploy.template.parameters"), false)); - environmentVariablesTable.setValues( - parameters.stream().map(parameter -> new EnvironmentVariable( - parameter.getLogicalName(), - parameter.defaultValue(), - false - ) { - @Override - public boolean getNameIsWriteable() { - return false; - } - - @Nullable - @Override - public String getDescription() { - return parameter.description(); - } - }).collect(Collectors.toList()) - ); - - return this; - } - - public Map getTemplateParameters() { - Map parameters = new HashMap<>(); - - environmentVariablesTable.stopEditing(); - environmentVariablesTable.getEnvironmentVariables() - .forEach(envVar -> parameters.put(envVar.getName(), envVar.getValue())); - - return parameters; - } - - private void createUIComponents() { - environmentVariablesTable = new EnvVariablesTable(); - stackParameters = new Wrapper(); - stacks = ResourceSelector.builder(project).resource(ACTIVE_STACKS).build(); - s3Bucket = ResourceSelector.builder(project).resource(S3Resources.listBucketNamesByActiveRegion(project)).build(); - - if (!ApplicationManager.getApplication().isUnitTestMode()) { - JComponent tableComponent = environmentVariablesTable.getComponent(); - hideActionButton(ToolbarDecorator.findAddButton(tableComponent)); - hideActionButton(ToolbarDecorator.findRemoveButton(tableComponent)); - hideActionButton(ToolbarDecorator.findEditButton(tableComponent)); - - stackParameters.setContent(tableComponent); - } - } - - private static void hideActionButton(final AnActionButton actionButton) { - if (actionButton != null) { - actionButton.setEnabled(false); - actionButton.setVisible(false); - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationSettings.kt new file mode 100644 index 0000000000..e4a2d6acc8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/DeployServerlessApplicationSettings.kt @@ -0,0 +1,15 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.deploy + +data class DeployServerlessApplicationSettings( + val stackName: String, + val bucket: String, + val ecrRepo: String?, + val autoExecute: Boolean, + val parameters: Map, + val tags: Map, + val useContainer: Boolean, + val capabilities: List +) 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 deleted file mode 100644 index 19bacac926..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployDialog.kt +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.deploy - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.process.CapturingProcessRunner -import com.intellij.execution.process.OSProcessHandler -import com.intellij.execution.process.ProcessHandlerFactory -import com.intellij.execution.process.ProcessOutput -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.progress.ProcessCanceledException -import com.intellij.openapi.progress.util.ProgressIndicatorBase -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.DialogWrapper -import com.intellij.openapi.util.Disposer -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.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 -import software.aws.toolkits.jetbrains.core.executables.getExecutable -import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon -import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.SamTelemetry -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CompletionStage -import javax.swing.Action -import javax.swing.JComponent - -class SamDeployDialog( - private val project: Project, - private val stackName: String, - private val template: VirtualFile, - private val parameters: Map, - private val s3Bucket: String, - private val autoExecute: Boolean, - private val useContainer: Boolean, - private val capabilities: List -) : DialogWrapper(project) { - private val progressIndicator = ProgressIndicatorBase() - private val view = SamDeployView(project, progressIndicator) - private var currentStep = 0 - 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 - - init { - Disposer.register(disposable, view) - - progressIndicator.setModalityProgress(null) - title = message("serverless.application.deploy_in_progress.title", stackName) - setOKButtonText(message("serverless.application.deploy.execute_change_set")) - setCancelButtonText(message("general.close_button")) - - super.init() - - deployFuture = executeDeployment().toCompletableFuture() - } - - override fun createActions(): Array = if (autoExecute) { - emptyArray() - } else { - super.createActions() - } - - override fun createCenterPanel(): JComponent? = view.content - - private fun executeDeployment(): CompletionStage { - okAction.isEnabled = false - cancelAction.isEnabled = false - - return runSamBuild() - .thenCompose { builtTemplate -> runSamPackage(builtTemplate) } - .thenCompose { packageTemplate -> runSamDeploy(packageTemplate) } - .thenApply { changeSet -> finish(changeSet) } - .exceptionally { e -> handleError(e) } - } - - private fun runSamBuild(): CompletionStage { - val buildDir = Paths.get(template.parent.path, SamCommon.SAM_BUILD_DIR, "build") - - Files.createDirectories(buildDir) - - return createBaseCommand().thenApply { - it - .withParameters("build") - .withParameters("--template") - .withParameters(template.path) - .withParameters("--build-dir") - .withParameters(buildDir.toString()) - .apply { - if (useContainer) { - withParameters("--use-container") - } - } - - it - }.thenCompose { - val builtTemplate = buildDir.resolve("template.yaml") - runCommand(message("serverless.application.deploy.step_name.build"), it) { builtTemplate } - } - } - - private fun runSamPackage(builtTemplateFile: Path): CompletionStage { - advanceStep() - val packagedTemplatePath = builtTemplateFile.parent.resolve("packaged-${builtTemplateFile.fileName}") - return createBaseCommand().thenApply { - it - .withParameters("package") - .withParameters("--template-file") - .withParameters(builtTemplateFile.toString()) - .withParameters("--output-template-file") - .withParameters(packagedTemplatePath.toString()) - .withParameters("--s3-bucket") - .withParameters(s3Bucket) - - it - }.thenCompose { - runCommand(message("serverless.application.deploy.step_name.package"), it) { packagedTemplatePath } - } - } - - private fun runSamDeploy(packagedTemplateFile: Path): CompletionStage { - advanceStep() - return createBaseCommand().thenApply { it -> - it.withParameters("deploy") - .withParameters("--template-file") - .withParameters(packagedTemplateFile.toString()) - .withParameters("--stack-name") - .withParameters(stackName) - - if (capabilities.isNotEmpty()) { - it.withParameters("--capabilities") - .withParameters(capabilities.map { it.capability }) - } - - it.withParameters("--no-execute-changeset") - - if (parameters.isNotEmpty()) { - it.withParameters("--parameter-overrides") - parameters.forEach { (key, value) -> - it.withParameters("$key=$value") - } - } - - it - }.thenCompose { command -> - runCommand(message("serverless.application.deploy.step_name.create_change_set"), command) { output -> - changeSetRegex.find(output.stdout)?.value - ?: throw RuntimeException(message("serverless.application.deploy.change_set_not_found")) - } - } - } - - private fun finish(changeSet: String): String = changeSet.also { - changeSetName = changeSet - progressIndicator.fraction = 1.0 - currentStep = NUMBER_OF_STEPS.toInt() - okAction.isEnabled = true - cancelAction.isEnabled = true - - runInEdt(ModalityState.any()) { - if (autoExecute) { - doOKAction() - } - } - - SamTelemetry.deploy( - project = project, - success = true, - version = SamCommon.getVersionString() - ) - } - - private fun handleError(error: Throwable): String { - LOG.warn(error) { "SAM deploy failed" } - - val message = if (error.cause is ProcessCanceledException) { - message("serverless.application.deploy.abort") - } else { - ExceptionUtil.getMessage(error) ?: message("general.unknown_error") - } - setErrorText(message) - - SamTelemetry.deploy( - project = project, - success = false, - version = SamCommon.getVersionString() - ) - - progressIndicator.cancel() - cancelAction.isEnabled = true - throw error - } - - private fun createBaseCommand(): CompletionStage { - val envVars = mutableMapOf() - envVars.putAll(region.toEnvironmentVariables()) - envVars.putAll(credentialsProvider.resolveCredentials().toEnvironmentVariables()) - - return ExecutableManager.getInstance().getExecutable().thenApply { - val samExecutable = when (it) { - is ExecutableInstance.Executable -> it - else -> throw RuntimeException((it as? ExecutableInstance.BadExecutable)?.validationError) - } - return@thenApply samExecutable - .getCommandLine() - .withWorkDirectory(template.parent.path) - .withEnvironment(envVars) - } - } - - private fun advanceStep() { - currentStep++ - progressIndicator.fraction = currentStep / NUMBER_OF_STEPS - } - - private fun runCommand( - title: String, - command: GeneralCommandLine, - result: (output: ProcessOutput) -> T - ): CompletionStage { - val consoleView = view.addLogTab(title) - val future = CompletableFuture() - val processHandler = createProcess(command) - - consoleView.attachToProcess(processHandler) - - ApplicationManager.getApplication().executeOnPooledThread { - val output = CapturingProcessRunner(processHandler).runProcess(progressIndicator) - if (output.exitCode == 0) { - try { - future.complete(result.invoke(output)) - } catch (e: Exception) { - future.completeExceptionally(e) - } - } else { - future.completeExceptionally(RuntimeException(message("serverless.application.deploy.execution_failed"))) - } - } - - return future - } - - private fun createProcess(command: GeneralCommandLine): OSProcessHandler = - ProcessHandlerFactory.getInstance().createColoredProcessHandler(command) - - private companion object { - const val NUMBER_OF_STEPS = 3.0 - val LOG = getLogger() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployView.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployView.form deleted file mode 100644 index 403b06bd21..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployView.form +++ /dev/null @@ -1,31 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployView.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployView.java deleted file mode 100644 index bfe43f6ca1..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/deploy/SamDeployView.java +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.deploy; - -import com.intellij.execution.filters.TextConsoleBuilder; -import com.intellij.execution.filters.TextConsoleBuilderFactory; -import com.intellij.execution.ui.ConsoleView; -import com.intellij.openapi.Disposable; -import com.intellij.openapi.actionSystem.ActionManager; -import com.intellij.openapi.actionSystem.ActionToolbar; -import com.intellij.openapi.actionSystem.DefaultActionGroup; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.wm.ex.ProgressIndicatorEx; -import com.intellij.ui.IdeBorderFactory; -import com.intellij.ui.components.JBTabbedPane; -import com.intellij.util.ui.JBUI; -import com.intellij.util.ui.UIUtil; - -import java.awt.AWTEvent; -import java.awt.BorderLayout; -import java.awt.event.MouseEvent; -import java.util.ArrayList; -import java.util.List; -import javax.swing.JComponent; -import javax.swing.JPanel; -import javax.swing.SwingUtilities; - -import software.aws.toolkits.jetbrains.ui.ProgressPanel; - -public class SamDeployView implements Disposable { - private final Project project; - private final ProgressIndicatorEx progressIndicator; - private final List consoleViews; - private boolean manuallySelectedTab; - ProgressPanel progressPanel; - JPanel content; - JBTabbedPane logTabs; - - public SamDeployView(Project project, ProgressIndicatorEx progressIndicator) { - this.project = project; - this.progressIndicator = progressIndicator; - this.manuallySelectedTab = false; - this.consoleViews = new ArrayList<>(); - } - - private void createUIComponents() { - progressPanel = new ProgressPanel(progressIndicator); - logTabs = new JBTabbedPane(); - logTabs.setTabComponentInsets(JBUI.emptyInsets()); - } - - public ConsoleView addLogTab(String title) { - TextConsoleBuilder builder = TextConsoleBuilderFactory.getInstance() - .createBuilder(project); - - builder.setViewer(false); - ConsoleView console = builder.getConsole(); - - consoleViews.add(console); - - UIUtil.invokeLaterIfNeeded(() -> { - JComponent consoleComponent = console.getComponent(); - consoleComponent.setBorder(IdeBorderFactory.createBorder()); - - DefaultActionGroup toolbarActions = new DefaultActionGroup(); - toolbarActions.addAll(console.createConsoleActions()); - - ActionToolbar toolbar = ActionManager.getInstance().createActionToolbar("SamDeployLogs", toolbarActions, false); - - JPanel logPanel = new JPanel(new BorderLayout()); - logPanel.setBorder(null); - logPanel.add(toolbar.getComponent(), BorderLayout.WEST); - logPanel.add(consoleComponent, BorderLayout.CENTER); - - // Serves the purpose of looking for click events that are on a child of the tab, so that we - // disable auto-switching tabs as progress proceeds - UIUtil.addAwtListener(event -> { - MouseEvent mouseEvent = (MouseEvent) event; - if (!UIUtil.isActionClick(mouseEvent)) { - return; - } - - if (SwingUtilities.isDescendingFrom(mouseEvent.getComponent(), logPanel)) { - manuallySelectedTab = true; - } - }, AWTEvent.MOUSE_EVENT_MASK, console); - - logTabs.addTab(title, logPanel); - - if (!manuallySelectedTab) { - logTabs.setSelectedIndex(logTabs.getTabCount() - 1); - } - }); - return console; - } - - @Override - public void dispose() { - consoleViews.forEach(ConsoleView::dispose); - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaExecutionUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaExecutionUtils.kt new file mode 100644 index 0000000000..dfee80ce65 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaExecutionUtils.kt @@ -0,0 +1,111 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.execution + +import com.intellij.execution.configurations.RuntimeConfigurationError +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.messages.MessageBus +import com.intellij.util.text.SemVer +import com.intellij.util.text.nullize +import software.aws.toolkits.core.lambda.LambdaArchitecture +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import software.aws.toolkits.jetbrains.services.lambda.validation.LambdaHandlerEvaluationListener +import software.aws.toolkits.jetbrains.services.lambda.validation.SamCliVersionEvaluationListener +import software.aws.toolkits.resources.message + +fun registerConfigValidationListeners(messageBus: MessageBus, parentDisposable: Disposable, validationCompleteCallback: (() -> Unit)) { + val connect = messageBus.connect(parentDisposable) + connect.subscribe( + LambdaHandlerEvaluationListener.TOPIC, + object : LambdaHandlerEvaluationListener { + override fun handlerValidationFinished(handlerName: String, isHandlerExists: Boolean) { + validationCompleteCallback() + } + } + ) + + connect.subscribe( + SamCliVersionEvaluationListener.TOPIC, + object : SamCliVersionEvaluationListener { + override fun samVersionValidationFinished(path: String, version: SemVer) { + validationCompleteCallback() + } + } + ) +} + +fun resolveLambdaFromHandler(handler: String?, runtime: String?, architecture: String?): ResolvedFunction { + handler ?: throw RuntimeConfigurationError(message("lambda.run_configuration.no_handler_specified")) + return ResolvedFunction(handler, runtime.validateSupportedRuntime(), architecture.validateSupportedArchitecture()) +} + +fun resolveLambdaFromTemplate(project: Project, templatePath: String?, functionName: String?): ResolvedFunction { + val (templateFile, logicalName) = validateSamTemplateDetails(templatePath, functionName) + + val function = SamTemplateUtils.findFunctionsFromTemplate(project, templateFile) + .find { it.logicalName == functionName } + ?: throw RuntimeConfigurationError( + message( + "lambda.run_configuration.sam.no_such_function", + logicalName, + templateFile.path + ) + ) + + val handler = tryOrNull { function.handler() } + ?: throw RuntimeConfigurationError(message("lambda.run_configuration.no_handler_specified")) + + val runtimeString = try { + function.runtime() + } catch (e: Exception) { + throw RuntimeConfigurationError(message("cloudformation.missing_property", "Runtime", logicalName)) + } + + val runtime = runtimeString.validateSupportedRuntime() + val architecture = function.architectures().validateSupportedArchitectures() + + return ResolvedFunction(handler, runtime, architecture) +} + +fun validateSamTemplateDetails(templatePath: String?, functionName: String?): Pair { + templatePath?.takeUnless { it.isEmpty() } + ?: throw RuntimeConfigurationError(message("lambda.run_configuration.sam.no_template_specified")) + + functionName ?: throw RuntimeConfigurationError(message("lambda.run_configuration.sam.no_function_specified")) + + val templateFile = LocalFileSystem.getInstance().refreshAndFindFileByPath(templatePath) + ?: throw RuntimeConfigurationError(message("lambda.run_configuration.sam.template_file_not_found")) + + return templateFile to functionName +} + +fun String?.validateSupportedRuntime(): LambdaRuntime { + val runtimeString = this.nullize() ?: throw RuntimeConfigurationError(message("lambda.run_configuration.no_runtime_specified")) + val runtime = LambdaRuntime.fromValue(runtimeString) + if (runtime?.runtimeGroup == null) { + throw RuntimeConfigurationError(message("lambda.run_configuration.unsupported_runtime", runtimeString)) + } + + return runtime +} + +fun List?.validateSupportedArchitectures(): LambdaArchitecture = this?.firstOrNull()?.validateSupportedArchitecture() ?: LambdaArchitecture.DEFAULT + +fun String?.validateSupportedArchitecture(): LambdaArchitecture { + val architectureString = this.nullize() ?: return LambdaArchitecture.DEFAULT + return LambdaArchitecture.fromValue(architectureString) + ?: throw RuntimeConfigurationError(message("lambda.run_configuration.unsupported_architecture", architectureString)) +} + +data class ResolvedFunction( + var handler: String, + var runtime: LambdaRuntime, + var architecture: LambdaArchitecture +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaInputPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaInputPanel.form index c196a436b8..2b4e42961f 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaInputPanel.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaInputPanel.form @@ -14,8 +14,8 @@ - - + +
@@ -23,7 +23,7 @@ - + @@ -33,7 +33,7 @@ - + @@ -41,7 +41,7 @@ - + @@ -51,8 +51,8 @@ - - + + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaInputPanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaInputPanel.java index 016114912c..dd25bd4184 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaInputPanel.java +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaInputPanel.java @@ -4,8 +4,6 @@ package software.aws.toolkits.jetbrains.services.lambda.execution; import static com.intellij.openapi.application.ActionsKt.runInEdt; -import static com.intellij.openapi.ui.Messages.CANCEL_BUTTON; -import static com.intellij.openapi.ui.Messages.OK_BUTTON; import static software.aws.toolkits.jetbrains.utils.ui.UiUtils.addQuickSelect; import static software.aws.toolkits.jetbrains.utils.ui.UiUtils.formatAndSet; import static software.aws.toolkits.resources.Localization.message; @@ -18,8 +16,8 @@ import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.ComboBox; +import com.intellij.openapi.ui.ComponentWithBrowseButton; import com.intellij.openapi.ui.Messages; -import com.intellij.openapi.ui.TextComponentAccessor; import com.intellij.openapi.ui.TextFieldWithBrowseButton; import com.intellij.openapi.util.text.StringUtil; import com.intellij.openapi.vfs.VfsUtil; @@ -33,6 +31,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.concurrent.CompletableFuture; +import javax.swing.JComboBox; import javax.swing.JPanel; import javax.swing.JRadioButton; import org.jetbrains.annotations.NotNull; @@ -40,7 +39,7 @@ import software.aws.toolkits.core.lambda.LambdaSampleEvent; import software.aws.toolkits.core.lambda.LambdaSampleEventProvider; import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider; -import software.aws.toolkits.jetbrains.ui.ProjectFileBrowseListener; +import software.aws.toolkits.jetbrains.ui.ProjectFileBrowseListenerKt; public class LambdaInputPanel { private static final Logger LOG = Logger.getInstance(LambdaInputPanel.class); @@ -57,6 +56,8 @@ public class LambdaInputPanel { EditorTextField inputText; JPanel panel; + private final int setWidthSize = 20; + public LambdaInputPanel(Project project) { this.project = project; @@ -70,8 +71,8 @@ public LambdaInputPanel(Project project) { int result = Messages.showOkCancelDialog(project, message("lambda.run_configuration.input.samples.confirm"), message("lambda.run_configuration.input.samples.confirm.title"), - OK_BUTTON, - CANCEL_BUTTON, + Messages.getOkButton(), + Messages.getCancelButton(), AllIcons.General.WarningDialog); if (result == Messages.CANCEL) { eventComboBoxModel.setSelectedItem(selected); @@ -99,12 +100,11 @@ public LambdaInputPanel(Project project) { addQuickSelect(inputTemplates.getButton(), useInputText, this::updateComponents); addQuickSelect(inputText.getComponent(), useInputText, this::updateComponents); - inputFile.addActionListener(new ProjectFileBrowseListener<>( + ProjectFileBrowseListenerKt.installTextFieldProjectFileBrowseListener( project, inputFile, - FileChooserDescriptorFactory.createSingleFileDescriptor(JsonFileType.INSTANCE), - TextComponentAccessor.TEXT_FIELD_WHOLE_TEXT - )); + FileChooserDescriptorFactory.createSingleFileDescriptor(JsonFileType.INSTANCE) + ); LambdaSampleEventProvider eventProvider = new LambdaSampleEventProvider(RemoteResourceResolverProvider.Companion.getInstance().get()); @@ -114,11 +114,10 @@ public LambdaInputPanel(Project project) { return null; })); - inputTemplates.addActionListener(new ProjectFileBrowseListener<>( + ProjectFileBrowseListenerKt.installComboBoxProjectFileBrowseListener( project, inputTemplates, FileChooserDescriptorFactory.createSingleFileDescriptor(JsonFileType.INSTANCE), - TextComponentAccessor.STRING_COMBOBOX_WHOLE_TEXT, chosenFile -> { try { String contents = VfsUtil.loadText(chosenFile); @@ -132,14 +131,14 @@ public LambdaInputPanel(Project project) { return null; // Required since lambda is defined in Kotlin } - )); - + ); updateComponents(); } private void createUIComponents() { inputText = EditorTextFieldProvider.getInstance().getEditorField(JsonLanguage.INSTANCE, project, Collections.emptyList()); + inputText.setPreferredWidth(setWidthSize); eventComboBoxModel = new SortedComboBoxModel<>(Comparator.comparing(LambdaSampleEvent::getName)); @@ -191,7 +190,7 @@ public String getInputText() { return StringUtil.nullize(inputText.getText().trim(), true); } - private class LocalLambdaSampleEvent extends LambdaSampleEvent { + private static class LocalLambdaSampleEvent extends LambdaSampleEvent { LocalLambdaSampleEvent(@NotNull String name, @NotNull String content) { super(name, () -> CompletableFuture.completedFuture(content)); } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaRunConfigurationType.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaRunConfigurationType.kt index d2e960aedc..99ea8bf6ad 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaRunConfigurationType.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/LambdaRunConfigurationType.kt @@ -7,7 +7,6 @@ import com.intellij.execution.configurations.ConfigurationTypeBase import com.intellij.execution.configurations.ConfigurationTypeUtil import icons.AwsIcons import software.aws.toolkits.jetbrains.core.help.HelpIds -import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver import software.aws.toolkits.jetbrains.services.lambda.execution.local.LocalLambdaRunConfigurationFactory import software.aws.toolkits.jetbrains.services.lambda.execution.remote.RemoteLambdaRunConfigurationFactory import software.aws.toolkits.resources.message @@ -20,16 +19,11 @@ class LambdaRunConfigurationType : AwsIcons.Resources.LAMBDA_FUNCTION ) { init { - // Although it should work, isApplicable doesn't seem to work for locallambdarunconfigurationfactory - // and it still shows up when it is not applicalbe. So we have to decide in the configuration to add it or not. - // TODO see if this is resolvable - if (LambdaHandlerResolver.supportedRuntimeGroups.isNotEmpty()) { - addFactory(LocalLambdaRunConfigurationFactory(this)) - } + addFactory(LocalLambdaRunConfigurationFactory(this)) addFactory(RemoteLambdaRunConfigurationFactory(this)) } - override fun getHelpTopic(): String? = HelpIds.RUN_DEBUG_CONFIGURATIONS_DIALOG.id + override fun getHelpTopic(): String = HelpIds.RUN_DEBUG_CONFIGURATIONS_DIALOG.id companion object { fun getInstance() = ConfigurationTypeUtil.findConfigurationType(LambdaRunConfigurationType::class.java) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaOptions.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaOptions.kt index d27262b684..e0a647e7da 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaOptions.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaOptions.kt @@ -15,6 +15,7 @@ import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions class LocalLambdaOptions : BaseLambdaOptions() { @get:Property(flat = true) // flat for backwards compat var functionOptions = FunctionOptions() + @get:Property(surroundWithTag = false) var samOptions = SamOptions() var debugHost = "localhost" @@ -23,12 +24,22 @@ class LocalLambdaOptions : BaseLambdaOptions() { @Tag("FunctionOptions") class FunctionOptions { var useTemplate = false + var platform: String? = null + var isImage: Boolean = false + var pathMappings: List = listOf() var templateFile: String? = null + @get:OptionTag("logicalFunctionName") var logicalId: String? = null var runtime: String? = null + var architecture: String? = null var handler: String? = null var timeout: Int = DEFAULT_TIMEOUT var memorySize: Int = DEFAULT_MEMORY_SIZE var environmentVariables: Map = linkedMapOf() } + +data class PersistedPathMapping( + var local: String? = null, + var remote: String? = null +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunConfiguration.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunConfiguration.kt index 43a9d498aa..af7b87dfdf 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunConfiguration.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunConfiguration.kt @@ -15,55 +15,61 @@ import com.intellij.openapi.options.SettingsEditor import com.intellij.openapi.options.SettingsEditorGroup import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.LocalFileSystem -import com.intellij.openapi.vfs.VfsUtil -import com.intellij.psi.NavigatablePsiElement import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.listeners.RefactoringElementAdapter import com.intellij.refactoring.listeners.RefactoringElementListener -import com.intellij.util.text.nullize +import com.intellij.util.PathMappingSettings.PathMapping +import com.intellij.util.text.SemVer import org.jetbrains.concurrency.isPending +import software.amazon.awssdk.services.lambda.LambdaClient import software.amazon.awssdk.services.lambda.model.Runtime -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.info -import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.lambda.LambdaArchitecture +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.core.lambda.validOrNull import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance import software.aws.toolkits.jetbrains.core.executables.ExecutableManager import software.aws.toolkits.jetbrains.core.executables.getExecutableIfPresent +import software.aws.toolkits.jetbrains.services.cloudformation.SamFunction import software.aws.toolkits.jetbrains.services.lambda.Lambda.findPsiElementsForHandler import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver -import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup import software.aws.toolkits.jetbrains.services.lambda.execution.LambdaRunConfigurationBase import software.aws.toolkits.jetbrains.services.lambda.execution.LambdaRunConfigurationType +import software.aws.toolkits.jetbrains.services.lambda.execution.ResolvedFunction +import software.aws.toolkits.jetbrains.services.lambda.execution.resolveLambdaFromHandler +import software.aws.toolkits.jetbrains.services.lambda.execution.resolveLambdaFromTemplate +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.HandlerRunSettings +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.ImageDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.ImageTemplateRunSettings +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.LocalLambdaRunSettings +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.SamRunningState +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.SamSettingsEditor +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.SamSettingsRunConfiguration +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.TemplateRunSettings +import software.aws.toolkits.jetbrains.services.lambda.execution.validateSamTemplateDetails +import software.aws.toolkits.jetbrains.services.lambda.minSamVersion import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon 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.jetbrains.services.lambda.validOrNull import software.aws.toolkits.jetbrains.services.lambda.validation.LambdaHandlerEvaluationListener import software.aws.toolkits.jetbrains.services.lambda.validation.LambdaHandlerValidator import software.aws.toolkits.jetbrains.ui.connection.AwsConnectionSettingsEditor import software.aws.toolkits.jetbrains.ui.connection.addAwsConnectionEditor import software.aws.toolkits.resources.message -import java.nio.file.Path +import java.nio.file.Paths class LocalLambdaRunConfigurationFactory(configuration: LambdaRunConfigurationType) : ConfigurationFactory(configuration) { override fun createTemplateConfiguration(project: Project) = LocalLambdaRunConfiguration(project, this) override fun getName(): String = "Local" - // Overwitten because it was deprecated in 2020.1 override fun getId(): String = name } class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactory) : LambdaRunConfigurationBase(project, factory, "SAM CLI"), - RefactoringListenerProvider { - - companion object { - private val logger = getLogger() - } - + RefactoringListenerProvider, + SamSettingsRunConfiguration { private val messageBus = project.messageBus override val serializableOptions = LocalLambdaOptions() @@ -72,69 +78,158 @@ class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactor val group = SettingsEditorGroup() group.addEditor(ExecutionBundle.message("run.configuration.configuration.tab.title"), LocalLambdaRunSettingsEditor(project)) group.addEditor(message("lambda.run_configuration.sam"), SamSettingsEditor()) - group.addAwsConnectionEditor(AwsConnectionSettingsEditor(project)) + group.addAwsConnectionEditor(AwsConnectionSettingsEditor(project, LambdaClient.SERVICE_NAME)) return group } override fun checkConfiguration() { - checkSamVersion() resolveRegion() resolveCredentials() - checkLambdaHandler() checkInput() + if (isImage) { + validateSamTemplateDetails(templateFile(), logicalId()) + checkImageSamVersion() + checkImageDebugger() + } else { + val function = resolveLambdaInfo(project = project, functionOptions = serializableOptions.functionOptions) + checkRuntimeSamVersion(function.runtime) + checkArchitectureSamVersion(function.architecture) + // If we aren't using a template we need to be able to find the handler. If it's a template, we don't care. + if (!serializableOptions.functionOptions.useTemplate) { + checkLambdaHandler(function.handler, function.runtime) + } + } } - private fun checkSamVersion() { - ExecutableManager.getInstance().getExecutableIfPresent().let { - when (it) { - is ExecutableInstance.Executable -> it - is ExecutableInstance.InvalidExecutable, is ExecutableInstance.UnresolvedExecutable -> throw RuntimeConfigurationError( - (it as? ExecutableInstance.BadExecutable)?.validationError - ) + private fun checkImageSamVersion() { + val executable = getSam() + + SemVer.parseFromText(executable.version)?.let { semVer -> + if (semVer < SamCommon.minImageVersion) { + throw RuntimeConfigurationError(message("lambda.image.sam_version_too_low", semVer, SamCommon.minImageVersion)) + } + } + } + + private fun checkImageDebugger() { + imageDebugger() ?: throw RuntimeConfigurationError(message("lambda.image.missing_debugger", rawImageDebugger().toString())) + } + + private fun checkRuntimeSamVersion(runtime: LambdaRuntime) { + val executable = getSam() + + runtime.runtimeGroup?.let { runtimeGroup -> + SemVer.parseFromText(executable.version)?.let { semVer -> + // TODO: Executable manager should better expose the VersionScheme of the Executable... + try { + runtimeGroup.validateSamVersionForZipDebugging(runtime, semVer) + } catch (e: Exception) { + throw RuntimeConfigurationError(e.message) + } } } } - private fun checkLambdaHandler() { + private fun checkArchitectureSamVersion(architecture: LambdaArchitecture) { + val executable = getSam() + + SemVer.parseFromText(executable.version)?.let { semVer -> + val architectureMinSam = architecture.minSamVersion() + if (semVer < architectureMinSam) { + throw RuntimeConfigurationError(message("sam.executable.minimum_too_low_architecture", architecture, architectureMinSam)) + } + } + } + + private fun getSam() = ExecutableManager.getInstance().getExecutableIfPresent().let { + when (it) { + is ExecutableInstance.Executable -> it + is ExecutableInstance.InvalidExecutable, is ExecutableInstance.UnresolvedExecutable -> throw RuntimeConfigurationError( + (it as? ExecutableInstance.BadExecutable)?.validationError + ) + } + } + + private fun checkLambdaHandler(handler: String, runtime: LambdaRuntime): LambdaRuntime { val handlerValidator = project.service() - val (handler, runtime) = resolveLambdaInfo(project = project, functionOptions = serializableOptions.functionOptions) - val promise = handlerValidator.evaluate(LambdaHandlerValidator.LambdaEntry(project, runtime, handler)) + val sdkRuntime = runtime.toSdkRuntime() ?: throw IllegalStateException("Cannot map runtime $runtime to SDK runtime.") + val promise = handlerValidator.evaluate(LambdaHandlerValidator.LambdaEntry(project, sdkRuntime, handler)) if (promise.isPending) { promise.then { isValid -> messageBus.syncPublisher(LambdaHandlerEvaluationListener.TOPIC).handlerValidationFinished(handler, isValid) } - logger.info { "Validation will proceed asynchronously for SAM CLI version" } throw RuntimeConfigurationError(message("lambda.run_configuration.handler.validation.in_progress")) } val isHandlerValid = promise.blockingGet(0)!! - if (!isHandlerValid) + if (!isHandlerValid) { throw RuntimeConfigurationError(message("lambda.run_configuration.handler_not_found", handler)) + } + + return runtime } override fun getState(executor: Executor, environment: ExecutionEnvironment): SamRunningState { try { - val (handler, runtime, templateDetails) = resolveLambdaInfo(project = project, functionOptions = serializableOptions.functionOptions) - val psiElement = handlerPsiElement(handler, runtime) - ?: throw RuntimeConfigurationError(message("lambda.run_configuration.handler_not_found", handler)) - - val samRunSettings = LocalLambdaRunSettings( - runtime, - handler, - resolveInput(), - timeout(), - memorySize(), - environmentVariables(), - resolveCredentials(), - resolveRegion(), - psiElement, - templateDetails, - serializableOptions.samOptions.copy(), - serializableOptions.debugHost - ) - - return SamRunningState(environment, samRunSettings) + val options: LocalLambdaRunSettings = if (serializableOptions.functionOptions.useTemplate) { + if (serializableOptions.functionOptions.isImage) { + val (templateFile, logicalName) = validateSamTemplateDetails(templateFile(), logicalId()) + + val resource = SamTemplateUtils + .findImageFunctionsFromTemplate(project, templateFile) + .firstOrNull { it.logicalName == logicalId() } ?: throw IllegalStateException("Function ${logicalId()} not found in template!") + val function = resource as? SamFunction ?: throw IllegalStateException("Image functions must be a SAM function") + + val debugger = imageDebugger() ?: throw IllegalStateException("No image debugger with ID ${rawImageDebugger()}") + + val dockerFile = function.dockerFile() ?: "Dockerfile" + val dockerFilePath = Paths.get(templateFile.path).parent.resolve(function.codeLocation()).resolve(dockerFile) + ImageTemplateRunSettings( + templateFile, + debugger, + logicalName, + LocalFileSystem.getInstance().refreshAndFindFileByIoFile(dockerFilePath.toFile()) + ?: throw IllegalStateException("Unable to get virtual file for path $dockerFilePath"), + pathMappings, + environmentVariables(), + ConnectionSettings(resolveCredentials(), resolveRegion()), + serializableOptions.samOptions.copy(), + serializableOptions.debugHost, + resolveInput() + ) + } else { + val (templateFile, logicalId) = validateSamTemplateDetails(templateFile(), logicalId()) + val resolvedFunction = resolveLambdaInfo(project = project, functionOptions = serializableOptions.functionOptions) + TemplateRunSettings( + templateFile, + resolvedFunction.runtime, + resolvedFunction.architecture, + resolvedFunction.handler, + logicalId, + environmentVariables(), + ConnectionSettings(resolveCredentials(), resolveRegion()), + serializableOptions.samOptions.copy(), + serializableOptions.debugHost, + resolveInput() + ) + } + } else { + val resolvedFunction = resolveLambdaInfo(project = project, functionOptions = serializableOptions.functionOptions) + HandlerRunSettings( + resolvedFunction.runtime, + resolvedFunction.architecture, + resolvedFunction.handler, + timeout(), + memorySize(), + environmentVariables(), + ConnectionSettings(resolveCredentials(), resolveRegion()), + serializableOptions.samOptions.copy(), + serializableOptions.debugHost, + resolveInput() + ) + } + return SamRunningState(environment, options) } catch (e: Exception) { throw ExecutionException(e.message, e) } @@ -143,7 +238,7 @@ class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactor override fun getRefactoringElementListener(element: PsiElement?): RefactoringElementListener? { element?.run { val handlerResolver = element.language.runtimeGroup?.let { runtimeGroup -> - LambdaHandlerResolver.getInstance(runtimeGroup) + LambdaHandlerResolver.getInstanceOrNull(runtimeGroup) } ?: return null val handlerPsi = handlerPsiElement() ?: return null @@ -167,7 +262,7 @@ class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactor return null } - fun useTemplate(templateLocation: String?, logicalId: String?) { + fun useTemplate(templateLocation: String?, logicalId: String?, runtime: String? = null) { val functionOptions = serializableOptions.functionOptions functionOptions.useTemplate = true @@ -175,7 +270,7 @@ class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactor functionOptions.logicalId = logicalId functionOptions.handler = null - functionOptions.runtime = null + functionOptions.runtime = runtime } fun useHandler(runtime: Runtime?, handler: String?) { @@ -197,7 +292,35 @@ class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactor fun handler() = serializableOptions.functionOptions.handler - fun runtime(): Runtime? = Runtime.fromValue(serializableOptions.functionOptions.runtime)?.validOrNull + fun runtime(): LambdaRuntime? = LambdaRuntime.fromValue(serializableOptions.functionOptions.runtime) + + /* + * This is only to be called for Image functions, otherwise we do not store the runtime for ZIP based template functions + * This is one of the things that needs to be cleaned up when we migrate the underlying representation + */ + fun runtime(runtime: Runtime?) { + serializableOptions.functionOptions.runtime = runtime?.toString() + } + + fun runtime(runtime: LambdaRuntime?) { + serializableOptions.functionOptions.runtime = runtime?.toString() + } + + fun architecture() = serializableOptions.functionOptions.architecture + + fun architecture(architecture: LambdaArchitecture?) { + serializableOptions.functionOptions.architecture = architecture?.toString() + } + + fun imageDebugger(): ImageDebugSupport? = serializableOptions.functionOptions.runtime?.let { + ImageDebugSupport.debuggers().get(it) + } + + private fun rawImageDebugger(): String? = serializableOptions.functionOptions.runtime + + fun imageDebugger(imageDebugger: ImageDebugSupport?) { + serializableOptions.functionOptions.runtime = imageDebugger?.id + } fun timeout() = serializableOptions.functionOptions.timeout @@ -217,41 +340,58 @@ class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactor serializableOptions.functionOptions.environmentVariables = envVars } - fun dockerNetwork(): String? = serializableOptions.samOptions.dockerNetwork - - fun dockerNetwork(network: String?) { - serializableOptions.samOptions.dockerNetwork = network - } - - fun debugHost(): String = serializableOptions.debugHost - - fun debugHost(host: String) { - serializableOptions.debugHost = host - } - - fun skipPullImage(): Boolean = serializableOptions.samOptions.skipImagePull + /* + * This is only needed in `getState` during runtime. Although it is persisted, we don't actually + * care about reading the value back off of disk. + * TODO when we have a template registry remove this + */ + var isImage: Boolean + get() = serializableOptions.functionOptions.isImage + set(value) { + serializableOptions.functionOptions.isImage = value + } - fun skipPullImage(skip: Boolean) { - serializableOptions.samOptions.skipImagePull = skip - } + var pathMappings: List + get() = serializableOptions.functionOptions.pathMappings.map { PathMapping(it.local, it.remote) } + set(list) { + serializableOptions.functionOptions.pathMappings = list.map { PersistedPathMapping(it.localRoot, it.remoteRoot) } + } - fun buildInContainer(): Boolean = serializableOptions.samOptions.buildInContainer + override var dockerNetwork: String? + get() = serializableOptions.samOptions.dockerNetwork + set(network) { + serializableOptions.samOptions.dockerNetwork = network + } - fun buildInContainer(useContainer: Boolean) { - serializableOptions.samOptions.buildInContainer = useContainer - } + override var debugHost: String + get() = serializableOptions.debugHost + set(host) { + serializableOptions.debugHost = host + } - fun additionalBuildArgs(): String? = serializableOptions.samOptions.additionalBuildArgs + override var skipPullImage: Boolean + get() = serializableOptions.samOptions.skipImagePull + set(skip) { + serializableOptions.samOptions.skipImagePull = skip + } - fun additionalBuildArgs(args: String?) { - serializableOptions.samOptions.additionalBuildArgs = args - } + override var buildInContainer: Boolean + get() = serializableOptions.samOptions.buildInContainer + set(useContainer) { + serializableOptions.samOptions.buildInContainer = useContainer + } - fun additionalLocalArgs(): String? = serializableOptions.samOptions.additionalLocalArgs + override var additionalBuildArgs: String? + get() = serializableOptions.samOptions.additionalBuildArgs + set(args) { + serializableOptions.samOptions.additionalBuildArgs = args + } - fun additionalLocalArgs(args: String?) { - serializableOptions.samOptions.additionalLocalArgs = args - } + override var additionalLocalArgs: String? + get() = serializableOptions.samOptions.additionalLocalArgs + set(args) { + serializableOptions.samOptions.additionalLocalArgs = args + } override fun suggestedName(): String? { val subName = serializableOptions.functionOptions.logicalId ?: handlerDisplayName() @@ -261,63 +401,14 @@ class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactor private fun handlerDisplayName(): String? { val handler = serializableOptions.functionOptions.handler ?: return null return runtime() + ?.toSdkRuntime() + .validOrNull ?.runtimeGroup - ?.let { LambdaHandlerResolver.getInstance(it) } + ?.let { LambdaHandlerResolver.getInstanceOrNull(it) } ?.handlerDisplayName(handler) ?: handler } - private fun resolveLambdaFromTemplate(project: Project, templatePath: String?, functionName: String?): Triple { - templatePath?.takeUnless { it.isEmpty() } - ?: throw RuntimeConfigurationError(message("lambda.run_configuration.sam.no_template_specified")) - - functionName ?: throw RuntimeConfigurationError(message("lambda.run_configuration.sam.no_function_specified")) - - val templateFile = LocalFileSystem.getInstance().refreshAndFindFileByPath(templatePath) - ?: throw RuntimeConfigurationError(message("lambda.run_configuration.sam.template_file_not_found")) - - val function = SamTemplateUtils.findFunctionsFromTemplate( - project, - templateFile - ).find { it.logicalName == functionName } - ?: throw RuntimeConfigurationError( - message( - "lambda.run_configuration.sam.no_such_function", - functionName, - templateFile.path - ) - ) - - val handler = tryOrNull { function.handler() } - ?: throw RuntimeConfigurationError(message("lambda.run_configuration.no_handler_specified")) - - val runtimeString = try { - function.runtime() - } catch (e: Exception) { - throw RuntimeConfigurationError(message("cloudformation.missing_property", "Runtime", functionName)) - } - - val runtime = runtimeString.validateSupportedRuntime() - - return Triple(handler, runtime, SamTemplateDetails(VfsUtil.virtualToIoFile(templateFile).toPath(), functionName)) - } - - private fun resolveLambdaFromHandler(handler: String?, runtime: String?): Triple { - handler ?: throw RuntimeConfigurationError(message("lambda.run_configuration.no_handler_specified")) - - return Triple(handler, runtime.validateSupportedRuntime(), null) - } - - private fun String?.validateSupportedRuntime(): Runtime { - val runtimeString = this.nullize() ?: throw RuntimeConfigurationError(message("lambda.run_configuration.no_runtime_specified")) - val runtime = Runtime.fromValue(runtimeString) - if (runtime.runtimeGroup == null) { - throw RuntimeConfigurationError(message("lambda.run_configuration.unsupported_runtime", runtimeString)) - } - - return runtime - } - - private fun resolveLambdaInfo(project: Project, functionOptions: FunctionOptions): Triple = + private fun resolveLambdaInfo(project: Project, functionOptions: FunctionOptions): ResolvedFunction = if (functionOptions.useTemplate) { resolveLambdaFromTemplate( project = project, @@ -327,11 +418,12 @@ class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactor } else { resolveLambdaFromHandler( handler = functionOptions.handler, - runtime = functionOptions.runtime + runtime = functionOptions.runtime, + architecture = functionOptions.architecture ) } - private fun handlerPsiElement(handler: String? = handler(), runtime: Runtime? = runtime()) = try { + private fun handlerPsiElement(handler: String? = handler(), runtime: Runtime? = runtime()?.toSdkRuntime()) = try { runtime?.let { handler?.let { findPsiElementsForHandler(project, runtime, handler).firstOrNull() @@ -341,23 +433,3 @@ class LocalLambdaRunConfiguration(project: Project, factory: ConfigurationFactor null } } - -data class LocalLambdaRunSettings( - val runtime: Runtime, - val handler: String, - val input: String, - val timeout: Int, - val memorySize: Int, - val environmentVariables: Map, - val credentials: ToolkitCredentialsProvider, - val region: AwsRegion, - val handlerElement: NavigatablePsiElement, - val templateDetails: SamTemplateDetails?, - val samOptions: SamOptions, - val debugHost: String -) { - val runtimeGroup: RuntimeGroup = runtime.runtimeGroup - ?: throw IllegalStateException("Attempting to run SAM for unsupported runtime $runtime") -} - -data class SamTemplateDetails(val templateFile: Path, val logicalName: String) 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 2deb337efd..65dee90545 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 @@ -8,7 +8,9 @@ import com.intellij.execution.RunnerAndConfigurationSettings import com.intellij.execution.actions.ConfigurationContext import com.intellij.execution.actions.LazyRunConfigurationProducer import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.openapi.module.ModuleUtilCore import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.TestSourcesFilter import com.intellij.openapi.util.Ref import com.intellij.psi.PsiElement import org.jetbrains.yaml.psi.YAMLKeyValue @@ -16,7 +18,6 @@ import org.jetbrains.yaml.psi.YAMLPsiElement import software.aws.toolkits.core.region.AwsRegion 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 import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils.functionFromElement @@ -52,15 +53,25 @@ class LocalLambdaRunConfigurationProducer : LazyRunConfigurationProducer().clearRequests() - - val supported = LambdaBuilder.supportedRuntimeGroups.flatMap { it.runtimes }.sorted() - val selected = RuntimeGroup.determineRuntime(project)?.let { if (it in supported) it else null } view = LocalLambdaRunSettingsEditorPanel(project) - - view.setRuntimes(supported) - view.runtime.selectedItem = selected - - invalidateOnProfileValidationFinished(project.messageBus) + registerConfigValidationListeners(project.messageBus, this) { view.invalidateConfiguration() } } override fun createEditor(): JComponent = view.panel override fun resetEditorFrom(configuration: LocalLambdaRunConfiguration) { - view.useTemplate.isSelected = configuration.isUsingTemplate() - if (configuration.isUsingTemplate()) { - view.runtime.isEnabled = false - view.setTemplateFile(configuration.templateFile()) - view.selectFunction(configuration.logicalId()) + val useTemplate = configuration.isUsingTemplate() + view.useTemplate = useTemplate + if (useTemplate) { + view.templateSettings.setTemplateFile(configuration.templateFile()) + view.templateSettings.selectFunction(configuration.logicalId()) + view.templateSettings.environmentVariables.envVars = configuration.environmentVariables() + if (view.templateSettings.isImage) { + view.templateSettings.imageDebuggerModel.selectedItem = configuration.imageDebugger() + view.templateSettings.pathMappingsTable.setMappingSettings(PathMappingSettings(configuration.pathMappings)) + } } else { - view.setTemplateFile(null) // Also clears the functions selector - view.runtime.model.selectedItem = configuration.runtime() - view.handlerPanel.handler.text = configuration.handler() ?: "" + view.rawSettings.runtimeModel.selectedItem = configuration.runtime() + view.rawSettings.architectureModel.selectedItem = configuration.architecture()?.validateSupportedArchitecture() + view.rawSettings.handlerPanel.handler.text = configuration.handler() ?: "" + view.rawSettings.timeoutSlider.value = configuration.timeout() + view.rawSettings.memorySlider.value = configuration.memorySize() + view.rawSettings.environmentVariables.envVars = configuration.environmentVariables() } - view.timeoutSlider.value = configuration.timeout() - view.memorySlider.value = configuration.memorySize() - view.environmentVariables.envVars = configuration.environmentVariables() - if (configuration.isUsingInputFile()) { view.lambdaInput.inputFile = configuration.inputSource() } else { @@ -61,38 +55,26 @@ class LocalLambdaRunSettingsEditor(project: Project) : SettingsEditor
- + @@ -8,93 +8,30 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + - + - + - - - - - + + - + - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -102,7 +39,7 @@ - + @@ -114,50 +51,14 @@ - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -167,6 +68,18 @@ + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunSettingsEditorPanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunSettingsEditorPanel.java deleted file mode 100644 index 88382cef67..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunSettingsEditorPanel.java +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.execution.local; - -import static software.aws.toolkits.jetbrains.utils.ui.UiUtils.addQuickSelect; -import static software.aws.toolkits.jetbrains.utils.ui.UiUtils.find; -import static software.aws.toolkits.resources.Localization.message; - -import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.ComboBox; -import com.intellij.openapi.ui.TextComponentAccessor; -import com.intellij.openapi.ui.TextFieldWithBrowseButton; -import com.intellij.ui.EditorTextField; -import com.intellij.ui.IdeBorderFactory; -import com.intellij.ui.SortedComboBoxModel; -import com.intellij.util.ui.JBUI; -import com.intellij.util.ui.UIUtil; -import java.io.File; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import javax.swing.DefaultComboBoxModel; -import javax.swing.JCheckBox; -import javax.swing.JComboBox; -import javax.swing.JPanel; -import javax.swing.SwingUtilities; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.yaml.YAMLFileType; -import software.amazon.awssdk.services.lambda.model.Runtime; -import software.aws.toolkits.core.utils.ExceptionUtils; -import software.aws.toolkits.jetbrains.services.cloudformation.Function; -import software.aws.toolkits.jetbrains.services.lambda.LambdaWidgets; -import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroupUtil; -import software.aws.toolkits.jetbrains.services.lambda.execution.LambdaInputPanel; -import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils; -import software.aws.toolkits.jetbrains.ui.EnvironmentVariablesTextField; -import software.aws.toolkits.jetbrains.ui.HandlerPanel; -import software.aws.toolkits.jetbrains.ui.ProjectFileBrowseListener; -import software.aws.toolkits.jetbrains.ui.SliderPanel; - -public final class LocalLambdaRunSettingsEditorPanel { - public JPanel panel; - public HandlerPanel handlerPanel; - public EnvironmentVariablesTextField environmentVariables; - private SortedComboBoxModel runtimeModel; - public JComboBox runtime; - public LambdaInputPanel lambdaInput; - public JCheckBox useTemplate; - public JComboBox function; - private DefaultComboBoxModel functionModels; - public TextFieldWithBrowseButton templateFile; - public JPanel lambdaInputPanel; - public SliderPanel timeoutSlider; - public SliderPanel memorySlider; - public JCheckBox invalidator; - - private Runtime lastSelectedRuntime = null; - - private final Project project; - - public LocalLambdaRunSettingsEditorPanel(Project project) { - this.project = project; - - lambdaInputPanel.setBorder(IdeBorderFactory.createTitledBorder(message("lambda.input.label"), false, JBUI.emptyInsets())); - useTemplate.addActionListener(e -> updateComponents()); - addQuickSelect(templateFile.getTextField(), useTemplate, this::updateComponents); - templateFile.addActionListener(new ProjectFileBrowseListener<>( - project, - templateFile, - FileChooserDescriptorFactory.createSingleFileDescriptor(YAMLFileType.YML), - TextComponentAccessor.TEXT_FIELD_WHOLE_TEXT - )); - - runtime.addActionListener(e -> { - int index = runtime.getSelectedIndex(); - if (index < 0) { - lastSelectedRuntime = null; - return; - } - Runtime selectedRuntime = runtime.getItemAt(index); - if (selectedRuntime == lastSelectedRuntime) return; - lastSelectedRuntime = selectedRuntime; - handlerPanel.setRuntime(selectedRuntime); - }); - - updateComponents(); - } - - private void createUIComponents() { - handlerPanel = new HandlerPanel(project); - lambdaInput = new LambdaInputPanel(project); - functionModels = new DefaultComboBoxModel<>(); - function = new ComboBox<>(functionModels); - function.addActionListener(e -> updateComponents()); - - runtimeModel = new SortedComboBoxModel<>(Comparator.comparing(Runtime::toString, Comparator.naturalOrder())); - runtime = new ComboBox<>(runtimeModel); - environmentVariables = new EnvironmentVariablesTextField(); - timeoutSlider = LambdaWidgets.lambdaTimeout(); - memorySlider = LambdaWidgets.lambdaMemory(); - } - - private void updateComponents() { - EditorTextField handler = handlerPanel.getHandler(); - - handlerPanel.setEnabled(!useTemplate.isSelected()); - runtime.setEnabled(!useTemplate.isSelected()); - templateFile.setEnabled(useTemplate.isSelected()); - timeoutSlider.setEnabled(!useTemplate.isSelected()); - memorySlider.setEnabled(!useTemplate.isSelected()); - - if (useTemplate.isSelected()) { - handler.setBackground(UIUtil.getComboBoxDisabledBackground()); - handler.setForeground(UIUtil.getComboBoxDisabledForeground()); - - if (functionModels.getSelectedItem() instanceof Function) { - Function selected = (Function) functionModels.getSelectedItem(); - handler.setText(selected.handler()); - Integer memorySize = selected.memorySize(); - Integer timeout = selected.timeout(); - if (memorySize != null) { - memorySlider.setValue(memorySize); - } - if (timeout != null) { - timeoutSlider.setValue(timeout); - } - - Runtime runtime = Runtime.fromValue(ExceptionUtils.tryOrNull(selected::runtime)); - runtimeModel.setSelectedItem(RuntimeGroupUtil.getValidOrNull(runtime)); - - function.setEnabled(true); - } - } else { - handler.setBackground(UIUtil.getTextFieldBackground()); - handler.setForeground(UIUtil.getTextFieldForeground()); - function.setEnabled(false); - } - } - - public void setTemplateFile(@Nullable String file) { - if (file == null) { - templateFile.setText(""); - updateFunctionModel(Collections.emptyList(), false); - } else { - templateFile.setText(file); - List functions = SamTemplateUtils.findFunctionsFromTemplate(project, new File(file)); - updateFunctionModel(functions, false); - } - } - - private void updateFunctionModel(List functions, boolean selectSingle) { - functionModels.removeAllElements(); - function.setEnabled(!functions.isEmpty()); - functions.forEach(functionModels::addElement); - if (selectSingle && functions.size() == 1) { - functionModels.setSelectedItem(functions.get(0)); - } else { - function.setSelectedIndex(-1); - } - updateComponents(); - } - - public void selectFunction(@Nullable String logicalFunctionName) { - if (logicalFunctionName == null) return; - Function function = find(functionModels, f -> f.getLogicalName().equals(logicalFunctionName)); - if (function != null) { - functionModels.setSelectedItem(function); - updateComponents(); - } - } - - public void setRuntimes(List runtimes) { - runtimeModel.setAll(runtimes); - } - - public void invalidateConfiguration() { - SwingUtilities.invokeLater(() -> invalidator.setSelected(!invalidator.isSelected())); - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunSettingsEditorPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunSettingsEditorPanel.kt new file mode 100644 index 0000000000..967b901865 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/LocalLambdaRunSettingsEditorPanel.kt @@ -0,0 +1,66 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.lambda.execution.local + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.components.JBRadioButton +import com.intellij.ui.components.panels.Wrapper +import com.intellij.util.ui.JBUI +import software.aws.toolkits.jetbrains.services.lambda.execution.LambdaInputPanel +import software.aws.toolkits.resources.message +import javax.swing.JCheckBox +import javax.swing.JPanel + +class LocalLambdaRunSettingsEditorPanel(private val project: Project) { + lateinit var panel: JPanel + private set + lateinit var invalidator: JCheckBox + private set + lateinit var lambdaInputPanel: JPanel + private set + lateinit var lambdaInput: LambdaInputPanel + private set + private lateinit var raw: JBRadioButton + private lateinit var template: JBRadioButton + private lateinit var settings: Wrapper + + val rawSettings = RawSettings(project) + val templateSettings = TemplateSettings(project) + var useTemplate: Boolean + get() = template.isSelected + set(value) { + if (value) { + template.isSelected = true + settings.setContent(templateSettings.panel) + } else { + raw.isSelected = true + settings.setContent(rawSettings.panel) + } + } + + private fun createUIComponents() { + lambdaInput = LambdaInputPanel(project) + } + + init { + template.addActionListener { + settings.setContent(templateSettings.panel) + invalidateConfiguration() + } + raw.addActionListener { + settings.setContent(rawSettings.panel) + invalidateConfiguration() + } + // Select template by default + template.isSelected = true + settings.setContent(templateSettings.panel) + + lambdaInputPanel.border = IdeBorderFactory.createTitledBorder(message("lambda.input.label"), false, JBUI.emptyInsets()) + } + + fun invalidateConfiguration() { + runInEdt { invalidator.isSelected = !invalidator.isSelected } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/RawSettings.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/RawSettings.form new file mode 100644 index 0000000000..53ccceef5f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/RawSettings.form @@ -0,0 +1,108 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/RawSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/RawSettings.kt new file mode 100644 index 0000000000..4acfcbdf0c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/RawSettings.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.lambda.execution.local + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.SortedComboBoxModel +import software.aws.toolkits.core.lambda.LambdaArchitecture +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder +import software.aws.toolkits.jetbrains.services.lambda.LambdaWidgets.lambdaMemory +import software.aws.toolkits.jetbrains.services.lambda.LambdaWidgets.lambdaTimeout +import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup +import software.aws.toolkits.jetbrains.ui.HandlerPanel +import software.aws.toolkits.jetbrains.ui.KeyValueTextField +import software.aws.toolkits.jetbrains.ui.SliderPanel +import java.util.Comparator +import javax.swing.JComboBox +import javax.swing.JPanel + +class RawSettings(private val project: Project) { + lateinit var panel: JPanel + private set + lateinit var handlerPanel: HandlerPanel + private set + lateinit var runtime: JComboBox + private set + lateinit var architecture: JComboBox + private set + lateinit var timeoutSlider: SliderPanel + private set + lateinit var memorySlider: SliderPanel + private set + lateinit var environmentVariables: KeyValueTextField + private set + lateinit var runtimeModel: SortedComboBoxModel + private set + lateinit var architectureModel: CollectionComboBoxModel + private set + + var lastSelectedRuntime: LambdaRuntime? = null + + private fun createUIComponents() { + runtimeModel = SortedComboBoxModel(compareBy(Comparator.naturalOrder()) { it.toString() }) + architectureModel = CollectionComboBoxModel(LambdaArchitecture.values().toList()) + runtime = ComboBox(runtimeModel) + architecture = ComboBox(architectureModel) + handlerPanel = HandlerPanel(project) + timeoutSlider = lambdaTimeout() + memorySlider = lambdaMemory() + environmentVariables = KeyValueTextField() + } + + init { + runtime.addActionListener { + val index = runtime.selectedIndex + if (index < 0) { + lastSelectedRuntime = null + return@addActionListener + } + val selectedRuntime = runtime.getItemAt(index) + if (selectedRuntime == lastSelectedRuntime) return@addActionListener + lastSelectedRuntime = selectedRuntime + handlerPanel.setRuntime(selectedRuntime.toSdkRuntime()) + architectureModel.replaceAll(selectedRuntime.architectures?.toMutableList() ?: mutableListOf(LambdaArchitecture.DEFAULT)) + architecture.isEnabled = architectureModel.size > 1 + } + val supportedRuntimes = LambdaBuilder.supportedRuntimeGroups().flatMap { it.supportedRuntimes }.sorted() + runtimeModel.setAll(supportedRuntimes) + runtimeModel.selectedItem = RuntimeGroup.determineRuntime(project)?.let { if (it in supportedRuntimes) it else null } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamDebugSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamDebugSupport.kt deleted file mode 100644 index f8caa446af..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamDebugSupport.kt +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.execution.local - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.openapi.extensions.ExtensionPointName -import com.intellij.util.net.NetUtils -import com.intellij.xdebugger.XDebugProcessStarter -import org.jetbrains.concurrency.Promise -import org.jetbrains.concurrency.resolvedPromise -import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroupExtensionPointObject - -interface SamDebugSupport { - - val debuggerAttachTimeoutMs: Long - get() = 60000L - - fun patchCommandLine(debugPorts: List, commandLine: GeneralCommandLine) { - debugPorts.forEach { - commandLine.withParameters("--debug-port").withParameters(it.toString()) - } - } - - fun createDebugProcessAsync( - environment: ExecutionEnvironment, - state: SamRunningState, - debugHost: String, - debugPorts: List - ): Promise = resolvedPromise(createDebugProcess(environment, state, debugHost, debugPorts)) - - fun createDebugProcess( - environment: ExecutionEnvironment, - state: SamRunningState, - debugHost: String, - debugPorts: List - ): XDebugProcessStarter? - - fun isSupported(runtime: Runtime): Boolean = true // Default behavior is all runtimes in the runtime group are supported - - fun getDebugPorts(): List = listOf(NetUtils.tryToFindAvailableSocketPort()) - - companion object : RuntimeGroupExtensionPointObject(ExtensionPointName("aws.toolkit.lambda.sam.debugSupport")) -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamDebugger.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamDebugger.kt deleted file mode 100644 index b2ee2f3731..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamDebugger.kt +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.execution.local - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.ui.RunContentDescriptor -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task -import com.intellij.xdebugger.XDebuggerManager -import com.jetbrains.rd.util.spinUntil -import org.jetbrains.concurrency.AsyncPromise -import org.jetbrains.concurrency.Promise -import software.aws.toolkits.core.utils.error -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup -import software.aws.toolkits.jetbrains.utils.notifyError -import software.aws.toolkits.resources.message - -internal class SamDebugger(runtimeGroup: RuntimeGroup) : SamRunner() { - companion object { - private val logger = getLogger() - } - - private val debugExtension = SamDebugSupport.getInstanceOrThrow(runtimeGroup) - - private val debugPorts = debugExtension.getDebugPorts() - - override fun patchCommandLine(commandLine: GeneralCommandLine) { - debugExtension.patchCommandLine(debugPorts, commandLine) - } - - override fun run(environment: ExecutionEnvironment, state: SamRunningState): Promise { - val promise = AsyncPromise() - - var isDebuggerAttachDone = false - - ProgressManager.getInstance().run(object : Task.Backgroundable(environment.project, message("lambda.debug.waiting"), false) { - override fun run(indicator: ProgressIndicator) { - val debugAttachedResult = spinUntil(debugExtension.debuggerAttachTimeoutMs) { isDebuggerAttachDone } - if (!debugAttachedResult) { - val message = message("lambda.debug.attach.fail") - logger.error { message } - notifyError(message("lambda.debug.attach.error"), message, environment.project) - } - } - }) - - debugExtension.createDebugProcessAsync(environment, state, state.settings.debugHost, debugPorts) - .onSuccess { debugProcessStarter -> - val debugManager = XDebuggerManager.getInstance(environment.project) - val runContentDescriptor = debugProcessStarter?.let { - return@let debugManager.startSession(environment, debugProcessStarter).runContentDescriptor - } - if (runContentDescriptor == null) { - promise.setError(IllegalStateException("Failed to create debug process")) - } else { - promise.setResult(runContentDescriptor) - } - } - .onError { - promise.setError(it) - } - .onProcessed { - isDebuggerAttachDone = true - } - - return promise - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamInvokeRunner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamInvokeRunner.kt deleted file mode 100644 index 6091d110e1..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamInvokeRunner.kt +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.execution.local - -import com.intellij.execution.configurations.RunProfile -import com.intellij.execution.configurations.RunProfileState -import com.intellij.execution.configurations.RunnerSettings -import com.intellij.execution.executors.DefaultDebugExecutor -import com.intellij.execution.executors.DefaultRunExecutor -import com.intellij.execution.runners.AsyncProgramRunner -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.ui.RunContentDescriptor -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.fileEditor.FileDocumentManager -import com.intellij.openapi.module.Module -import com.intellij.openapi.module.ModuleUtil -import com.intellij.psi.PsiFile -import org.jetbrains.concurrency.AsyncPromise -import org.jetbrains.concurrency.Promise -import org.slf4j.event.Level -import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.core.telemetry.DefaultMetricEvent.Companion.METADATA_INVALID -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.tryOrNull -import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.core.AwsResourceCache -import software.aws.toolkits.jetbrains.services.lambda.BuildLambdaFromHandler -import software.aws.toolkits.jetbrains.services.lambda.BuildLambdaFromTemplate -import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderUtils -import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup -import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon -import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils -import software.aws.toolkits.jetbrains.services.lambda.validOrNull -import software.aws.toolkits.jetbrains.services.sts.StsResources -import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService -import software.aws.toolkits.telemetry.Result -import java.io.File - -class SamInvokeRunner : AsyncProgramRunner() { - override fun getRunnerId(): String = "SamInvokeRunner" - - override fun canRun(executorId: String, profile: RunProfile): Boolean { - if (profile !is LocalLambdaRunConfiguration) { - return false - } - - if (DefaultRunExecutor.EXECUTOR_ID == executorId) { - return true // Always true so that the run icon is shown, error is then told to user that runtime doesnt work - } - - // Requires SamDebugSupport too - if (DefaultDebugExecutor.EXECUTOR_ID == executorId) { - val runtimeValue = if (profile.isUsingTemplate()) { - LOG.tryOrNull("Failed to get runtime of ${profile.logicalId()}", Level.WARN) { - SamTemplateUtils.findFunctionsFromTemplate(profile.project, File(profile.templateFile())) - .find { it.logicalName == profile.logicalId() } - ?.runtime() - ?.let { - Runtime.fromValue(it)?.validOrNull - } - } - } else { - profile.runtime() - } - - val runtimeGroup = runtimeValue?.runtimeGroup ?: return false - - return SamDebugSupport.supportedRuntimeGroups.contains(runtimeGroup) && - SamDebugSupport.getInstance(runtimeGroup)?.isSupported(runtimeValue) ?: false - } - - return false - } - - override fun execute(environment: ExecutionEnvironment, state: RunProfileState): Promise { - FileDocumentManager.getInstance().saveAllDocuments() - - val buildingPromise = AsyncPromise() - val samState = state as SamRunningState - val lambdaSettings = samState.settings - val module = getModule(samState.settings.handlerElement.containingFile) - val runtimeGroup = lambdaSettings.runtimeGroup - - val buildRequest = if (lambdaSettings.templateDetails?.templateFile != null) { - BuildLambdaFromTemplate( - lambdaSettings.templateDetails.templateFile, - lambdaSettings.templateDetails.logicalName, - lambdaSettings.samOptions - ) - } else { - BuildLambdaFromHandler( - lambdaSettings.handlerElement, - lambdaSettings.handler, - lambdaSettings.runtime, - lambdaSettings.timeout, - lambdaSettings.memorySize, - lambdaSettings.environmentVariables, - lambdaSettings.samOptions - ) - } - - LambdaBuilderUtils.buildAndReport(module, runtimeGroup, buildRequest) - .thenAccept { - samState.runner.checkDockerInstalled() - runInEdt { - samState.builtLambda = it - samState.runner.run(environment, samState) - .onSuccess { - buildingPromise.setResult(it) - }.onError { - buildingPromise.setError(it) - } - } - }.exceptionally { - LOG.warn(it) { "Failed to create Lambda package" } - buildingPromise.setError(it) - throw it - }.whenComplete { _, exception -> - AwsResourceCache.getInstance(state.environment.project) - .getResource(StsResources.ACCOUNT, lambdaSettings.region, lambdaSettings.credentials) - .whenComplete { account, _ -> - TelemetryService.getInstance().record( - TelemetryService.MetricEventMetadata( - awsAccount = account ?: METADATA_INVALID, - awsRegion = lambdaSettings.region.id - ) - ) { - datum("lambda_invokeLocal") { - count() - // exception can be null but is not annotated as nullable - metadata("debug", environment.isDebug()) - metadata("result", if (exception == null) Result.Succeeded.value else Result.Failed.value) - metadata("runtime", lambdaSettings.runtime.name) - metadata("samVersion", SamCommon.getVersionString()) - metadata("templateBased", buildRequest is BuildLambdaFromTemplate) - } - } - } - } - - return buildingPromise - } - - private fun getModule(psiFile: PsiFile): Module = ModuleUtil.findModuleForFile(psiFile) - ?: throw java.lang.IllegalStateException("Failed to locate module for $psiFile") - - private fun ExecutionEnvironment.isDebug(): Boolean = (executor.id == DefaultDebugExecutor.EXECUTOR_ID) - - private companion object { - val LOG = getLogger() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamRunner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamRunner.kt deleted file mode 100644 index 809c2a4944..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamRunner.kt +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.execution.local - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.process.OSProcessHandler -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.execution.runners.RunContentBuilder -import com.intellij.execution.ui.RunContentDescriptor -import org.jetbrains.concurrency.Promise -import org.jetbrains.concurrency.resolvedPromise -import software.aws.toolkits.resources.message - -open class SamRunner { - open fun patchCommandLine(commandLine: GeneralCommandLine) {} - - open fun run(environment: ExecutionEnvironment, state: SamRunningState): Promise { - val executionResult = state.execute(environment.executor, environment.runner) - return resolvedPromise(RunContentBuilder(executionResult, environment).showRunContent(environment.contentToReuse)) - } - - /* - * Assert that Docker is installed. If it is not, throw an exception. - */ - fun checkDockerInstalled() { - try { - val processHandler = OSProcessHandler(GeneralCommandLine("docker", "ps")) - processHandler.startNotify() - processHandler.waitFor() - val exitValue = processHandler.exitCode - if (exitValue != 0) { - throw Exception(message("lambda.debug.docker.not_connected")) - } - } catch (t: Throwable) { - throw Exception(message("lambda.debug.docker.not_connected"), t) - } - } -} 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 deleted file mode 100644 index 0c8bb22e7c..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamRunningState.kt +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -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.runners.ExecutionEnvironment -import com.intellij.openapi.util.io.FileUtil -import software.aws.toolkits.jetbrains.core.credentials.toEnvironmentVariables -import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance -import software.aws.toolkits.jetbrains.core.executables.ExecutableManager -import software.aws.toolkits.jetbrains.core.executables.getExecutableIfPresent -import software.aws.toolkits.jetbrains.services.lambda.BuiltLambda -import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable - -class SamRunningState( - environment: ExecutionEnvironment, - val settings: LocalLambdaRunSettings -) : CommandLineState(environment) { - lateinit var builtLambda: BuiltLambda - - val runner = if (environment.executor.id == DefaultDebugExecutor.EXECUTOR_ID) { - SamDebugger(settings.runtimeGroup) - } else { - SamRunner() - } - - override fun startProcess(): ProcessHandler { - val totalEnvVars = settings.environmentVariables.toMutableMap() - totalEnvVars += settings.credentials.resolveCredentials().toEnvironmentVariables() - totalEnvVars += settings.region.toEnvironmentVariables() - - val samExecutable = ExecutableManager.getInstance().getExecutableIfPresent().let { - when (it) { - is ExecutableInstance.Executable -> it - else -> throw RuntimeException((it as? ExecutableInstance.BadExecutable)?.validationError ?: "") - } - } - val commandLine = samExecutable.getCommandLine() - .withParameters("local") - .withParameters("invoke") - .apply { settings.templateDetails?.run { withParameters(logicalName) } } - .withParameters("--template") - .withParameters(builtLambda.templateLocation.toString()) - .withParameters("--event") - .withParameters(createEventFile()) - .withEnvironment(totalEnvVars) - .withEnvironment("PYTHONUNBUFFERED", "1") // Force SAM to not buffer stdout/stderr so it gets shown in IDE - - val samOptions = settings.samOptions - if (samOptions.skipImagePull) { - commandLine.withParameters("--skip-pull-image") - } - - samOptions.dockerNetwork?.let { - if (it.isNotBlank()) { - commandLine.withParameters("--docker-network") - .withParameters(it.trim()) - } - } - - samOptions.additionalLocalArgs?.let { - if (it.isNotBlank()) { - commandLine.withParameters(*it.split(" ").toTypedArray()) - } - } - - runner.patchCommandLine(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 { - val eventFile = FileUtil.createTempFile("${environment.runProfile.name}-event", ".json", true) - eventFile.writeText(settings.input) - return eventFile.absolutePath - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamSettingsEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamSettingsEditor.kt deleted file mode 100644 index f7a7c6bab7..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamSettingsEditor.kt +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.execution.local - -import com.intellij.docker.DockerCloudConfiguration -import com.intellij.docker.DockerCloudType -import com.intellij.openapi.options.SettingsEditor -import com.intellij.remoteServer.configuration.RemoteServersManager -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.tryOrNull -import software.aws.toolkits.jetbrains.services.ecs.execution.DockerUtil.dockerPluginAvailable -import software.aws.toolkits.jetbrains.utils.ui.selected -import java.net.URI -import javax.swing.JComponent - -class SamSettingsEditor : SettingsEditor() { - private val view = SamSettingsEditorPanel() - - override fun createEditor(): JComponent { - val hostOptions = mutableSetOf() - - hostOptions.add("localhost") - if (dockerPluginAvailable()) { - val dockerCloudType = DockerCloudType.getInstance() - RemoteServersManager.getInstance().servers.asSequence() - .filter { it.type == dockerCloudType } - .map { it.configuration } - .filterIsInstance< DockerCloudConfiguration>() - .mapNotNull { convertDockerApiToHost(it.apiUrl) } - .forEach { hostOptions.add(it) } - } - hostOptions.forEach { view.debugHostChooser.addItem(it) } - - return view.panel - } - - private fun convertDockerApiToHost(api: String) = LOGGER.tryOrNull("Failed to convert $api to host") { - val apiUri = URI.create(api) - if (apiUri.scheme != "unix") { - apiUri.host - } else { - null - } - } - - override fun resetEditorFrom(configuration: LocalLambdaRunConfiguration) { - view.dockerNetwork.text = configuration.dockerNetwork() - view.debugHostChooser.selectedItem = configuration.debugHost() - view.skipPullImage.isSelected = configuration.skipPullImage() - view.buildInContainer.isSelected = configuration.buildInContainer() - view.additionalBuildArgs.text = configuration.additionalBuildArgs() - view.additionalLocalArgs.text = configuration.additionalLocalArgs() - } - - override fun applyEditorTo(configuration: LocalLambdaRunConfiguration) { - configuration.dockerNetwork(view.dockerNetwork.text.trim()) - configuration.debugHost(view.debugHostChooser.selected() ?: "localhost") - configuration.skipPullImage(view.skipPullImage.isSelected) - configuration.buildInContainer(view.buildInContainer.isSelected) - configuration.additionalBuildArgs(view.additionalBuildArgs.text.trim()) - configuration.additionalLocalArgs(view.additionalLocalArgs.text.trim()) - } - - private companion object { - val LOGGER = getLogger() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamSettingsEditorPanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamSettingsEditorPanel.java deleted file mode 100644 index 7657437e59..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamSettingsEditorPanel.java +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.execution.local; - -import com.intellij.openapi.ui.ComboBox; -import com.intellij.ui.components.fields.ExpandableTextField; -import javax.swing.JCheckBox; -import javax.swing.JPanel; -import javax.swing.JTextField; - -public class SamSettingsEditorPanel { - JCheckBox buildInContainer; - JTextField dockerNetwork; - JCheckBox skipPullImage; - JPanel panel; - ExpandableTextField additionalBuildArgs; - ExpandableTextField additionalLocalArgs; - ComboBox debugHostChooser; -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/TemplateSettings.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/TemplateSettings.form new file mode 100644 index 0000000000..9310ec491b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/TemplateSettings.form @@ -0,0 +1,97 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/TemplateSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/TemplateSettings.kt new file mode 100644 index 0000000000..741de16811 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/TemplateSettings.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.lambda.execution.local + +import com.intellij.execution.util.PathMappingsComponent +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.SortedComboBoxModel +import com.intellij.util.PathMappingSettings +import org.jetbrains.yaml.YAMLFileType +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cloudformation.Function +import software.aws.toolkits.jetbrains.services.cloudformation.SamFunction +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.ImageDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils.getFunctionEnvironmentVariables +import software.aws.toolkits.jetbrains.ui.KeyValueTextField +import software.aws.toolkits.jetbrains.ui.installTextFieldProjectFileBrowseListener +import software.aws.toolkits.jetbrains.utils.ui.find +import software.aws.toolkits.jetbrains.utils.ui.selected +import java.io.File +import java.util.Comparator +import javax.swing.DefaultComboBoxModel +import javax.swing.JComboBox +import javax.swing.JPanel +import javax.swing.event.DocumentEvent + +class TemplateSettings(val project: Project) { + lateinit var panel: JPanel + private set + lateinit var templateFile: TextFieldWithBrowseButton + private set + lateinit var function: JComboBox + private set + lateinit var pathMappingsTable: PathMappingsComponent + private set + private lateinit var functionModels: DefaultComboBoxModel + private lateinit var imageSettingsPanel: JPanel + lateinit var environmentVariables: KeyValueTextField + private set + lateinit var imageDebugger: JComboBox + private set + lateinit var imageDebuggerModel: SortedComboBoxModel + private set + + val isImage + get() = function.selected()?.packageType() == PackageType.IMAGE + + init { + // by default, do not show the image settings or path mappings table + imageSettingsPanel.isVisible = false + pathMappingsTable.isVisible = false + environmentVariables.isEnabled = false + installTextFieldProjectFileBrowseListener( + project, + templateFile, + FileChooserDescriptorFactory.createSingleFileDescriptor(YAMLFileType.YML) + ) { + it.canonicalPath ?: "" + } + + templateFile.textField.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + updateFunctionModel(templateFile.text) + } + }) + function.addActionListener { + val selected = function.selected() + imageSettingsPanel.isVisible = selected is SamFunction && selected.packageType() == PackageType.IMAGE + if (selected == null) { + environmentVariables.isEnabled = false + } else { + environmentVariables.isEnabled = true + setEnvVars(selected) + } + } + imageDebugger.addActionListener { + val pathMappingsApplicable = pathMappingsApplicable() + pathMappingsTable.isVisible = pathMappingsApplicable + if (!pathMappingsApplicable) { + // Clear mappings if it's no longer applicable + pathMappingsTable.setMappingSettings(PathMappingSettings()) + } + } + imageDebuggerModel.setAll(ImageDebugSupport.debuggers().values) + } + + private fun createUIComponents() { + functionModels = DefaultComboBoxModel() + function = ComboBox(functionModels) + environmentVariables = KeyValueTextField() + imageDebuggerModel = SortedComboBoxModel(compareBy(Comparator.naturalOrder()) { it.displayName() }) + imageDebugger = ComboBox(imageDebuggerModel) + imageDebugger.renderer = SimpleListCellRenderer.create { label, value, _ -> label.text = value?.displayName() } + } + + fun setTemplateFile(path: String?) { + templateFile.text = path ?: "" + updateFunctionModel(path) + } + + private fun updateFunctionModel(path: String?) { + if (path.isNullOrBlank()) { + templateFile.text = "" + updateFunctionModel(emptyList()) + return + } + val file = File(path) + if (!file.exists() || !file.isFile) { + updateFunctionModel(emptyList()) + } else { + val functions = SamTemplateUtils.findFunctionsFromTemplate(project, file) + updateFunctionModel(functions) + } + } + + fun selectFunction(logicalFunctionName: String?) { + val function = functionModels.find { it.logicalName == logicalFunctionName } ?: return + functionModels.selectedItem = function + } + + private fun setEnvVars(function: Function) = try { + environmentVariables.envVars = getFunctionEnvironmentVariables(File(templateFile.text).toPath(), function.logicalName) + } catch (e: Exception) { + // We don't want to throw exceptions out to the UI when we fail to parse the template, so log and continue + LOG.warn(e) { "Failed to set environment variables field" } + } + + private fun updateFunctionModel(functions: List) { + functionModels.removeAllElements() + function.isEnabled = functions.isNotEmpty() + functionModels.addAll(functions) + if (functions.size == 1) { + functionModels.setSelectedItem(functions[0]) + } else { + function.setSelectedIndex(-1) + } + } + + private fun pathMappingsApplicable(): Boolean = imageDebugger.selected()?.supportsPathMappings() ?: false + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunConfiguration.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunConfiguration.kt index eee69b45b4..fcdfb6e12b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunConfiguration.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunConfiguration.kt @@ -11,6 +11,7 @@ import com.intellij.execution.configurations.RuntimeConfigurationError import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.openapi.options.SettingsEditorGroup import com.intellij.openapi.project.Project +import software.amazon.awssdk.services.lambda.LambdaClient import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.jetbrains.services.lambda.execution.LambdaRunConfigurationBase @@ -21,8 +22,8 @@ import software.aws.toolkits.resources.message class RemoteLambdaRunConfigurationFactory(configuration: LambdaRunConfigurationType) : ConfigurationFactory(configuration) { override fun createTemplateConfiguration(project: Project) = RemoteLambdaRunConfiguration(project, this) - override fun getName(): String = "Remote" + override fun getId(): String = name } class RemoteLambdaRunConfiguration(project: Project, factory: ConfigurationFactory) : @@ -34,7 +35,7 @@ class RemoteLambdaRunConfiguration(project: Project, factory: ConfigurationFacto val group = SettingsEditorGroup() val remoteLambdaSettings = RemoteLambdaRunSettingsEditor(project) group.addEditor(ExecutionBundle.message("run.configuration.configuration.tab.title"), remoteLambdaSettings) - group.addAwsConnectionEditor(AwsConnectionSettingsEditor(project, remoteLambdaSettings::updateFunctions)) + group.addAwsConnectionEditor(AwsConnectionSettingsEditor(project, LambdaClient.SERVICE_NAME, remoteLambdaSettings::updateFunctions)) return group } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunSettingsEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunSettingsEditor.kt index 7ff3f9925f..b77a202eb5 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunSettingsEditor.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunSettingsEditor.kt @@ -5,9 +5,8 @@ package software.aws.toolkits.jetbrains.services.lambda.execution.remote import com.intellij.openapi.options.SettingsEditor import com.intellij.openapi.project.Project -import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider +import software.aws.toolkits.core.ConnectionSettings import software.aws.toolkits.core.region.AwsRegion -import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.tryOrNull import software.aws.toolkits.jetbrains.core.credentials.CredentialManager import software.aws.toolkits.jetbrains.core.map @@ -17,12 +16,12 @@ import java.util.concurrent.atomic.AtomicReference import javax.swing.JPanel class RemoteLambdaRunSettingsEditor(project: Project) : SettingsEditor() { - private val settings = AtomicReference>() + private val credentialSettingsRef = AtomicReference() private val functionSelector = ResourceSelector - .builder(project) + .builder() .resource(LambdaResources.LIST_FUNCTIONS.map { it.functionName() }) .disableAutomaticLoading() - .awsConnection { settings.get() ?: throw IllegalStateException("functionSelector.reload() called before region/credentials set") } + .awsConnection { credentialSettingsRef.get() ?: throw IllegalStateException("functionSelector.reload() called before region/credentials set") } .build() private val view = RemoteLambdaRunSettingsEditorPanel(project, functionSelector) private val credentialManager = CredentialManager.getInstance() @@ -34,8 +33,9 @@ class RemoteLambdaRunSettingsEditor(project: Project) : SettingsEditor() - } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunSettingsEditorPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunSettingsEditorPanel.form index 64ee92b0c9..49b4f40e1c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunSettingsEditorPanel.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunSettingsEditorPanel.form @@ -13,8 +13,8 @@ - - + +
@@ -23,7 +23,7 @@ - + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunner.kt index c0673d22be..de2d145dab 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunner.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaRunner.kt @@ -4,14 +4,43 @@ package software.aws.toolkits.jetbrains.services.lambda.execution.remote import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.configurations.RunnerSettings import com.intellij.execution.executors.DefaultRunExecutor -import com.intellij.execution.runners.DefaultProgramRunner +import com.intellij.execution.runners.AsyncProgramRunner +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.RunContentBuilder +import com.intellij.execution.ui.RunContentDescriptor +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import org.jetbrains.concurrency.AsyncPromise +import org.jetbrains.concurrency.Promise -class RemoteLambdaRunner : DefaultProgramRunner() { +class RemoteLambdaRunner : AsyncProgramRunner() { override fun getRunnerId(): String = "Remote AWS Lambda" - override fun canRun( - executorId: String, - profile: RunProfile - ): Boolean = DefaultRunExecutor.EXECUTOR_ID == executorId && profile is RemoteLambdaRunConfiguration + override fun canRun(executorId: String, profile: RunProfile): Boolean = + DefaultRunExecutor.EXECUTOR_ID == executorId && profile is RemoteLambdaRunConfiguration + + override fun execute(environment: ExecutionEnvironment, state: RunProfileState): Promise { + val runPromise = AsyncPromise() + val remoteState = state as RemoteLambdaState + ApplicationManager.getApplication().executeOnPooledThread { + try { + val executionResult = remoteState.execute(environment.executor, this) + val builder = RunContentBuilder(executionResult, environment) + + runInEdt(ModalityState.any()) { + runPromise.setResult(builder.showRunContent(environment.contentToReuse)) + } + } catch (e: Exception) { + runInEdt(ModalityState.any()) { + runPromise.setError(e) + } + } + } + + return runPromise + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaState.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaState.kt index bf5b0a9da8..780aa95552 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaState.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/remote/RemoteLambdaState.kt @@ -41,17 +41,21 @@ class RemoteLambdaState( consoleBuilder = TextConsoleBuilderFactory.getInstance().createBuilder(project, searchScope) } - override fun execute(executor: Executor, runner: ProgramRunner<*>): ExecutionResult? { + override fun execute(executor: Executor, runner: ProgramRunner<*>): ExecutionResult { val lambdaProcess = LambdaProcess() val console = consoleBuilder.console console.attachToProcess(lambdaProcess) - ApplicationManager.getApplication().executeOnPooledThread { invokeLambda(lambdaProcess) } - return DefaultExecutionResult(console, lambdaProcess) } private inner class LambdaProcess : ProcessHandler() { + override fun startNotify() { + super.startNotify() + + ApplicationManager.getApplication().executeOnPooledThread { invokeLambda(this) } + } + override fun getProcessInput(): OutputStream? = null override fun detachIsDefault(): Boolean = true @@ -66,7 +70,7 @@ class RemoteLambdaState( } private fun invokeLambda(lambdaProcess: ProcessHandler) { - val client = AwsClientManager.getInstance(environment.project) + val client = AwsClientManager.getInstance() .getClient(settings.credentialProvider, settings.region) var result = Result.Succeeded diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/ImageDebugSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/ImageDebugSupport.kt new file mode 100644 index 0000000000..549ae2218c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/ImageDebugSupport.kt @@ -0,0 +1,53 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.execution.sam + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.util.io.FileUtil +import software.aws.toolkits.jetbrains.core.utils.buildList +import java.util.UUID + +interface ImageDebugSupport : SamDebugSupport { + val id: String + + /** + * The primary language id, used to find the correct builder for the runtime + */ + val languageId: String + + fun displayName(): String + fun supportsPathMappings(): Boolean = false + + override fun samArguments(debugPorts: List): List = buildList { + val containerEnvVars = containerEnvVars(debugPorts) + if (containerEnvVars.isNotEmpty()) { + val path = createContainerEnvVarsFile(containerEnvVars) + add("--container-env-vars") + add(path) + } + } + + /** + * Environment variables added to the execution of the container. These are used for debugging support for OCI + * runtimes. The SAM CLI sets these for Zip based functions, but not Image based functions. An easy starting point + * for the arguments is the list SAM cli maintains for Zip functions: + * https://github.com/aws/aws-sam-cli/blob/develop/samcli/local/docker/lambda_debug_settings.py + * @param debugPorts The list of debugger ports. Some runtimes (dotnet) require more than one + */ + fun containerEnvVars(debugPorts: List): Map = emptyMap() + + private fun createContainerEnvVarsFile(envVars: Map): String { + val envVarsFile = FileUtil.createTempFile("${UUID.randomUUID()}-debugArgs", ".json", true) + envVarsFile.writeText(mapper.writeValueAsString(envVars)) + return envVarsFile.absolutePath + } + + companion object { + private val mapper = jacksonObjectMapper() + val EP_NAME = ExtensionPointName("aws.toolkit.lambda.sam.imageDebuggerSupport") + + fun debuggers(): Map = EP_NAME.extensionList.associateBy { it.id } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/LocalLambdaRunSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/LocalLambdaRunSettings.kt new file mode 100644 index 0000000000..3a921ab9a8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/LocalLambdaRunSettings.kt @@ -0,0 +1,90 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.execution.sam + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.PathMappingSettings.PathMapping +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.lambda.LambdaArchitecture +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup +import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions + +interface TemplateSettings { + val templateFile: VirtualFile + val logicalId: String +} + +interface ZipSettings { + val runtime: LambdaRuntime + val architecture: LambdaArchitecture + val handler: String +} + +interface ImageSettings { + val imageDebugger: ImageDebugSupport +} + +sealed class LocalLambdaRunSettings( + val connection: ConnectionSettings, + val samOptions: SamOptions, + val environmentVariables: Map, + val debugHost: String, + val input: String +) { + abstract val runtimeGroup: RuntimeGroup +} + +class TemplateRunSettings( + override val templateFile: VirtualFile, + override val runtime: LambdaRuntime, + override val architecture: LambdaArchitecture, + override val handler: String, + override val logicalId: String, + environmentVariables: Map, + connection: ConnectionSettings, + samOptions: SamOptions, + debugHost: String, + input: String +) : TemplateSettings, ZipSettings, LocalLambdaRunSettings(connection, samOptions, environmentVariables, debugHost, input) { + override val runtimeGroup = runtime.runtimeGroup ?: throw IllegalStateException("Attempting to run SAM for unsupported runtime $runtime") +} + +class HandlerRunSettings( + override val runtime: LambdaRuntime, + override val architecture: LambdaArchitecture, + override val handler: String, + val timeout: Int, + val memorySize: Int, + environmentVariables: Map, + connection: ConnectionSettings, + samOptions: SamOptions, + debugHost: String, + input: String +) : ZipSettings, LocalLambdaRunSettings(connection, samOptions, environmentVariables, debugHost, input) { + override val runtimeGroup = runtime.runtimeGroup ?: throw IllegalStateException("Attempting to run SAM for unsupported runtime $runtime") +} + +class ImageTemplateRunSettings( + override val templateFile: VirtualFile, + override val imageDebugger: ImageDebugSupport, + override val logicalId: String, + val dockerFile: VirtualFile, + val pathMappings: List, + environmentVariables: Map, + connection: ConnectionSettings, + samOptions: SamOptions, + debugHost: String, + input: String +) : ImageSettings, TemplateSettings, LocalLambdaRunSettings(connection, samOptions, environmentVariables, debugHost, input) { + override val runtimeGroup = RuntimeGroup.find { imageDebugger.languageId in it.languageIds } + ?: throw IllegalStateException("Attempting to run SAM for unsupported language ${imageDebugger.languageId}") +} + +fun LocalLambdaRunSettings.resolveDebuggerSupport() = when (this) { + is ImageTemplateRunSettings -> imageDebugger + is ZipSettings -> RuntimeDebugSupport.getInstance(runtimeGroup) + else -> throw IllegalStateException("Can't find debugger support for $this") +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/RuntimeDebugSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/RuntimeDebugSupport.kt new file mode 100644 index 0000000000..dc0caa9431 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/RuntimeDebugSupport.kt @@ -0,0 +1,14 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.execution.sam + +import com.intellij.openapi.extensions.ExtensionPointName +import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroupExtensionPointObject + +interface RuntimeDebugSupport : SamDebugSupport { + fun isSupported(runtime: Runtime): Boolean = true // Default behavior is all runtimes in the runtime group are supported + + companion object : RuntimeGroupExtensionPointObject(ExtensionPointName("aws.toolkit.lambda.sam.runtimeDebugSupport")) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamDebugSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamDebugSupport.kt new file mode 100644 index 0000000000..25bd196a4f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamDebugSupport.kt @@ -0,0 +1,35 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.execution.sam + +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.util.registry.Registry +import com.intellij.xdebugger.XDebugProcessStarter +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.Step + +interface SamDebugSupport { + fun numberOfDebugPorts(): Int = 1 + + /** + * SAM arguments added to the execution of `sam local invoke`. These include --debugger-path and --debug-args + * for debugging, and anything else that is needed on a per-runtime basis + * @param debugPorts The list of debugger ports. Some runtimes (dotnet) require more than one + */ + fun samArguments(debugPorts: List): List = emptyList() + + fun additionalDebugProcessSteps(environment: ExecutionEnvironment, state: SamRunningState): List = listOf() + + suspend fun createDebugProcess( + context: Context, + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List + ): XDebugProcessStarter + + companion object { + fun debuggerConnectTimeoutMs() = Registry.intValue("aws.debuggerAttach.timeout", 60000).toLong() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamInvokeRunner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamInvokeRunner.kt new file mode 100644 index 0000000000..e884319054 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamInvokeRunner.kt @@ -0,0 +1,85 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.execution.sam + +import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.configurations.RunnerSettings +import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.execution.executors.DefaultRunExecutor +import com.intellij.execution.runners.AsyncProgramRunner +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.RunContentBuilder +import com.intellij.execution.ui.RunContentDescriptor +import com.intellij.openapi.fileEditor.FileDocumentManager +import org.jetbrains.concurrency.AsyncPromise +import org.jetbrains.concurrency.Promise +import org.slf4j.event.Level +import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.core.lambda.validOrNull +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.services.lambda.execution.local.LocalLambdaRunConfiguration +import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import java.io.File + +class SamInvokeRunner : AsyncProgramRunner() { + override fun getRunnerId(): String = "SamInvokeRunner" + + override fun canRun(executorId: String, profile: RunProfile): Boolean { + if (profile !is LocalLambdaRunConfiguration) { + return false + } + + if (DefaultRunExecutor.EXECUTOR_ID == executorId) { + // Always true so that the run icon is shown, error is then told to user that runtime doesnt work + return true + } + + if (DefaultDebugExecutor.EXECUTOR_ID != executorId) { + // Only support debugging if it is the default executor + return false + } + + val runtimeValue = if (profile.isUsingTemplate() && !profile.isImage) { + LOG.tryOrNull("Failed to get runtime of ${profile.logicalId()}", Level.WARN) { + SamTemplateUtils.findZipFunctionsFromTemplate(profile.project, File(profile.templateFile())) + .find { it.logicalName == profile.logicalId() } + ?.runtime() + ?.let { + Runtime.fromValue(it)?.validOrNull + } + } + } else if (!profile.isImage) { + profile.runtime()?.toSdkRuntime() + } else { + null + } + val runtimeGroup = runtimeValue?.runtimeGroup + + val canRunRuntime = runtimeGroup != null && + RuntimeDebugSupport.supportedRuntimeGroups().contains(runtimeGroup) && + RuntimeDebugSupport.getInstanceOrNull(runtimeGroup)?.isSupported(runtimeValue) ?: false + val canRunImage = profile.isImage && profile.imageDebugger() != null + + return canRunRuntime || canRunImage + } + + override fun execute(environment: ExecutionEnvironment, state: RunProfileState): Promise { + val runPromise = AsyncPromise() + FileDocumentManager.getInstance().saveAllDocuments() + val runContentDescriptor = state.execute(environment.executor, this)?.let { + RunContentBuilder(it, environment).showRunContent(environment.contentToReuse) + } + + runPromise.setResult(runContentDescriptor) + + return runPromise + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamRunningState.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamRunningState.kt new file mode 100644 index 0000000000..4dbea80f32 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamRunningState.kt @@ -0,0 +1,240 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.execution.sam + +import com.intellij.build.BuildView +import com.intellij.build.DefaultBuildDescriptor +import com.intellij.build.ViewManager +import com.intellij.execution.DefaultExecutionResult +import com.intellij.execution.ExecutionResult +import com.intellij.execution.Executor +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.executors.DefaultDebugExecutor +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.ProgramRunner +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.core.telemetry.DefaultMetricEvent +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.jetbrains.services.PathMapping +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder +import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon +import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions +import software.aws.toolkits.jetbrains.services.lambda.steps.AttachDebugger +import software.aws.toolkits.jetbrains.services.lambda.steps.AttachDebuggerParent +import software.aws.toolkits.jetbrains.services.lambda.steps.BuildLambda +import software.aws.toolkits.jetbrains.services.lambda.steps.BuildLambdaRequest +import software.aws.toolkits.jetbrains.services.lambda.steps.GetPorts +import software.aws.toolkits.jetbrains.services.lambda.steps.SamRunnerStep +import software.aws.toolkits.jetbrains.services.sts.StsResources +import software.aws.toolkits.jetbrains.services.telemetry.MetricEventMetadata +import software.aws.toolkits.jetbrains.utils.execution.steps.BuildViewWorkflowEmitter +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.ParallelStep +import software.aws.toolkits.jetbrains.utils.execution.steps.Step +import software.aws.toolkits.jetbrains.utils.execution.steps.StepExecutor +import software.aws.toolkits.jetbrains.utils.execution.steps.StepWorkflow +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.LambdaPackageType +import software.aws.toolkits.telemetry.LambdaTelemetry +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.Runtime +import java.nio.file.Paths + +class SamRunningState( + val environment: ExecutionEnvironment, + val settings: LocalLambdaRunSettings +) : RunProfileState { + lateinit var pathMappings: List + + override fun execute(executor: Executor, runner: ProgramRunner<*>): ExecutionResult { + val project = environment.project + val descriptor = DefaultBuildDescriptor( + runConfigId(), + message("lambda.run_configuration.sam"), + "/unused/location", + System.currentTimeMillis() + ) + + val buildView = BuildView( + project, + descriptor, + null, + object : ViewManager { + override fun isConsoleEnabledByDefault() = false + + override fun isBuildContentView() = true + } + ) + + val lambdaBuilder = LambdaBuilder.getInstance(settings.runtimeGroup) + + val buildLambdaRequest = buildBuildLambdaRequest(environment.project, settings) + + pathMappings = createPathMappings(lambdaBuilder, settings, buildLambdaRequest) + val buildWorkflow = buildWorkflow(environment, settings, this, buildLambdaRequest, buildView) + + return DefaultExecutionResult(buildView, buildWorkflow) + } + + private fun createPathMappings(lambdaBuilder: LambdaBuilder, settings: LocalLambdaRunSettings, buildRequest: BuildLambdaRequest): List { + val defaultPathMappings = lambdaBuilder.defaultPathMappings(buildRequest.templatePath, buildRequest.logicalId ?: dummyLogicalId, buildRequest.buildDir) + return if (settings is ImageTemplateRunSettings) { + // This needs to be a bit smart. If a user set local path matches a default path, we need to make sure that is the one set + // by removing the default set one. + val userMappings = settings.pathMappings.map { PathMapping(it.localRoot, it.remoteRoot) } + userMappings + defaultPathMappings.filterNot { defaultMapping -> userMappings.any { defaultMapping.localRoot == it.localRoot } } + } else { + defaultPathMappings + } + } + + private fun reportMetric(lambdaSettings: LocalLambdaRunSettings, result: Result, isDebug: Boolean) { + val account = AwsResourceCache.getInstance() + .getResourceIfPresent(StsResources.ACCOUNT, lambdaSettings.connection) + + LambdaTelemetry.invokeLocal( + metadata = MetricEventMetadata( + awsAccount = account ?: DefaultMetricEvent.METADATA_INVALID, + awsRegion = lambdaSettings.connection.region.id + ), + debug = isDebug, + runtime = Runtime.from( + when (lambdaSettings) { + is ZipSettings -> { + lambdaSettings.runtime.toString() + } + is ImageSettings -> { + lambdaSettings.imageDebugger.id + } + else -> { + "" + } + } + ), + version = SamCommon.getVersionString(), + lambdaPackageType = if (lambdaSettings is ImageTemplateRunSettings) LambdaPackageType.Image else LambdaPackageType.Zip, + result = result + ) + } + + private fun buildWorkflow( + environment: ExecutionEnvironment, + settings: LocalLambdaRunSettings, + state: SamRunningState, + buildRequest: BuildLambdaRequest, + buildView: BuildView + ): ProcessHandler { + val startSam = SamRunnerStep(environment, settings, environment.isDebug()) + + val workflow = StepWorkflow( + buildList { + add(ValidateDocker()) + if (buildRequest.preBuildSteps.isNotEmpty()) { + addAll(buildRequest.preBuildSteps) + } + add(BuildLambda(buildRequest)) + if (environment.isDebug()) { + add(GetPorts(settings)) + add(object : ParallelStep() { + override fun buildChildSteps(context: Context): List = listOf( + startSam, + AttachDebuggerParent( + state.settings.resolveDebuggerSupport().additionalDebugProcessSteps(environment, state) + + AttachDebugger( + environment, + state + ) + ) + ) + + override val stepName: String = "" + override val hidden: Boolean = true + }) + } else { + add(startSam) + } + } + ) + val emitter = BuildViewWorkflowEmitter.createEmitter(buildView, message("sam.build.running"), environment.executionId.toString()) + val executor = StepExecutor(environment.project, workflow, emitter) + executor.onSuccess = { + reportMetric(settings, Result.Succeeded, environment.isDebug()) + } + executor.onError = { + reportMetric(settings, Result.Failed, environment.isDebug()) + } + + // Let run config system start the execution through the process handler + return executor.getProcessHandler() + } + + private fun runConfigId() = environment.executionId.toString() + + companion object { + private const val dummyLogicalId = "Function" + + internal fun buildBuildLambdaRequest(project: Project, lambdaSettings: LocalLambdaRunSettings) = when (lambdaSettings) { + is TemplateRunSettings -> + buildLambdaFromTemplate( + project, + lambdaSettings + ) + is ImageTemplateRunSettings -> + buildLambdaFromTemplate( + project, + lambdaSettings + ) + is HandlerRunSettings -> + lambdaSettings.lambdaBuilder().buildFromHandler( + project, + lambdaSettings + ) + } + + private fun buildLambdaFromTemplate( + project: Project, + lambdaSettings: TemplateRunSettings, + ): BuildLambdaRequest = buildLambdaFromTemplate( + project, + lambdaSettings.lambdaBuilder(), + lambdaSettings.templateFile, + lambdaSettings.logicalId, + lambdaSettings.samOptions + ) + + private fun buildLambdaFromTemplate( + project: Project, + lambdaSettings: ImageTemplateRunSettings, + ): BuildLambdaRequest = buildLambdaFromTemplate( + project, + lambdaSettings.lambdaBuilder(), + lambdaSettings.templateFile, + lambdaSettings.logicalId, + lambdaSettings.samOptions + ) + + private fun buildLambdaFromTemplate( + project: Project, + lambdaBuilder: LambdaBuilder, + templateFile: VirtualFile, + logicalId: String, + samOptions: SamOptions + ): BuildLambdaRequest { + val templatePath = Paths.get(templateFile.path) + val buildDir = templatePath.resolveSibling(".aws-sam").resolve("build") + val module = ModuleUtil.findModuleForFile(templateFile, project) + val additionalBuildEnvironmentVariables = lambdaBuilder.additionalBuildEnvironmentVariables(project, module, samOptions) + + return BuildLambdaRequest(templatePath, logicalId, buildDir, additionalBuildEnvironmentVariables, samOptions) + } + + private fun LocalLambdaRunSettings.lambdaBuilder() = LambdaBuilder.getInstance(this.runtimeGroup) + + private fun ExecutionEnvironment.isDebug(): Boolean = (executor.id == DefaultDebugExecutor.EXECUTOR_ID) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamSettingsEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamSettingsEditor.kt new file mode 100644 index 0000000000..f87f28432b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamSettingsEditor.kt @@ -0,0 +1,80 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.execution.sam + +import com.intellij.docker.DockerCloudConfiguration +import com.intellij.docker.DockerCloudType +import com.intellij.openapi.options.SettingsEditor +import com.intellij.remoteServer.configuration.RemoteServersManager +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.jetbrains.core.plugins.pluginIsInstalledAndEnabled +import software.aws.toolkits.jetbrains.utils.ui.selected +import java.net.URI +import javax.swing.JComponent + +/* + * An interface implemented by run configurations that can have a Sam Settings panel + */ +interface SamSettingsRunConfiguration { + var dockerNetwork: String? + var buildInContainer: Boolean + var skipPullImage: Boolean + var debugHost: String + var additionalLocalArgs: String? + var additionalBuildArgs: String? +} + +class SamSettingsEditor : SettingsEditor() { + private val view = SamSettingsEditorPanel() + + override fun createEditor(): JComponent { + val hostOptions = mutableSetOf() + + hostOptions.add("localhost") + if (pluginIsInstalledAndEnabled("Docker")) { + val dockerCloudType = DockerCloudType.getInstance() + RemoteServersManager.getInstance().servers.asSequence() + .filter { it.type == dockerCloudType } + .map { it.configuration } + .filterIsInstance() + .mapNotNull { convertDockerApiToHost(it.apiUrl) } + .forEach { hostOptions.add(it) } + } + hostOptions.forEach { view.debugHostChooser.addItem(it) } + + return view.panel + } + + private fun convertDockerApiToHost(api: String) = LOGGER.tryOrNull("Failed to convert $api to host") { + val apiUri = URI.create(api) + if (apiUri.scheme != "unix") { + apiUri.host + } else { + null + } + } + + override fun resetEditorFrom(configuration: T) { + view.dockerNetwork.text = configuration.dockerNetwork + view.debugHostChooser.selectedItem = configuration.debugHost + view.skipPullImage.isSelected = configuration.skipPullImage + view.buildInContainer.isSelected = configuration.buildInContainer + view.additionalBuildArgs.text = configuration.additionalBuildArgs + view.additionalLocalArgs.text = configuration.additionalLocalArgs + } + + override fun applyEditorTo(configuration: T) { + configuration.dockerNetwork = view.dockerNetwork.text.trim() + configuration.debugHost = view.debugHostChooser.selected() ?: "localhost" + configuration.skipPullImage = view.skipPullImage.isSelected + configuration.buildInContainer = view.buildInContainer.isSelected + configuration.additionalBuildArgs = view.additionalBuildArgs.text.trim() + configuration.additionalLocalArgs = view.additionalLocalArgs.text.trim() + } + + private companion object { + val LOGGER = getLogger>() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamSettingsEditorPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamSettingsEditorPanel.form similarity index 82% rename from jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamSettingsEditorPanel.form rename to jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamSettingsEditorPanel.form index ca3431a520..64edfe5574 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/local/SamSettingsEditorPanel.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamSettingsEditorPanel.form @@ -1,5 +1,5 @@ -
+ @@ -13,8 +13,8 @@ - - + + @@ -22,8 +22,8 @@ - - + + @@ -31,8 +31,8 @@ - - + + @@ -40,8 +40,8 @@ - - + + @@ -51,7 +51,7 @@ - + @@ -61,7 +61,7 @@ - + @@ -69,8 +69,8 @@ - - + + @@ -85,7 +85,7 @@ - + @@ -95,7 +95,7 @@ - + @@ -103,8 +103,8 @@ - - + + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamSettingsEditorPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamSettingsEditorPanel.kt new file mode 100644 index 0000000000..0a8f1c9c4d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/SamSettingsEditorPanel.kt @@ -0,0 +1,26 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.lambda.execution.sam + +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.components.fields.ExpandableTextField +import javax.swing.JCheckBox +import javax.swing.JPanel +import javax.swing.JTextField + +class SamSettingsEditorPanel { + lateinit var buildInContainer: JCheckBox + private set + lateinit var dockerNetwork: JTextField + private set + lateinit var skipPullImage: JCheckBox + private set + lateinit var panel: JPanel + private set + lateinit var additionalBuildArgs: ExpandableTextField + private set + lateinit var additionalLocalArgs: ExpandableTextField + private set + lateinit var debugHostChooser: ComboBox + private set +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/ValidateDocker.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/ValidateDocker.kt new file mode 100644 index 0000000000..6d21a2fd7a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/sam/ValidateDocker.kt @@ -0,0 +1,24 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.execution.sam + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.ProcessListener +import software.aws.toolkits.jetbrains.utils.execution.steps.CliBasedStep +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.resources.message + +class ValidateDocker : CliBasedStep() { + override val stepName: String = "Validate Docker" + + override fun constructCommandLine(context: Context): GeneralCommandLine = GeneralCommandLine("docker", "ps") + + override fun handleErrorResult(exitCode: Int, output: String, stepEmitter: StepEmitter) { + throw Exception(message("lambda.debug.docker.not_connected")) + } + + // Change logger to not log std out since we dont actually want the output of docker + override fun createProcessEmitter(stepEmitter: StepEmitter): ProcessListener = CliOutputEmitter(stepEmitter, printStdOut = false) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/template/YamlLambdaRunLineMarkerContributor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/template/YamlLambdaRunLineMarkerContributor.kt index 349eb2f6f2..03bddee57c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/template/YamlLambdaRunLineMarkerContributor.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/execution/template/YamlLambdaRunLineMarkerContributor.kt @@ -10,6 +10,7 @@ import com.intellij.psi.PsiElement import org.jetbrains.yaml.psi.YAMLKeyValue import software.aws.toolkits.jetbrains.services.cloudformation.Function import software.aws.toolkits.jetbrains.services.cloudformation.yaml.YamlCloudFormationTemplate +import software.aws.toolkits.jetbrains.utils.isTestOrInjectedText class YamlLambdaRunLineMarkerContributor : RunLineMarkerContributor() { @@ -19,6 +20,10 @@ class YamlLambdaRunLineMarkerContributor : RunLineMarkerContributor() { return null } + if (element.isTestOrInjectedText()) { + return null + } + val parent = element.parent as? YAMLKeyValue ?: return null return if (parent.key == element && YamlCloudFormationTemplate.convertPsiToResource(parent) as? Function != null) { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaDebugSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaDebugSupport.kt new file mode 100644 index 0000000000..dda86f202e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaDebugSupport.kt @@ -0,0 +1,135 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.java + +import com.intellij.debugger.DebugEnvironment +import com.intellij.debugger.DebuggerManagerEx +import com.intellij.debugger.engine.JavaDebugProcess +import com.intellij.debugger.engine.RemoteDebugProcessHandler +import com.intellij.execution.DefaultExecutionResult +import com.intellij.execution.ExecutionResult +import com.intellij.execution.configurations.RemoteConnection +import com.intellij.execution.impl.ConsoleViewImpl +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.lang.java.JavaLanguage +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.GlobalSearchScopes +import com.intellij.xdebugger.XDebugProcess +import com.intellij.xdebugger.XDebugProcessStarter +import com.intellij.xdebugger.XDebugSession +import com.intellij.xdebugger.impl.XDebugSessionImpl +import kotlinx.coroutines.withContext +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.ImageDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.RuntimeDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.SamRunningState +import software.aws.toolkits.jetbrains.utils.execution.steps.Context + +class JavaRuntimeDebugSupport : RuntimeDebugSupport { + override suspend fun createDebugProcess( + context: Context, + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List + ): XDebugProcessStarter = createDebugProcess(environment, debugHost, debugPorts) +} + +abstract class JavaImageDebugSupport : ImageDebugSupport { + override fun supportsPathMappings(): Boolean = true + override val languageId = JavaLanguage.INSTANCE.id + override suspend fun createDebugProcess( + context: Context, + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List + ): XDebugProcessStarter = createDebugProcess(environment, debugHost, debugPorts) +} + +open class Java8ImageDebugSupport : JavaImageDebugSupport() { + override val id: String = LambdaRuntime.JAVA8.toString() + override fun displayName() = LambdaRuntime.JAVA8.toString().capitalize() + override fun containerEnvVars(debugPorts: List): Map = mapOf( + "_JAVA_OPTIONS" to "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,quiet=y,address=${debugPorts.first()} " + + "-XX:MaxHeapSize=2834432k -XX:MaxMetaspaceSize=163840k -XX:ReservedCodeCacheSize=81920k -XX:+UseSerialGC " + + "-XX:-TieredCompilation -Djava.net.preferIPv4Stack=true -Xshare:off" + ) +} + +class Java8Al2ImageDebugSupport : Java8ImageDebugSupport() { + override val id: String = LambdaRuntime.JAVA8_AL2.toString() + override fun displayName() = LambdaRuntime.JAVA8_AL2.toString().capitalize() +} + +open class Java11ImageDebugSupport : JavaImageDebugSupport() { + override val id: String = LambdaRuntime.JAVA11.toString() + override fun displayName() = LambdaRuntime.JAVA11.toString().capitalize() + override fun containerEnvVars(debugPorts: List): Map = mapOf( + "_JAVA_OPTIONS" to "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,quiet=y,address=*:${debugPorts.first()} " + + "-XX:MaxHeapSize=2834432k -XX:MaxMetaspaceSize=163840k -XX:ReservedCodeCacheSize=81920k -XX:+UseSerialGC " + + "-XX:-TieredCompilation -Djava.net.preferIPv4Stack=true" + ) +} + +open class Java17ImageDebugSupport : JavaImageDebugSupport() { + override val id: String = LambdaRuntime.JAVA17.toString() + override fun displayName() = LambdaRuntime.JAVA17.toString().capitalize() + override fun containerEnvVars(debugPorts: List): Map = mapOf( + "_JAVA_OPTIONS" to "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,quiet=y,address=*:${debugPorts.first()} " + + "-XX:MaxHeapSize=2834432k -XX:MaxMetaspaceSize=163840k -XX:ReservedCodeCacheSize=81920k -XX:+UseSerialGC " + + "-XX:-TieredCompilation -Djava.net.preferIPv4Stack=true" + ) +} + +private val edtContext = getCoroutineUiContext() + +private suspend fun createDebugProcess( + environment: ExecutionEnvironment, + debugHost: String, + debugPorts: List +): XDebugProcessStarter { + val connection = RemoteConnection(true, debugHost, debugPorts.first().toString(), false) + val debugEnvironment = RemotePortDebugEnvironment(environment, connection) + val debuggerManager = DebuggerManagerEx.getInstanceEx(environment.project) + + val debuggerSession = withContext(edtContext) { + debuggerManager.attachVirtualMachine(debugEnvironment) + } ?: throw IllegalStateException("Attaching to the JVM failed! $debugHost:${debugPorts.first()}") + + return object : XDebugProcessStarter() { + override fun start(session: XDebugSession): XDebugProcess { + if (session is XDebugSessionImpl) { + val debugProcess = debuggerSession.process + val executionResult = debugProcess.executionResult + session.addExtraActions(*executionResult.actions) + if (executionResult is DefaultExecutionResult) { + session.addRestartActions(*executionResult.restartActions) + } + } + + return JavaDebugProcess.create(session, debuggerSession) + } + } +} + +/** + * We are required to make our own so we do not end up in a loop. DefaultDebugEnvironment will execute the run config again + * which make a tragic recursive loop starting the run config an infinite number of times + */ +private class RemotePortDebugEnvironment(private val environment: ExecutionEnvironment, private val connection: RemoteConnection) : DebugEnvironment { + override fun createExecutionResult(): ExecutionResult { + val consoleView = ConsoleViewImpl(environment.project, false) + val process = RemoteDebugProcessHandler(environment.project) + consoleView.attachToProcess(process) + return DefaultExecutionResult(consoleView, process) + } + + override fun getSearchScope(): GlobalSearchScope = GlobalSearchScopes.executionScope(environment.project, environment.runProfile) + override fun isRemote(): Boolean = true + override fun getRemoteConnection(): RemoteConnection = connection + override fun getPollTimeout(): Long = DebugEnvironment.LOCAL_START_TIMEOUT.toLong() + override fun getSessionName(): String = environment.runProfile.name +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilder.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilder.kt index ba543ce755..096779dc28 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilder.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilder.kt @@ -6,8 +6,10 @@ package software.aws.toolkits.jetbrains.services.lambda.java import com.intellij.openapi.externalSystem.ExternalSystemModulePropertyManager import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil import com.intellij.openapi.module.Module +import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.JavaSdkType import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.util.io.FileUtil import com.intellij.psi.PsiElement import org.jetbrains.idea.maven.project.MavenProjectsManager @@ -15,21 +17,26 @@ import software.aws.toolkits.jetbrains.core.plugins.pluginIsInstalledAndEnabled import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions import software.aws.toolkits.resources.message +import java.nio.file.Path +import java.nio.file.Paths class JavaLambdaBuilder : LambdaBuilder() { - override fun baseDirectory(module: Module, handlerElement: PsiElement): String = - when { + override fun handlerBaseDirectory(module: Module, handlerElement: PsiElement): Path { + val buildFileDir = when { isGradle(module) -> getGradleProjectLocation(module) isMaven(module) -> getPomLocation(module) else -> throw IllegalStateException(message("lambda.build.java.unsupported_build_system", module.name)) } - override fun additionalEnvironmentVariables(module: Module, samOptions: SamOptions): Map { + return Paths.get(buildFileDir) + } + + override fun additionalBuildEnvironmentVariables(project: Project, module: Module?, samOptions: SamOptions): Map { if (samOptions.buildInContainer) { return emptyMap() } - val sdk = ModuleRootManager.getInstance(module).sdk ?: return emptyMap() + val sdk = module ?.let { ModuleRootManager.getInstance(it).sdk } ?: ProjectRootManager.getInstance(project).projectSdk ?: return emptyMap() val sdkHome = sdk.homePath ?: return emptyMap() return if (sdk.sdkType is JavaSdkType) { @@ -39,23 +46,17 @@ class JavaLambdaBuilder : LambdaBuilder() { } } - private fun isGradle(module: Module): Boolean = ExternalSystemModulePropertyManager.getInstance(module) - .getExternalSystemId() == "GRADLE" + private fun isGradle(module: Module): Boolean = ExternalSystemModulePropertyManager.getInstance(module).getExternalSystemId() == "GRADLE" - private fun getGradleProjectLocation(module: Module): String = - ExternalSystemApiUtil.getExternalProjectPath(module) - ?: throw IllegalStateException(message("lambda.build.unable_to_locate_project_root", module)) - - private fun isMaven(module: Module): Boolean { - if (pluginIsInstalledAndEnabled("org.jetbrains.idea.maven")) { - return MavenProjectsManager.getInstance(module.project).isMavenizedModule(module) - } + private fun getGradleProjectLocation(module: Module): String = ExternalSystemApiUtil.getExternalProjectPath(module) + ?: throw IllegalStateException(message("lambda.build.unable_to_locate_project_root", module)) - return false + private fun isMaven(module: Module): Boolean = if (pluginIsInstalledAndEnabled("org.jetbrains.idea.maven")) { + MavenProjectsManager.getInstance(module.project).isMavenizedModule(module) + } else { + false } - private fun getPomLocation(module: Module): String = - MavenProjectsManager.getInstance(module.project).findProject(module)?.directory ?: throw IllegalStateException( - message("lambda.build.unable_to_locate_project_root", module) - ) + private fun getPomLocation(module: Module): String = MavenProjectsManager.getInstance(module.project).findProject(module)?.directory + ?: throw IllegalStateException(message("lambda.build.unable_to_locate_project_root", module)) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaHandlerResolver.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaHandlerResolver.kt index 440693720e..5069257ac7 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaHandlerResolver.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaHandlerResolver.kt @@ -22,8 +22,6 @@ import com.intellij.psi.search.GlobalSearchScope import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver class JavaLambdaHandlerResolver : LambdaHandlerResolver { - override fun version(): Int = 1 - override fun findPsiElements( project: Project, handler: String, @@ -41,9 +39,20 @@ class JavaLambdaHandlerResolver : LambdaHandlerResolver { classes.filterIsInstance().toTypedArray() } else { val handlerMethod = classes.asSequence() - .map { it.findMethodsByName(methodName, true) } + .map { psiClass -> + psiClass.findMethodsByName(methodName, true) + .filter { it.body != null } // Filter out interfaces + .filter { + val file = it.containingFile.virtualFile + + return@filter if (psiClass.implementsLambdaHandlerInterface(file)) { + true + } else { + it.isValidHandler(psiClass, file) + } + } + } .flatMap { it.asSequence() } - .filter { it.body != null } // Filter out interfaces .pickMostSpecificHandler() handlerMethod?.let { arrayOf(it) @@ -54,7 +63,7 @@ class JavaLambdaHandlerResolver : LambdaHandlerResolver { } override fun determineHandler(element: PsiElement): String? = - DumbService.getInstance(element.project).computeWithAlternativeResolveEnabled { + DumbService.getInstance(element.project).computeWithAlternativeResolveEnabled { when (element) { is PsiClass -> findByClass(element) is PsiMethod -> findByMethod(element) @@ -131,7 +140,8 @@ class JavaLambdaHandlerResolver : LambdaHandlerResolver { private fun findByClass(clz: PsiClass): String? = if (clz.canBeInstantiatedByLambda() && clz.containingFile.virtualFile != null && - clz.implementsLambdaHandlerInterface(clz.containingFile.virtualFile)) { + clz.implementsLambdaHandlerInterface(clz.containingFile.virtualFile) + ) { clz.qualifiedName } else { null @@ -147,11 +157,13 @@ class JavaLambdaHandlerResolver : LambdaHandlerResolver { clz.qualifiedName?.let { handlers.add(it) } } - handlers.addAll(clz.allMethods - .asSequence() - .filter { it.isValidHandler(clz, file) } - .map { "${clz.qualifiedName}::${it.name}" } - .toSet()) + handlers.addAll( + clz.allMethods + .asSequence() + .filter { it.isValidHandler(clz, file) } + .map { "${clz.qualifiedName}::${it.name}" } + .toSet() + ) return handlers } @@ -187,15 +199,16 @@ class JavaLambdaHandlerResolver : LambdaHandlerResolver { !(parentClass.implementsLambdaHandlerInterface(file) && this.name == HANDLER_NAME) private fun PsiMethod.hasRequiredParameters(): Boolean = when (this.parameters.size) { - 1 -> true - 2 -> (this.parameterList.parameters[0].isInputStreamParameter() && - this.parameterList.parameters[1].isOutputStreamParameter()) || - this.parameterList.parameters[1].isContextParameter() - 3 -> this.parameterList.parameters[0].isInputStreamParameter() && - this.parameterList.parameters[1].isOutputStreamParameter() && - this.parameterList.parameters[2].isContextParameter() - else -> false - } + 1 -> true + 2 -> + (this.parameterList.parameters[0].isInputStreamParameter() && this.parameterList.parameters[1].isOutputStreamParameter()) || + this.parameterList.parameters[1].isContextParameter() + 3 -> + this.parameterList.parameters[0].isInputStreamParameter() && + this.parameterList.parameters[1].isOutputStreamParameter() && + this.parameterList.parameters[2].isContextParameter() + else -> false + } private fun PsiParameter.isContextParameter(): Boolean = isClass(LAMBDA_CONTEXT) private fun PsiParameter.isInputStreamParameter(): Boolean = isClass(INPUT_STREAM) @@ -205,7 +218,7 @@ class JavaLambdaHandlerResolver : LambdaHandlerResolver { PsiType.getTypeByName( classFullName, project, - GlobalSearchScope.projectScope(project) + GlobalSearchScope.allScope(project) ).isAssignableFrom(this.type) private companion object { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaRuntimeGroup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaRuntimeGroup.kt index a28cf616e0..c99ad45c0c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaRuntimeGroup.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaRuntimeGroup.kt @@ -11,14 +11,23 @@ import com.intellij.openapi.projectRoots.JavaSdkType import com.intellij.openapi.projectRoots.JavaSdkVersion import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.projectRoots.SdkType -import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.jetbrains.services.lambda.SdkBasedRuntimeGroupInformation +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.BuiltInRuntimeGroups +import software.aws.toolkits.jetbrains.services.lambda.SdkBasedRuntimeGroup -class JavaRuntimeGroup : SdkBasedRuntimeGroupInformation() { - override val runtimes = setOf(Runtime.JAVA8, Runtime.JAVA11) +class JavaRuntimeGroup : SdkBasedRuntimeGroup() { + override val id: String = BuiltInRuntimeGroups.Java override val languageIds = setOf(JavaLanguage.INSTANCE.id) + override val supportsPathMappings: Boolean = false - override fun runtimeForSdk(sdk: Sdk): Runtime? { + override val supportedRuntimes: List = listOf( + LambdaRuntime.JAVA8, + LambdaRuntime.JAVA8_AL2, + LambdaRuntime.JAVA11, + LambdaRuntime.JAVA17 + ) + + override fun runtimeForSdk(sdk: Sdk): LambdaRuntime? { if (sdk.sdkType is JavaSdkType) { val javaSdkVersion = JavaSdk.getInstance().getVersion(sdk) ?: return null return determineRuntimeForSdk(javaSdkVersion) @@ -27,14 +36,14 @@ class JavaRuntimeGroup : SdkBasedRuntimeGroupInformation() { } private fun determineRuntimeForSdk(sdk: JavaSdkVersion) = when { - sdk <= JavaSdkVersion.JDK_1_8 -> Runtime.JAVA8 - sdk <= JavaSdkVersion.JDK_11 -> Runtime.JAVA11 + // TODO: is this actually the right logic? + sdk <= JavaSdkVersion.JDK_1_8 -> LambdaRuntime.JAVA8_AL2 + sdk <= JavaSdkVersion.JDK_11 -> LambdaRuntime.JAVA11 + sdk <= JavaSdkVersion.JDK_17 -> LambdaRuntime.JAVA17 else -> null } override fun getModuleType(): ModuleType<*> = JavaModuleType.getModuleType() override fun getIdeSdkType(): SdkType = JavaSdk.getInstance() - - override fun supportsSamBuild(): Boolean = true } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaSamDebugSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaSamDebugSupport.kt deleted file mode 100644 index 93716fd92f..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaSamDebugSupport.kt +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.java - -import com.intellij.debugger.DebuggerManagerEx -import com.intellij.debugger.DefaultDebugEnvironment -import com.intellij.debugger.engine.JavaDebugProcess -import com.intellij.execution.DefaultExecutionResult -import com.intellij.execution.configurations.RemoteConnection -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.xdebugger.XDebugProcess -import com.intellij.xdebugger.XDebugProcessStarter -import com.intellij.xdebugger.XDebugSession -import com.intellij.xdebugger.impl.XDebugSessionImpl -import software.aws.toolkits.jetbrains.services.lambda.execution.local.SamDebugSupport -import software.aws.toolkits.jetbrains.services.lambda.execution.local.SamRunningState - -class JavaSamDebugSupport : SamDebugSupport { - override fun createDebugProcess( - environment: ExecutionEnvironment, - state: SamRunningState, - debugHost: String, - debugPorts: List - ): XDebugProcessStarter? { - val connection = RemoteConnection(true, debugHost, debugPorts.first().toString(), false) - val debugEnvironment = DefaultDebugEnvironment(environment, state, connection, true) - val debuggerManager = DebuggerManagerEx.getInstanceEx(environment.project) - val debuggerSession = debuggerManager.attachVirtualMachine(debugEnvironment) ?: return null - - return object : XDebugProcessStarter() { - override fun start(session: XDebugSession): XDebugProcess { - if (debuggerSession is XDebugSessionImpl) { - val debugProcess = debuggerSession.process - val executionResult = debugProcess.executionResult - debuggerSession.addExtraActions(*executionResult.actions) - if (executionResult is DefaultExecutionResult) { - debuggerSession.addRestartActions(*executionResult.restartActions) - } - } - - return JavaDebugProcess.create(session, debuggerSession) - } - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaSamProjectWizard.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaSamProjectWizard.kt index 7fee1caa96..6f90f0a2a0 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaSamProjectWizard.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/java/JavaSamProjectWizard.kt @@ -3,13 +3,11 @@ package software.aws.toolkits.jetbrains.services.lambda.java -import com.intellij.openapi.externalSystem.importing.ImportSpecBuilder -import com.intellij.openapi.externalSystem.service.execution.ProgressExecutionMode +import com.intellij.openapi.application.runInEdt import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil -import com.intellij.openapi.externalSystem.util.ExternalSystemUtil import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ModifiableRootModel +import com.intellij.openapi.ui.TextFieldWithBrowseButton import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VfsUtil @@ -17,46 +15,34 @@ import com.intellij.openapi.vfs.VirtualFile import org.jetbrains.idea.maven.project.MavenProjectsManager import org.jetbrains.plugins.gradle.settings.GradleProjectSettings import org.jetbrains.plugins.gradle.util.GradleConstants -import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.core.lambda.LambdaRuntime import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.logWhenNull -import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup -import software.aws.toolkits.jetbrains.services.lambda.SamNewProjectSettings -import software.aws.toolkits.jetbrains.services.lambda.SamProjectTemplate -import software.aws.toolkits.jetbrains.services.lambda.SamProjectWizard -import software.aws.toolkits.jetbrains.services.lambda.TemplateParameters -import software.aws.toolkits.jetbrains.services.lambda.TemplateParameters.AppBasedTemplate -import software.aws.toolkits.jetbrains.services.lambda.sam.SamSchemaDownloadPostCreationAction -import software.aws.toolkits.jetbrains.services.schemas.SchemaCodeLangs -import software.aws.toolkits.jetbrains.ui.wizard.IntelliJSdkSelectionPanel -import software.aws.toolkits.jetbrains.ui.wizard.SamProjectGenerator -import software.aws.toolkits.jetbrains.ui.wizard.SchemaResourceSelectorSelectionPanel -import software.aws.toolkits.jetbrains.ui.wizard.SchemaSelectionPanel -import software.aws.toolkits.jetbrains.ui.wizard.SdkSelectionPanel +import software.aws.toolkits.jetbrains.services.lambda.BuiltInRuntimeGroups +import software.aws.toolkits.jetbrains.services.lambda.wizard.IntelliJSdkSelectionPanel +import software.aws.toolkits.jetbrains.services.lambda.wizard.SamAppTemplateBased +import software.aws.toolkits.jetbrains.services.lambda.wizard.SamNewProjectSettings +import software.aws.toolkits.jetbrains.services.lambda.wizard.SamProjectTemplate +import software.aws.toolkits.jetbrains.services.lambda.wizard.SamProjectWizard +import software.aws.toolkits.jetbrains.services.lambda.wizard.SdkSelector import software.aws.toolkits.resources.message -import java.nio.file.Paths class JavaSamProjectWizard : SamProjectWizard { - override fun createSchemaSelectionPanel( - generator: SamProjectGenerator - ): SchemaSelectionPanel = - SchemaResourceSelectorSelectionPanel(generator.builder, RuntimeGroup.JAVA, generator.defaultSourceCreatingProject) - - override fun createSdkSelectionPanel(generator: SamProjectGenerator): SdkSelectionPanel = - IntelliJSdkSelectionPanel(generator.builder, RuntimeGroup.JAVA) + override fun createSdkSelectionPanel(projectLocation: TextFieldWithBrowseButton?): SdkSelector = IntelliJSdkSelectionPanel(BuiltInRuntimeGroups.Java) override fun listTemplates(): Collection = listOf( - SamHelloWorldMaven(), SamHelloWorldGradle(), - SamEventBridgeHelloWorldMaven(), + SamHelloWorldMaven(), SamEventBridgeHelloWorldGradle(), - SamEventBridgeStarterAppMaven(), - SamEventBridgeStarterAppGradle() + SamEventBridgeHelloWorldMaven(), + SamEventBridgeStarterAppGradle(), + SamEventBridgeStarterAppMaven() ) } -abstract class JavaSamProjectTemplate : SamProjectTemplate() { - override fun supportedRuntimes() = setOf(Runtime.JAVA8, Runtime.JAVA11) +abstract class JavaSamProjectTemplate : SamAppTemplateBased() { + override fun supportedZipRuntimes() = setOf(LambdaRuntime.JAVA8, LambdaRuntime.JAVA8_AL2, LambdaRuntime.JAVA11, LambdaRuntime.JAVA17) + override fun supportedImageRuntimes() = setOf(LambdaRuntime.JAVA8, LambdaRuntime.JAVA8_AL2, LambdaRuntime.JAVA11, LambdaRuntime.JAVA17) // Helper method to locate the build file, such as pom.xml in the project content root. protected fun locateBuildFile(contentRoot: VirtualFile, buildFileName: String): VirtualFile? { @@ -79,173 +65,103 @@ abstract class JavaSamProjectTemplate : SamProjectTemplate() { } } -abstract class JavaGradleSamProjectTemplate : JavaSamProjectTemplate() { - override fun postCreationAction( - settings: SamNewProjectSettings, - contentRoot: VirtualFile, - rootModel: ModifiableRootModel, - sourceCreatingProject: Project, - indicator: ProgressIndicator - ) { - super.postCreationAction(settings, contentRoot, rootModel, sourceCreatingProject, indicator) - val buildFile = locateBuildFile(contentRoot, "build.gradle") ?: return - - val gradleProjectSettings = GradleProjectSettings().apply { - withQualifiedModuleNames() - externalProjectPath = buildFile.path - } - - val externalSystemSettings = ExternalSystemApiUtil.getSettings(rootModel.project, GradleConstants.SYSTEM_ID) - externalSystemSettings.setLinkedProjectsSettings(setOf(gradleProjectSettings)) - - val importSpecBuilder = ImportSpecBuilder(rootModel.project, GradleConstants.SYSTEM_ID) - .forceWhenUptodate() - .useDefaultCallback() - .use(ProgressExecutionMode.IN_BACKGROUND_ASYNC) - - ExternalSystemUtil.refreshProjects(importSpecBuilder) - } -} - -class SamHelloWorldMaven : JavaSamProjectTemplate() { - override fun getName() = message("sam.init.template.hello_world_maven.name") - - override fun getDescription() = message("sam.init.template.hello_world.description") - - override fun templateParameters(): TemplateParameters = AppBasedTemplate("hello-world", "maven") +abstract class JavaMavenSamProjectTemplate : JavaSamProjectTemplate() { + override val dependencyManager: String = "maven" override fun postCreationAction( settings: SamNewProjectSettings, contentRoot: VirtualFile, rootModel: ModifiableRootModel, - sourceCreatingProject: Project, indicator: ProgressIndicator ) { - super.postCreationAction(settings, contentRoot, rootModel, sourceCreatingProject, indicator) + super.postCreationAction(settings, contentRoot, rootModel, indicator) val pomFile = locateBuildFile(contentRoot, "pom.xml") ?: return val projectsManager = MavenProjectsManager.getInstance(rootModel.project) - projectsManager.addManagedFilesOrUnignore(listOf(pomFile)) + runInEdt { + projectsManager.addManagedFilesOrUnignore(listOf(pomFile)) + } } } -class SamHelloWorldGradle : JavaGradleSamProjectTemplate() { - override fun getName() = message("sam.init.template.hello_world_gradle.name") - - override fun getDescription() = message("sam.init.template.hello_world.description") - - override fun templateParameters(): TemplateParameters = AppBasedTemplate("hello-world", "gradle") +abstract class JavaGradleSamProjectTemplate : JavaSamProjectTemplate() { + override val dependencyManager: String = "gradle" override fun postCreationAction( settings: SamNewProjectSettings, contentRoot: VirtualFile, rootModel: ModifiableRootModel, - sourceCreatingProject: Project, indicator: ProgressIndicator ) { - super.postCreationAction(settings, contentRoot, rootModel, sourceCreatingProject, indicator) val buildFile = locateBuildFile(contentRoot, "build.gradle") ?: return val gradleProjectSettings = GradleProjectSettings().apply { withQualifiedModuleNames() - externalProjectPath = buildFile.path + externalProjectPath = buildFile.parent.path } val externalSystemSettings = ExternalSystemApiUtil.getSettings(rootModel.project, GradleConstants.SYSTEM_ID) externalSystemSettings.setLinkedProjectsSettings(setOf(gradleProjectSettings)) - val importSpecBuilder = ImportSpecBuilder(rootModel.project, GradleConstants.SYSTEM_ID) - .forceWhenUptodate() - .useDefaultCallback() - .use(ProgressExecutionMode.IN_BACKGROUND_ASYNC) - - ExternalSystemUtil.refreshProjects(importSpecBuilder) + super.postCreationAction(settings, contentRoot, rootModel, indicator) } } -class SamEventBridgeStarterAppGradle : JavaGradleSamProjectTemplate() { - override fun getName() = message("sam.init.template.eventBridge_starterApp_gradle.name") - - override fun getDescription() = message("sam.init.template.eventBridge_starterApp.description") +class SamHelloWorldMaven : JavaMavenSamProjectTemplate() { + override fun displayName() = message("sam.init.template.hello_world_maven.name") - override fun functionName(): String = "HelloWorldFunction" + override fun description() = message("sam.init.template.hello_world.description") - override fun templateParameters(): TemplateParameters = AppBasedTemplate("eventBridge-schema-app", "gradle") + override val appTemplateName: String = "hello-world" +} - override fun supportsDynamicSchemas(): Boolean = true +class SamHelloWorldGradle : JavaGradleSamProjectTemplate() { + override fun displayName() = message("sam.init.template.hello_world_gradle.name") - override fun postCreationAction( - settings: SamNewProjectSettings, - contentRoot: VirtualFile, - rootModel: ModifiableRootModel, - sourceCreatingProject: Project, - indicator: ProgressIndicator - ) { - settings.schemaParameters?.let { - val functionRoot = Paths.get(contentRoot.path, functionName()) - - SamSchemaDownloadPostCreationAction().downloadCodeIntoWorkspace( - it, - contentRoot, - functionRoot, - SchemaCodeLangs.JAVA8, - sourceCreatingProject, - rootModel.project, - indicator - ) - } + override fun description() = message("sam.init.template.hello_world.description") - super.postCreationAction(settings, contentRoot, rootModel, sourceCreatingProject, indicator) - } + override val appTemplateName: String = "hello-world" } -class SamEventBridgeStarterAppMaven : JavaGradleSamProjectTemplate() { - override fun getName() = message("sam.init.template.eventBridge_starterApp_maven.name") +class SamEventBridgeStarterAppGradle : JavaGradleSamProjectTemplate() { + override fun displayName() = message("sam.init.template.event_bridge_starter_app_gradle.name") - override fun getDescription() = message("sam.init.template.eventBridge_starterApp.description") + override fun description() = message("sam.init.template.event_bridge_starter_app.description") - override fun functionName(): String = "HelloWorldFunction" + override fun supportedImageRuntimes() = emptySet() - override fun templateParameters(): TemplateParameters = AppBasedTemplate("eventBridge-schema-app", "maven") + override val appTemplateName: String = "eventBridge-schema-app" override fun supportsDynamicSchemas(): Boolean = true +} - override fun postCreationAction( - settings: SamNewProjectSettings, - contentRoot: VirtualFile, - rootModel: ModifiableRootModel, - sourceCreatingProject: Project, - indicator: ProgressIndicator - ) { - settings.schemaParameters?.let { - val functionRoot = Paths.get(contentRoot.path, functionName()) - - SamSchemaDownloadPostCreationAction().downloadCodeIntoWorkspace( - it, - contentRoot, - functionRoot, - SchemaCodeLangs.JAVA8, - sourceCreatingProject, - rootModel.project, - indicator - ) - } +class SamEventBridgeStarterAppMaven : JavaMavenSamProjectTemplate() { + override fun displayName() = message("sam.init.template.event_bridge_starter_app_maven.name") - super.postCreationAction(settings, contentRoot, rootModel, sourceCreatingProject, indicator) - } + override fun description() = message("sam.init.template.event_bridge_starter_app.description") + + override fun supportedImageRuntimes() = emptySet() + + override val appTemplateName: String = "eventBridge-schema-app" + + override fun supportsDynamicSchemas(): Boolean = true } class SamEventBridgeHelloWorldGradle : JavaGradleSamProjectTemplate() { - override fun getName() = message("sam.init.template.eventBridge_helloWorld_gradle.name") + override fun displayName() = message("sam.init.template.event_bridge_hello_world_gradle.name") + + override fun description() = message("sam.init.template.event_bridge_hello_world.description") - override fun getDescription() = message("sam.init.template.eventBridge_helloWorld.description") + override fun supportedImageRuntimes() = emptySet() - override fun templateParameters(): TemplateParameters = AppBasedTemplate("eventBridge-hello-world", "gradle") + override val appTemplateName: String = "eventBridge-hello-world" } -class SamEventBridgeHelloWorldMaven : JavaGradleSamProjectTemplate() { - override fun getName() = message("sam.init.template.eventBridge_helloWorld_maven.name") +class SamEventBridgeHelloWorldMaven : JavaMavenSamProjectTemplate() { + override fun displayName() = message("sam.init.template.event_bridge_hello_world_maven.name") + + override fun description() = message("sam.init.template.event_bridge_hello_world.description") - override fun getDescription() = message("sam.init.template.eventBridge_helloWorld.description") + override fun supportedImageRuntimes() = emptySet() - override fun templateParameters(): TemplateParameters = AppBasedTemplate("eventBridge-hello-world", "maven") + override val appTemplateName: String = "eventBridge-hello-world" } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PyCharmSdkSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PyCharmSdkSelectionPanel.kt new file mode 100644 index 0000000000..290bb77b72 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PyCharmSdkSelectionPanel.kt @@ -0,0 +1,72 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.python + +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.DocumentAdapter +import com.jetbrains.python.configuration.PyConfigurableInterpreterList +import com.jetbrains.python.newProject.steps.PyAddExistingSdkPanel +import com.jetbrains.python.newProject.steps.PyAddNewEnvironmentPanel +import com.jetbrains.python.sdk.PreferredSdkComparator +import com.jetbrains.python.sdk.PySdkSettings +import com.jetbrains.python.sdk.PythonSdkType +import com.jetbrains.python.sdk.PythonSdkUtil +import com.jetbrains.python.sdk.add.PyAddSdkGroupPanel +import software.aws.toolkits.jetbrains.services.lambda.wizard.SdkSelector +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.event.DocumentEvent + +class PyCharmSdkSelectionPanel(private val projectLocation: TextFieldWithBrowseButton?) : SdkSelector { + private val sdkPanel by lazy { + sdkPanel() + } + + override fun sdkSelectionPanel(): JComponent = sdkPanel + + override fun sdkSelectionLabel(): JLabel? = null + + private fun sdkPanel(): PyAddSdkGroupPanel { + // Based on PyCharm's ProjectSpecificSettingsStep + val existingSdks = getValidPythonSdks() + val newProjectLocation = getProjectLocation() + val newEnvironmentPanel = PyAddNewEnvironmentPanel(existingSdks, newProjectLocation, null) + val existingSdkPanel = PyAddExistingSdkPanel(null, null, existingSdks, newProjectLocation, existingSdks.firstOrNull()) + + val defaultPanel = if (PySdkSettings.instance.useNewEnvironmentForNewProject) newEnvironmentPanel else existingSdkPanel + + val interpreterPanel = createPythonSdkPanel(listOf(newEnvironmentPanel, existingSdkPanel), defaultPanel) + + projectLocation?.textField?.document?.addDocumentListener( + object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + interpreterPanel.newProjectPath = getProjectLocation() + } + } + ) + + return interpreterPanel + } + + private fun getProjectLocation(): String? = projectLocation?.text?.trim() + + private fun getValidPythonSdks(): List = PyConfigurableInterpreterList.getInstance(null).allPythonSdks + .asSequence() + .filter { it.sdkType is PythonSdkType && !PythonSdkUtil.isInvalid(it) } + .sortedWith(PreferredSdkComparator()) + .toList() + + override fun getSdk(): Sdk? { + val sdk = sdkPanel.getOrCreateSdk() ?: return null + if (sdkPanel.selectedPanel is PyAddNewEnvironmentPanel) { + SdkConfigurationUtil.addSdk(sdk) + } + return sdk + } + + override fun validateSelection(): ValidationInfo? = sdkPanel.validateAll().firstOrNull() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PyCharmSdkUtil.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PyCharmSdkUtil.kt new file mode 100644 index 0000000000..a7a2c553e4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PyCharmSdkUtil.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.lambda.python + +import com.intellij.util.ui.EmptyIcon +import com.jetbrains.python.sdk.add.PyAddSdkGroupPanel +import com.jetbrains.python.sdk.add.PyAddSdkPanel +import java.util.function.Supplier + +fun createPythonSdkPanel(panels: List, defaultPanel: PyAddSdkPanel): PyAddSdkGroupPanel = + // String and icon aren't used, they are just returned again in the icon and panelName val's + PyAddSdkGroupPanel(Supplier { "Python SDK" }, EmptyIcon.ICON_16, panels, defaultPanel) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonDebugSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonDebugSupport.kt new file mode 100644 index 0000000000..777d7f8f96 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonDebugSupport.kt @@ -0,0 +1,148 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.python + +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.xdebugger.DefaultDebugProcessHandler +import com.intellij.xdebugger.XDebugProcess +import com.intellij.xdebugger.XDebugProcessStarter +import com.intellij.xdebugger.XDebugSession +import com.jetbrains.python.PythonHelper +import com.jetbrains.python.PythonLanguage +import com.jetbrains.python.console.PyDebugConsoleBuilder +import com.jetbrains.python.console.PythonDebugLanguageConsoleView +import com.jetbrains.python.debugger.PyDebugProcess +import com.jetbrains.python.debugger.PyDebugRunner +import com.jetbrains.python.sdk.PythonSdkType +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.jetbrains.services.PathMapper +import software.aws.toolkits.jetbrains.services.PathMapping +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.ImageDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.RuntimeDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.SamRunningState +import software.aws.toolkits.jetbrains.utils.execution.steps.Context + +class PythonRuntimeDebugSupport : RuntimeDebugSupport { + override fun samArguments(debugPorts: List): List = listOf( + "--debugger-path", + // Mount pydevd from PyCharm into docker + PythonHelper.DEBUGGER.pythonPathEntry, + "--debug-args", + "-u $DEBUGGER_VOLUME_PATH/pydevd.py --multiprocess --port ${debugPorts.first()} --file" + ) + + override suspend fun createDebugProcess( + context: Context, + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List + ): XDebugProcessStarter = createDebugProcess(environment, state, debugHost, debugPorts) +} + +abstract class PythonImageDebugSupport : ImageDebugSupport { + override fun supportsPathMappings(): Boolean = true + override val languageId = PythonLanguage.INSTANCE.id + + // Image debug settings are out of the SAM cli https://github.com/aws/aws-sam-cli/blob/develop/samcli/local/docker/lambda_debug_settings.py + protected abstract val pythonPath: String + protected abstract val bootstrapPath: String + + override suspend fun createDebugProcess( + context: Context, + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List + ): XDebugProcessStarter = createDebugProcess(environment, state, debugHost, debugPorts) + + override fun samArguments(debugPorts: List): List = buildList { + addAll(super.samArguments(debugPorts)) + + val debugArgs = "$pythonPath -u $DEBUGGER_VOLUME_PATH/pydevd.py --multiprocess --port ${debugPorts.first()} --file $bootstrapPath" + + add("--debugger-path") + // Mount pydevd from PyCharm into docker + add(PythonHelper.DEBUGGER.pythonPathEntry) + add("--debug-args") + add(debugArgs) + } +} + +class Python37ImageDebugSupport : PythonImageDebugSupport() { + override val id: String = LambdaRuntime.PYTHON3_7.toString() + override fun displayName() = LambdaRuntime.PYTHON3_7.toString().capitalize() + override val pythonPath: String = "/var/lang/bin/python3.7" + override val bootstrapPath: String = "/var/runtime/bootstrap" +} + +class Python38ImageDebugSupport : PythonImageDebugSupport() { + override val id: String = LambdaRuntime.PYTHON3_8.toString() + override fun displayName() = LambdaRuntime.PYTHON3_8.toString().capitalize() + override val pythonPath: String = "/var/lang/bin/python3.8" + override val bootstrapPath: String = "/var/runtime/bootstrap.py" +} + +class Python39ImageDebugSupport : PythonImageDebugSupport() { + override val id: String = LambdaRuntime.PYTHON3_9.toString() + override fun displayName() = LambdaRuntime.PYTHON3_9.toString().capitalize() + override val pythonPath: String = "/var/lang/bin/python3.9" + override val bootstrapPath: String = "/var/runtime/bootstrap.py" +} + +class Python310ImageDebugSupport : PythonImageDebugSupport() { + override val id: String = LambdaRuntime.PYTHON3_10.toString() + override fun displayName() = LambdaRuntime.PYTHON3_10.toString().capitalize() + override val pythonPath: String = "/var/lang/bin/python3.10" + override val bootstrapPath: String = "/var/runtime/bootstrap.py" +} + +class Python311ImageDebugSupport : PythonImageDebugSupport() { + override val id: String = LambdaRuntime.PYTHON3_11.toString() + override fun displayName() = LambdaRuntime.PYTHON3_11.toString().capitalize() + override val pythonPath: String = "/var/lang/bin/python3.11" + override val bootstrapPath: String = "/var/runtime/bootstrap.py" +} + +private const val DEBUGGER_VOLUME_PATH = "/tmp/lambci_debug_files" + +private fun createDebugProcess( + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List +): XDebugProcessStarter { + // TODO: We should allow using the module SDK, but we can't easily get the module + val sdk = ProjectRootManager.getInstance(environment.project).projectSdk?.takeIf { it.sdkType is PythonSdkType } + + return object : XDebugProcessStarter() { + override fun start(session: XDebugSession): XDebugProcess { + val mappings = state.pathMappings.plus( + listOf( + PathMapping( + PythonHelper.DEBUGGER.pythonPathEntry, + DEBUGGER_VOLUME_PATH + ) + ) + ) + + val console = PyDebugConsoleBuilder(environment.project, sdk).console as PythonDebugLanguageConsoleView + + val handler = DefaultDebugProcessHandler() + return PyDebugProcess( + session, + console, + handler, + debugHost, + debugPorts.first() + ).also { + it.positionConverter = PathMapper.PositionConverter(PathMapper(mappings)) + console.attachToProcess(handler) + PyDebugRunner.initDebugConsoleView(environment.project, it, console, it.processHandler, session) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaBuilder.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaBuilder.kt index cfb7657783..63155d24af 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaBuilder.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaBuilder.kt @@ -4,42 +4,36 @@ package software.aws.toolkits.jetbrains.services.lambda.python import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.application.runReadAction import com.intellij.openapi.module.Module -import com.intellij.openapi.project.Project -import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiElement import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder -import software.aws.toolkits.resources.message +import java.nio.file.Path +import java.nio.file.Paths class PythonLambdaBuilder : LambdaBuilder() { - override fun baseDirectory(module: Module, handlerElement: PsiElement): String { + override fun handlerBaseDirectory(module: Module, handlerElement: PsiElement): Path { val handlerVirtualFile = ReadAction.compute { handlerElement.containingFile?.virtualFile ?: throw IllegalArgumentException("Handler file must be backed by a VirtualFile") } - return getBaseDirectory(module.project, handlerVirtualFile).path + return Paths.get(locateRequirementsTxt(handlerVirtualFile).parent.path) } - private fun getBaseDirectory(project: Project, virtualFile: VirtualFile): VirtualFile { - val fileIndex = ProjectFileIndex.getInstance(project) - - fileIndex.getSourceRootForFile(virtualFile)?.let { - return it - } - - fileIndex.getContentRootForFile(virtualFile)?.let { contentRoot -> - var dir: VirtualFile? = virtualFile + companion object { + fun locateRequirementsTxt(startLocation: VirtualFile): VirtualFile = runReadAction { + var dir = if (startLocation.isDirectory) startLocation else startLocation.parent while (dir != null) { - if (dir == contentRoot || dir.findChild("requirements.txt") != null) { - return dir + val requirementsFile = dir.findChild("requirements.txt") + if (requirementsFile != null && requirementsFile.isValid) { + return@runReadAction requirementsFile } - dir = dir.parent } - } - throw IllegalStateException(message("lambda.build.unable_to_locate_handler_root")) + throw IllegalStateException("Cannot locate requirements.txt in a parent directory of ${startLocation.path}") + } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaHandlerResolver.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaHandlerResolver.kt index de08a4c838..90f05bc8cf 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaHandlerResolver.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonLambdaHandlerResolver.kt @@ -3,11 +3,9 @@ package software.aws.toolkits.jetbrains.services.lambda.python -import com.intellij.openapi.module.Module -import com.intellij.openapi.module.ModuleUtilCore import com.intellij.openapi.project.Project -import com.intellij.openapi.roots.ModuleRootManager import com.intellij.openapi.roots.TestSourcesFilter +import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.NavigatablePsiElement import com.intellij.psi.PsiDirectory @@ -21,8 +19,6 @@ import com.jetbrains.python.psi.stubs.PyModuleNameIndex import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver class PythonLambdaHandlerResolver : LambdaHandlerResolver { - override fun version(): Int = 1 - override fun determineHandlers(element: PsiElement, file: VirtualFile): Set = determineHandler(element)?.let { setOf(it) }.orEmpty() @@ -45,10 +41,7 @@ class PythonLambdaHandlerResolver : LambdaHandlerResolver { // Find the module by the name PyModuleNameIndex.find(moduleFile, project, false).forEach { pyModule -> val lambdaFunctionCandidate = pyModule.findTopLevelFunction(functionName) ?: return@forEach - - val module = ModuleUtilCore.findModuleForFile(lambdaFunctionCandidate.containingFile) - - if (validateHandlerPath(module, pyModule, moduleFolders, parentFolders)) { + if (validateHandlerPath(pyModule, moduleFolders, parentFolders)) { return arrayOf(lambdaFunctionCandidate) } } @@ -57,19 +50,25 @@ class PythonLambdaHandlerResolver : LambdaHandlerResolver { } private fun validateHandlerPath( - module: Module?, pyModule: PyFile, parentModuleFolders: List, parentFolders: List ): Boolean { // Start matching to see if the parent folders align var directory = pyModule.containingDirectory + val hasRequirementsTxt = { directory.virtualFile.findChild("requirements.txt") != null } - // Go from deepest back up + // Go from the deepest back up until we find a requirements.txt + // SAM CLI will strictly use requirements.txt in the directory defined by CodeUri, but we don't have that data here parentModuleFolders.reversed().forEach { parentModule -> if (parentModule != directory?.name || !directoryHasInitPy(directory)) { return false } + + if (hasRequirementsTxt()) { + return true + } + directory = directory.parentDirectory } @@ -77,26 +76,19 @@ class PythonLambdaHandlerResolver : LambdaHandlerResolver { if (folder != directory?.name) { return false } - directory = directory.parentDirectory - } - val rootVirtualFile = directory.virtualFile - module?.let { - val rootManager = ModuleRootManager.getInstance(module) - if (rootManager.contentRoots.contains(rootVirtualFile)) { + if (hasRequirementsTxt()) { return true } - if (rootManager.getSourceRoots(false).contains(rootVirtualFile)) { - return true - } - - if (rootVirtualFile.findChild("requirements.txt") != null) { - return true - } + directory = directory.parentDirectory } - return false + if (hasRequirementsTxt()) { + return true + } else { + throw IllegalStateException("Failed to locate requirements.txt") + } } private fun directoryHasInitPy(psiDirectory: PsiDirectory) = psiDirectory.findFile("__init__.py") != null @@ -117,7 +109,13 @@ class PythonLambdaHandlerResolver : LambdaHandlerResolver { // https://pytest.readthedocs.io/en/reorganize-docs/new-docs/user/naming_conventions.html#id1 function.name?.startsWith("test_") != true ) { - return function.qualifiedName + val requirementsFileDir = runCatching { PythonLambdaBuilder.locateRequirementsTxt(virtualFile).parent }.getOrNull() ?: return null + val filePath = if (virtualFile.parent != requirementsFileDir) { + VfsUtil.getRelativePath(virtualFile.parent, requirementsFileDir)?.let { "$it/" } ?: return null + } else { + "" + } + return "$filePath${virtualFile.nameWithoutExtension}.${function.name}" } return null } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonRuntimeGroup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonRuntimeGroup.kt index 486ee0b247..feed187f1e 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonRuntimeGroup.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonRuntimeGroup.kt @@ -10,35 +10,33 @@ import com.jetbrains.python.PythonLanguage import com.jetbrains.python.PythonModuleTypeBase import com.jetbrains.python.psi.LanguageLevel import com.jetbrains.python.sdk.PythonSdkType -import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.jetbrains.services.lambda.SdkBasedRuntimeGroupInformation - -class PythonRuntimeGroup : SdkBasedRuntimeGroupInformation() { - - override val runtimes: Set = setOf( - Runtime.PYTHON2_7, - Runtime.PYTHON3_6, - Runtime.PYTHON3_7, - Runtime.PYTHON3_8 - ) +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.BuiltInRuntimeGroups +import software.aws.toolkits.jetbrains.services.lambda.SdkBasedRuntimeGroup +class PythonRuntimeGroup : SdkBasedRuntimeGroup() { + override val id: String = BuiltInRuntimeGroups.Python override val languageIds: Set = setOf(PythonLanguage.INSTANCE.id) + override val supportsPathMappings: Boolean = true + + override val supportedRuntimes = listOf( + LambdaRuntime.PYTHON3_7, + LambdaRuntime.PYTHON3_8, + LambdaRuntime.PYTHON3_9, + LambdaRuntime.PYTHON3_10, + LambdaRuntime.PYTHON3_11 + ) - override fun runtimeForSdk(sdk: Sdk): Runtime? = determineRuntimeForSdk(sdk) + override fun runtimeForSdk(sdk: Sdk): LambdaRuntime? = when { + sdk.sdkType is PythonSdkType && PythonSdkType.getLanguageLevelForSdk(sdk).isAtLeast(LanguageLevel.PYTHON311) -> LambdaRuntime.PYTHON3_11 + sdk.sdkType is PythonSdkType && PythonSdkType.getLanguageLevelForSdk(sdk).isAtLeast(LanguageLevel.PYTHON310) -> LambdaRuntime.PYTHON3_10 + sdk.sdkType is PythonSdkType && PythonSdkType.getLanguageLevelForSdk(sdk).isAtLeast(LanguageLevel.PYTHON39) -> LambdaRuntime.PYTHON3_9 + sdk.sdkType is PythonSdkType && PythonSdkType.getLanguageLevelForSdk(sdk).isAtLeast(LanguageLevel.PYTHON38) -> LambdaRuntime.PYTHON3_8 + sdk.sdkType is PythonSdkType && PythonSdkType.getLanguageLevelForSdk(sdk).isAtLeast(LanguageLevel.PYTHON37) -> LambdaRuntime.PYTHON3_7 + else -> null + } override fun getModuleType(): ModuleType<*> = PythonModuleTypeBase.getInstance() override fun getIdeSdkType(): SdkType = PythonSdkType.getInstance() - - override fun supportsSamBuild(): Boolean = true - - companion object { - fun determineRuntimeForSdk(sdk: Sdk) = when { - sdk.sdkType is PythonSdkType && PythonSdkType.getLanguageLevelForSdk(sdk).isAtLeast(LanguageLevel.PYTHON38) -> Runtime.PYTHON3_8 - sdk.sdkType is PythonSdkType && PythonSdkType.getLanguageLevelForSdk(sdk).isAtLeast(LanguageLevel.PYTHON37) -> Runtime.PYTHON3_7 - sdk.sdkType is PythonSdkType && PythonSdkType.getLanguageLevelForSdk(sdk).isPy3K -> Runtime.PYTHON3_6 - sdk.sdkType is PythonSdkType && PythonSdkType.getLanguageLevelForSdk(sdk).isPython2 -> Runtime.PYTHON2_7 - else -> null - } - } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonSamDebugSupport.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonSamDebugSupport.kt deleted file mode 100644 index 1fdd1df304..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonSamDebugSupport.kt +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.python - -import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.runners.ExecutionEnvironment -import com.intellij.xdebugger.XDebugProcess -import com.intellij.xdebugger.XDebugProcessStarter -import com.intellij.xdebugger.XDebugSession -import com.jetbrains.python.PythonHelper -import com.jetbrains.python.debugger.PyDebugProcess -import software.aws.toolkits.jetbrains.services.PathMapper -import software.aws.toolkits.jetbrains.services.PathMapping -import software.aws.toolkits.jetbrains.services.lambda.execution.local.SamDebugSupport -import software.aws.toolkits.jetbrains.services.lambda.execution.local.SamRunningState - -class PythonSamDebugSupport : SamDebugSupport { - override fun patchCommandLine(debugPorts: List, commandLine: GeneralCommandLine) { - super.patchCommandLine(debugPorts, commandLine) - - // Note: To debug pydevd, pass '--DEBUG' - val debugArgs = "-u $DEBUGGER_VOLUME_PATH/pydevd.py --multiprocess --port ${debugPorts.first()} --file" - - commandLine.withParameters("--debugger-path") - .withParameters(PythonHelper.DEBUGGER.pythonPathEntry) // Mount pydevd from PyCharm into docker - .withParameters("--debug-args") - .withParameters(debugArgs) - } - - override fun createDebugProcess( - environment: ExecutionEnvironment, - state: SamRunningState, - debugHost: String, - debugPorts: List - ): XDebugProcessStarter? = object : XDebugProcessStarter() { - override fun start(session: XDebugSession): XDebugProcess { - val mappings = state.builtLambda.mappings.map { - PathMapping( - it.localRoot, - "$TASK_PATH/${it.remoteRoot}" - ) - }.toMutableList() - - mappings.add( - PathMapping( - PythonHelper.DEBUGGER.pythonPathEntry, - DEBUGGER_VOLUME_PATH - ) - ) - - val executionResult = state.execute(environment.executor, environment.runner) - return PyDebugProcess( - session, - executionResult.executionConsole, - executionResult.processHandler, - debugHost, - debugPorts.first() - ).also { - it.positionConverter = PathMapper.PositionConverter(PathMapper(mappings)) - } - } - } - - private companion object { - const val TASK_PATH = "/var/task" - const val DEBUGGER_VOLUME_PATH = "/tmp/lambci_debug_files" - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonSamProjectWizard.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonSamProjectWizard.kt index 55ae5507e3..dcae4d94e3 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonSamProjectWizard.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/python/PythonSamProjectWizard.kt @@ -4,41 +4,34 @@ package software.aws.toolkits.jetbrains.services.lambda.python import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ModifiableRootModel +import com.intellij.openapi.ui.TextFieldWithBrowseButton import com.intellij.openapi.vfs.VirtualFile import com.intellij.util.PlatformUtils -import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup -import software.aws.toolkits.jetbrains.services.lambda.SamNewProjectSettings -import software.aws.toolkits.jetbrains.services.lambda.SamProjectTemplate -import software.aws.toolkits.jetbrains.services.lambda.SamProjectWizard -import software.aws.toolkits.jetbrains.services.lambda.TemplateParameters -import software.aws.toolkits.jetbrains.services.lambda.TemplateParameters.AppBasedTemplate -import software.aws.toolkits.jetbrains.services.lambda.TemplateParameters.LocationBasedTemplate -import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon -import software.aws.toolkits.jetbrains.services.lambda.sam.SamSchemaDownloadPostCreationAction -import software.aws.toolkits.jetbrains.services.schemas.SchemaCodeLangs -import software.aws.toolkits.jetbrains.ui.wizard.IntelliJSdkSelectionPanel -import software.aws.toolkits.jetbrains.ui.wizard.PyCharmSdkSelectionPanel -import software.aws.toolkits.jetbrains.ui.wizard.SamProjectGenerator -import software.aws.toolkits.jetbrains.ui.wizard.SchemaResourceSelectorSelectionPanel -import software.aws.toolkits.jetbrains.ui.wizard.SchemaSelectionPanel -import software.aws.toolkits.jetbrains.ui.wizard.SdkSelectionPanel +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.core.lambda.LambdaArchitecture +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.BuiltInRuntimeGroups +import software.aws.toolkits.jetbrains.services.lambda.wizard.IntelliJSdkSelectionPanel +import software.aws.toolkits.jetbrains.services.lambda.wizard.LocationBasedTemplate +import software.aws.toolkits.jetbrains.services.lambda.wizard.SamAppTemplateBased +import software.aws.toolkits.jetbrains.services.lambda.wizard.SamNewProjectSettings +import software.aws.toolkits.jetbrains.services.lambda.wizard.SamProjectTemplate +import software.aws.toolkits.jetbrains.services.lambda.wizard.SamProjectWizard +import software.aws.toolkits.jetbrains.services.lambda.wizard.SdkSelector +import software.aws.toolkits.jetbrains.services.lambda.wizard.TemplateParameters import software.aws.toolkits.resources.message -import java.nio.file.Paths -class PythonSamProjectWizard : SamProjectWizard { - override fun createSchemaSelectionPanel( - generator: SamProjectGenerator - ): SchemaSelectionPanel = - SchemaResourceSelectorSelectionPanel(generator.builder, RuntimeGroup.PYTHON, generator.defaultSourceCreatingProject) +private val pythonTemplateRuntimes = + setOf(LambdaRuntime.PYTHON3_7, LambdaRuntime.PYTHON3_8, LambdaRuntime.PYTHON3_9, LambdaRuntime.PYTHON3_10, LambdaRuntime.PYTHON3_11) +private val eventBridgeTemplateRuntimes = + setOf(LambdaRuntime.PYTHON3_7, LambdaRuntime.PYTHON3_8, LambdaRuntime.PYTHON3_9, LambdaRuntime.PYTHON3_10, LambdaRuntime.PYTHON3_11) - override fun createSdkSelectionPanel(generator: SamProjectGenerator): SdkSelectionPanel = - when { - PlatformUtils.isPyCharm() -> PyCharmSdkSelectionPanel(generator.step) - else -> IntelliJSdkSelectionPanel(generator.builder, RuntimeGroup.PYTHON) - } +class PythonSamProjectWizard : SamProjectWizard { + override fun createSdkSelectionPanel(projectLocation: TextFieldWithBrowseButton?): SdkSelector = when { + PlatformUtils.isIntelliJ() -> IntelliJSdkSelectionPanel(BuiltInRuntimeGroups.Python) + else -> PyCharmSdkSelectionPanel(projectLocation) + } override fun listTemplates(): Collection = listOf( SamHelloWorldPython(), @@ -48,81 +41,79 @@ class PythonSamProjectWizard : SamProjectWizard { ) } -abstract class PythonSamProjectTemplate : SamProjectTemplate() { - override fun supportedRuntimes() = setOf(Runtime.PYTHON2_7, Runtime.PYTHON3_6, Runtime.PYTHON3_7, Runtime.PYTHON3_8) +abstract class PythonSamProjectTemplate : SamAppTemplateBased() { + override fun supportedZipRuntimes() = pythonTemplateRuntimes + override fun supportedImageRuntimes() = pythonTemplateRuntimes + + override val dependencyManager: String = "pip" override fun postCreationAction( settings: SamNewProjectSettings, contentRoot: VirtualFile, rootModel: ModifiableRootModel, - sourceCreatingProject: Project, indicator: ProgressIndicator ) { - super.postCreationAction(settings, contentRoot, rootModel, sourceCreatingProject, indicator) - SamCommon.setSourceRoots(contentRoot, rootModel.project, rootModel) + super.postCreationAction(settings, contentRoot, rootModel, indicator) + addSourceRoots(rootModel.project, rootModel, contentRoot) } } class SamHelloWorldPython : PythonSamProjectTemplate() { - override fun getName() = message("sam.init.template.hello_world.name") + override fun displayName() = message("sam.init.template.hello_world.name") - override fun getDescription() = message("sam.init.template.hello_world.description") + override fun description() = message("sam.init.template.hello_world.description") - override fun templateParameters(): TemplateParameters = AppBasedTemplate("hello-world", "pip") + override val appTemplateName: String = "hello-world" } -class SamDynamoDBCookieCutter : PythonSamProjectTemplate() { - override fun getName() = message("sam.init.template.dynamodb_cookiecutter.name") +class SamDynamoDBCookieCutter : SamProjectTemplate() { + override fun displayName() = message("sam.init.template.dynamodb_cookiecutter.name") + + override fun description() = message("sam.init.template.dynamodb_cookiecutter.description") - override fun getDescription() = message("sam.init.template.dynamodb_cookiecutter.description") + override fun supportedZipRuntimes() = pythonTemplateRuntimes + override fun supportedImageRuntimes() = emptySet() - override fun templateParameters(): TemplateParameters = LocationBasedTemplate("gh:aws-samples/cookiecutter-aws-sam-dynamodb-python") + override fun postCreationAction( + settings: SamNewProjectSettings, + contentRoot: VirtualFile, + rootModel: ModifiableRootModel, + indicator: ProgressIndicator + ) { + super.postCreationAction(settings, contentRoot, rootModel, indicator) + addSourceRoots(rootModel.project, rootModel, contentRoot) + } + + override fun templateParameters( + projectName: String, + runtime: LambdaRuntime, + architecture: LambdaArchitecture, + packagingType: PackageType + ): TemplateParameters = LocationBasedTemplate( + "gh:aws-samples/cookiecutter-aws-sam-dynamodb-python" + ) } class SamEventBridgeHelloWorld : PythonSamProjectTemplate() { - override fun supportedRuntimes() = setOf(Runtime.PYTHON3_6, Runtime.PYTHON3_7, Runtime.PYTHON3_8) + override fun supportedZipRuntimes() = eventBridgeTemplateRuntimes + override fun supportedImageRuntimes() = emptySet() - override fun getName() = message("sam.init.template.eventBridge_helloWorld.name") + override fun displayName() = message("sam.init.template.event_bridge_hello_world.name") - override fun getDescription() = message("sam.init.template.eventBridge_helloWorld.description") + override fun description() = message("sam.init.template.event_bridge_hello_world.description") - override fun templateParameters(): TemplateParameters = AppBasedTemplate("eventBridge-hello-world", "pip") + override val appTemplateName: String = "eventBridge-hello-world" } class SamEventBridgeStarterApp : PythonSamProjectTemplate() { - override fun supportedRuntimes() = setOf(Runtime.PYTHON3_6, Runtime.PYTHON3_7, Runtime.PYTHON3_8) - - override fun getName() = message("sam.init.template.eventBridge_starterApp.name") + override fun supportedZipRuntimes() = eventBridgeTemplateRuntimes + override fun supportedImageRuntimes() = emptySet() - override fun getDescription() = message("sam.init.template.eventBridge_starterApp.description") + override fun displayName() = message("sam.init.template.event_bridge_starter_app.name") - override fun templateParameters(): TemplateParameters = AppBasedTemplate("eventBridge-schema-app", "pip") + override fun description() = message("sam.init.template.event_bridge_starter_app.description") - override fun functionName(): String = "hello_world_function" + override val appTemplateName: String = "eventBridge-schema-app" override fun supportsDynamicSchemas(): Boolean = true - - override fun postCreationAction( - settings: SamNewProjectSettings, - contentRoot: VirtualFile, - rootModel: ModifiableRootModel, - sourceCreatingProject: Project, - indicator: ProgressIndicator - ) { - settings.schemaParameters?.let { - val functionRoot = Paths.get(contentRoot.path, functionName()) - - SamSchemaDownloadPostCreationAction().downloadCodeIntoWorkspace( - it, - contentRoot, - functionRoot, - SchemaCodeLangs.PYTHON3_6, - sourceCreatingProject, - rootModel.project, - indicator - ) - } - - super.postCreationAction(settings, contentRoot, rootModel, sourceCreatingProject, indicator) - } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamCommon.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamCommon.kt index b2b5123fd9..3305ab55de 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamCommon.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamCommon.kt @@ -4,12 +4,12 @@ package software.aws.toolkits.jetbrains.services.lambda.sam import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.application.runReadAction import com.intellij.openapi.project.Project -import com.intellij.openapi.roots.ModifiableRootModel import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VfsUtil -import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.text.SemVer import software.aws.toolkits.jetbrains.core.executables.ExecutableManager import software.aws.toolkits.jetbrains.core.executables.getExecutableIfPresent import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationTemplate @@ -18,71 +18,47 @@ import software.aws.toolkits.resources.message import java.io.FileFilter import java.nio.file.Paths -class SamCommon { - companion object { - val mapper = jacksonObjectMapper() - const val SAM_BUILD_DIR = ".aws-sam" - const val SAM_INFO_VERSION_KEY = "version" - const val SAM_INVALID_OPTION_SUBSTRING = "no such option" - const val SAM_NAME = "SAM CLI" +object SamCommon { + val mapper = jacksonObjectMapper() + const val SAM_BUILD_DIR = ".aws-sam" + const val SAM_INFO_VERSION_KEY = "version" + const val SAM_INVALID_OPTION_SUBSTRING = "no such option" + const val SAM_NAME = "SAM CLI" - /** - * @return The string representation of the SAM version else "UNKNOWN" - */ - fun getVersionString(): String = ExecutableManager.getInstance().getExecutableIfPresent().version ?: "UNKNOWN" + // The minimum SAM CLI version required for images. TODO remove when sam min > 1.13.0 + val minImageVersion = SemVer("1.13.0", 1, 13, 0) - fun getTemplateFromDirectory(projectRoot: VirtualFile): VirtualFile? { - // Use Java File so we don't need to do a full VFS refresh - val projectRootFile = VfsUtil.virtualToIoFile(projectRoot) - val yamlFiles = projectRootFile.listFiles(FileFilter { - it.isFile && it.name.endsWith("yaml") || it.name.endsWith("yml") - })?.toList() ?: emptyList() - assert(yamlFiles.size == 1) { message("cloudformation.yaml.too_many_files", yamlFiles.size) } - return LocalFileSystem.getInstance().refreshAndFindFileByIoFile(yamlFiles.first()) - } + /** + * @return The string representation of the SAM version else "UNKNOWN" + */ + fun getVersionString(): String = ExecutableManager.getInstance().getExecutableIfPresent().version ?: "UNKNOWN" - fun getCodeUrisFromTemplate(project: Project, template: VirtualFile): List { - val cfTemplate = CloudFormationTemplate.parse(project, template) + fun getTemplateFromDirectory(projectRoot: VirtualFile): VirtualFile? { + // Use Java File so we don't need to do a full VFS refresh + val projectRootFile = VfsUtil.virtualToIoFile(projectRoot) + val yamlFiles = projectRootFile.listFiles( + FileFilter { + it.isFile && it.name.endsWith("yaml") || it.name.endsWith("yml") + } + )?.toList() ?: emptyList() + assert(yamlFiles.size == 1) { message("cloudformation.yaml.too_many_files", yamlFiles.size) } + return LocalFileSystem.getInstance().refreshAndFindFileByIoFile(yamlFiles.first()) + } - val codeUris = mutableListOf() - val templatePath = Paths.get(template.parent.path) - val localFileSystem = LocalFileSystem.getInstance() + fun getCodeUrisFromTemplate(project: Project, template: VirtualFile): List { + val templatePath = Paths.get(template.parent.path) - cfTemplate.resources().filter { it.isType(SERVERLESS_FUNCTION_TYPE) }.forEach { resource -> - val codeUriValue = resource.getScalarProperty("CodeUri") - val codeUriPath = templatePath.resolve(codeUriValue) - localFileSystem.refreshAndFindFileByIoFile(codeUriPath.toFile()) - ?.takeIf { it.isDirectory } - ?.let { codeUri -> - codeUris.add(codeUri) - } - } - return codeUris - } + val codeDirs = runReadAction { + val cfTemplate = CloudFormationTemplate.parse(project, template) - fun setSourceRoots(projectRoot: VirtualFile, project: Project, modifiableModel: ModifiableRootModel) { - val template = getTemplateFromDirectory(projectRoot) ?: return - val codeUris = getCodeUrisFromTemplate(project, template) - modifiableModel.contentEntries.forEach { contentEntry -> - if (contentEntry.file == projectRoot) { - codeUris.forEach { contentEntry.addSourceFolder(it, false) } - } - } + cfTemplate.resources() + .filter { it.isType(SERVERLESS_FUNCTION_TYPE) } + .map { templatePath.resolve(it.getScalarProperty("CodeUri")) } + .toList() } - fun excludeSamDirectory(projectRoot: VirtualFile, modifiableModel: ModifiableRootModel) { - modifiableModel.contentEntries.forEach { contentEntry -> - if (contentEntry.file == projectRoot) { - contentEntry.addExcludeFolder( - VfsUtilCore.pathToUrl( - Paths.get( - projectRoot.path, - SAM_BUILD_DIR - ).toString() - ) - ) - } - } - } + val localFileSystem = LocalFileSystem.getInstance() + return codeDirs.mapNotNull { localFileSystem.refreshAndFindFileByIoFile(it.toFile()) } + .filter { it.isDirectory } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamCommonUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamCommonUtils.kt new file mode 100644 index 0000000000..3aec8c2f9d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamCommonUtils.kt @@ -0,0 +1,204 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.sam + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.runInEdtAndGet +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import org.jetbrains.yaml.YAMLFileType +import software.amazon.awssdk.services.cloudformation.model.StackSummary +import software.aws.toolkits.jetbrains.ToolkitPlaces +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.services.cloudformation.Parameter +import software.aws.toolkits.jetbrains.services.cloudformation.validateSamTemplateHasResources +import software.aws.toolkits.jetbrains.ui.KeyValueTextField +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.ui.find +import software.aws.toolkits.resources.message +import java.util.regex.PatternSyntaxException + +object SamTemplateFileUtils { + val templateYamlRegex = Regex("template\\.y[a]?ml", RegexOption.IGNORE_CASE) + + /** + * Determines the relevant Sam Template, returns null if one can't be found. + */ + fun getSamTemplateFile(e: AnActionEvent): VirtualFile? = runReadAction { + val virtualFiles = e.getData(PlatformDataKeys.VIRTUAL_FILE_ARRAY) ?: return@runReadAction null + val virtualFile = virtualFiles.singleOrNull() ?: return@runReadAction null + + if (templateYamlRegex.matches(virtualFile.name)) { + return@runReadAction virtualFile + } + + // If the module node was selected, see if there is a template file in the top level folder + val module = e.getData(LangDataKeys.MODULE_CONTEXT) + if (module != null) { + // It is only acceptable if one template file is found + val childTemplateFiles = ModuleRootManager.getInstance(module).contentRoots.flatMap { root -> + root.children.filter { child -> templateYamlRegex.matches(child.name) } + } + + if (childTemplateFiles.size == 1) { + return@runReadAction childTemplateFiles.single() + } + } + + return@runReadAction null + } + + fun validateTemplateFile(project: Project, templateFile: VirtualFile): String? = + try { + runReadAction { + project.validateSamTemplateHasResources(templateFile) + } + } catch (e: Exception) { + message("serverless.application.deploy.error.bad_parse", templateFile.path, e) + } + + fun retrieveSamTemplate(e: AnActionEvent, project: Project): VirtualFile? { + if (e.place == ToolkitPlaces.EXPLORER_TOOL_WINDOW) { + return runInEdtAndGet { + FileChooser.chooseFile( + FileChooserDescriptorFactory.createSingleFileDescriptor(YAMLFileType.YML), + project, + project.guessProjectDir() + ) + } ?: return null + } else { + val file = getSamTemplateFile(e) + if (file == null) { + Exception(message("serverless.application.deploy.toast.template_file_failure")) + .notifyError(message("aws.notification.title"), project) + return null + } + return file + } + } +} + +fun getSamCli(): GeneralCommandLine { + val executable = runBlocking { + ExecutableManager.getInstance().getExecutable().await() + } + + val samExecutable = when (executable) { + is ExecutableInstance.Executable -> executable + else -> { + throw RuntimeException((executable as? ExecutableInstance.BadExecutable)?.validationError.orEmpty()) + } + } + + return samExecutable.getCommandLine() +} + +object ValidateSamParameters { + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-using-console-create-stack-parameters.html + // A stack name can contain only alphanumeric characters (case-sensitive) and hyphens. It must start with an alphabetic character and can't be longer than 128 characters. + private val STACK_NAME_PATTERN = "[a-zA-Z][a-zA-Z0-9-]*".toRegex() + const val MAX_STACK_NAME_LENGTH = 128 + fun validateStackName(name: String?, availableStacks: ResourceSelector): String? { + if (name.isNullOrEmpty()) { + return message("serverless.application.deploy.validation.new.stack.name.missing") + } + if (!STACK_NAME_PATTERN.matches(name)) { + return message("serverless.application.deploy.validation.new.stack.name.invalid") + } + if (name.length > MAX_STACK_NAME_LENGTH) { + return message("serverless.application.deploy.validation.new.stack.name.too.long", MAX_STACK_NAME_LENGTH) + } + // Check if the new stack name is same as an existing stack name + availableStacks.model.find { it.stackName() == name }?.let { + return message("serverless.application.deploy.validation.new.stack.name.duplicate") + } + return null + } + + fun validateParameters(parametersComponent: KeyValueTextField, templateFileParameters: List): ValidationInfo? { + // validate on ui element because value hasn't been committed yet + val parameters = parametersComponent.envVars + val parameterDeclarations = templateFileParameters.associateBy { it.logicalName } + + val invalidParameters = parameters.entries.mapNotNull { (name, value) -> + val cfnParameterDeclaration = parameterDeclarations[name] ?: return ValidationInfo("parameter declared but not in template") + when (cfnParameterDeclaration.getOptionalScalarProperty("Type")) { + "String" -> validateStringParameter(name, value, cfnParameterDeclaration) + "Number" -> validateNumberParameter(name, value, cfnParameterDeclaration) + // not implemented: List, CommaDelimitedList, AWS-specific parameters, SSM parameters + else -> null + } + } + + return invalidParameters.firstOrNull() + } + + private fun validateStringParameter(name: String, providedValue: String?, parameterDeclaration: Parameter): ValidationInfo? { + val value = providedValue.orEmpty() + val minValue = parameterDeclaration.getOptionalScalarProperty("MinLength") + val maxValue = parameterDeclaration.getOptionalScalarProperty("MaxLength") + val allowedPattern = parameterDeclaration.getOptionalScalarProperty("AllowedPattern") + + minValue?.toIntOrNull()?.let { + if (value.length < it) { + return ValidationInfo(message("serverless.application.deploy.validation.template.values.tooShort", name, minValue)) + } + } + + maxValue?.toIntOrNull()?.let { + if (value.length > it) { + return ValidationInfo(message("serverless.application.deploy.validation.template.values.tooLong", name, maxValue)) + } + } + + allowedPattern?.let { + try { + val regex = it.toRegex() + if (!regex.matches(value)) { + return ValidationInfo(message("serverless.application.deploy.validation.template.values.failsRegex", name, regex)) + } + } catch (e: PatternSyntaxException) { + return ValidationInfo(message("serverless.application.deploy.validation.template.values.badRegex", name, e.message ?: it)) + } + } + + return null + } + + private fun validateNumberParameter(name: String, value: String?, parameterDeclaration: Parameter): ValidationInfo? { + // cfn numbers can be integer or float. assume real implementation refers to java floats + val number = value?.toFloatOrNull() + ?: return ValidationInfo(message("serverless.application.deploy.validation.template.values.notANumber", name, value.orEmpty())) + val minValue = parameterDeclaration.getOptionalScalarProperty("MinValue") + val maxValue = parameterDeclaration.getOptionalScalarProperty("MaxValue") + + minValue?.toFloatOrNull()?.let { + if (number < it) { + return ValidationInfo(message("serverless.application.deploy.validation.template.values.tooSmall", name, minValue)) + } + } + + maxValue?.toFloatOrNull()?.let { + if (number > it) { + return ValidationInfo(message("serverless.application.deploy.validation.template.values.tooBig", name, maxValue)) + } + } + + return null + } +} 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 56f315ef01..2d7d25c440 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 @@ -3,25 +3,47 @@ package software.aws.toolkits.jetbrains.services.lambda.sam +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.openapi.util.SystemInfo import com.intellij.util.text.SemVer +import software.aws.toolkits.core.lambda.LambdaArchitecture import software.aws.toolkits.jetbrains.core.executables.AutoResolvable import software.aws.toolkits.jetbrains.core.executables.ExecutableCommon import software.aws.toolkits.jetbrains.core.executables.ExecutableType import software.aws.toolkits.jetbrains.core.executables.Validatable +import software.aws.toolkits.jetbrains.services.lambda.deploy.DeployServerlessApplicationSettings +import software.aws.toolkits.jetbrains.services.lambda.sam.sync.SyncServerlessApplicationSettings +import software.aws.toolkits.jetbrains.services.lambda.wizard.AppBasedImageTemplate +import software.aws.toolkits.jetbrains.services.lambda.wizard.AppBasedZipTemplate +import software.aws.toolkits.jetbrains.services.lambda.wizard.LocationBasedTemplate +import software.aws.toolkits.jetbrains.services.lambda.wizard.TemplateParameters import software.aws.toolkits.jetbrains.settings.ExecutableDetector import java.nio.file.Path import java.nio.file.Paths class SamExecutable : ExecutableType, AutoResolvable, Validatable { + companion object { + // inclusive + val minVersion = SemVer("1.0.0", 1, 0, 0) + + // exclusive + val maxVersion = SemVer("2.0.0", 2, 0, 0) + + /** + * The text SAM cli prints after the user code finishes running and + * the debugger is disconnected. + */ + const val endDebuggingText = "END RequestId: " + + // Reliable start message printed when delve starts + // Comes from: https://github.com/go-delve/delve/blob/f5d2e132bca763d222680815ace98601c2396517/service/debugger/debugger.go#L187 + const val goStartMessage = "launching process with args" + } + override val displayName: String = "sam" override val id: String = "samCli" - // inclusive - val samMinVersion = SemVer("0.47.0", 0, 47, 0) - // exclusive - val samMaxVersion = SemVer("2.0.0", 2, 0, 0) - override fun version(path: Path): SemVer = ExecutableCommon.getVersion( path.toString(), SamVersionCache, @@ -32,25 +54,256 @@ class SamExecutable : ExecutableType, AutoResolvable, Validatable { val version = this.version(path) ExecutableCommon.checkSemVerVersion( version, - samMinVersion, - samMaxVersion, + minVersion, + maxVersion, this.displayName ) } override fun resolve(): Path? { - val path = (if (SystemInfo.isWindows) { - ExecutableDetector().find( - arrayOf("C:\\Program Files\\Amazon\\AWSSAMCLI\\bin", "C:\\Program Files (x86)\\Amazon\\AWSSAMCLI\\bin"), - arrayOf("sam.cmd", "sam.exe") + val path = ( + if (SystemInfo.isWindows) { + ExecutableDetector().find( + arrayOf("C:\\Program Files\\Amazon\\AWSSAMCLI\\bin", "C:\\Program Files (x86)\\Amazon\\AWSSAMCLI\\bin"), + arrayOf("sam.cmd", "sam.exe") + ) + } else { + ExecutableDetector().find( + arrayOf("/usr/local/bin", "/usr/bin"), + arrayOf("sam") + ) + } + ) ?: return null + + return Paths.get(path) + } +} + +fun GeneralCommandLine.samBuildCommand( + templatePath: Path, + logicalId: String? = null, + buildDir: Path, + environmentVariables: Map, + samOptions: SamOptions +) = this.apply { + withEnvironment(environmentVariables) + withWorkDirectory(templatePath.toAbsolutePath().parent.toString()) + + addParameter("build") + + // Add logical id if known to perform min build + logicalId?.let { + withParameters(logicalId) + } + + addParameter("--template") + addParameter(templatePath.toString()) + addParameter("--build-dir") + addParameter(buildDir.toString()) + if (samOptions.buildInContainer) { + withParameters("--use-container") + } + + if (samOptions.skipImagePull) { + withParameters("--skip-pull-image") + } + + samOptions.dockerNetwork?.let { network -> + val sanitizedNetwork = network.trim() + if (sanitizedNetwork.isNotBlank()) { + withParameters("--docker-network").withParameters(sanitizedNetwork) + } + } + + samOptions.additionalBuildArgs?.let { buildArgs -> + if (buildArgs.isNotBlank()) { + withParameters(*buildArgs.split(" ").toTypedArray()) + } + } +} + +fun GeneralCommandLine.samPackageCommand( + environmentVariables: Map, + templatePath: Path, + packagedTemplatePath: Path, + s3Bucket: String?, + ecrRepo: String? +) = this.apply { + withEnvironment(environmentVariables) + withWorkDirectory(templatePath.parent.toAbsolutePath().toString()) + + addParameter("package") + addParameter("--template-file") + addParameter(templatePath.toString()) + addParameter("--output-template-file") + addParameter(packagedTemplatePath.toString()) + s3Bucket?.let { + addParameter("--s3-bucket") + addParameter(s3Bucket) + } + ecrRepo?.let { + addParameter("--image-repository") + addParameter(ecrRepo) + } +} + +fun GeneralCommandLine.samDeployCommand( + environmentVariables: Map, + templatePath: Path, + settings: DeployServerlessApplicationSettings +) = this.apply { + withEnvironment(environmentVariables) + withWorkDirectory(templatePath.parent.toAbsolutePath().toString()) + + addParameter("deploy") + addParameter("--template-file") + addParameter(templatePath.toString()) + addParameter("--stack-name") + addParameter(settings.stackName) + addParameter("--s3-bucket") + addParameter(settings.bucket) + settings.ecrRepo?.let { + addParameter("--image-repository") + addParameter(it) + } + + if (settings.capabilities.isNotEmpty()) { + addParameter("--capabilities") + addParameters(settings.capabilities.map { it.capability }) + } + + addParameter("--no-execute-changeset") + + if (settings.parameters.isNotEmpty()) { + addParameter("--parameter-overrides") + // Even though keys must be alphanumeric, escape it so that it is "valid" enough so that CFN can return a validation error instead of us failing + settings.parameters.forEach { (key, value) -> + addParameter( + "${escapeParameter(key)}=${escapeParameter(value)}" ) - } else { - ExecutableDetector().find( - arrayOf("/usr/local/bin", "/usr/bin"), - arrayOf("sam") + } + } + + if (settings.tags.isNotEmpty()) { + addParameter("--tags") + // Even though keys must be alphanumeric, escape it so that it is "valid" enough so that CFN can return a validation error instead of us failing + settings.tags.forEach { (key, value) -> + addParameter( + "${escapeParameter(key)}=${escapeParameter(value)}" ) - }) ?: return null + } + } +} - return Paths.get(path) +private fun escapeParameter(param: String): String { + // Invert the quote if the string is already quoted + val quote = if (param.startsWith("\"") || param.endsWith("\"")) { + "'" + } else { + "\"" + } + + return quote + param + quote +} + +fun GeneralCommandLine.samInitCommand( + outputDir: Path, + parameters: TemplateParameters, + extraContext: Map +) = this.apply { + addParameter("init") + addParameter("--no-input") + addParameter("--output-dir") + addParameter(outputDir.toAbsolutePath().toString()) + + when (parameters) { + is AppBasedZipTemplate -> { + addParameter("--name") + addParameter(parameters.name) + addParameter("--runtime") + addParameter(parameters.runtime.toString()) + if (parameters.architecture != LambdaArchitecture.DEFAULT) { + addParameter("--architecture") + addParameter(parameters.architecture.toString()) + } + addParameter("--dependency-manager") + addParameter(parameters.dependencyManager) + addParameter("--app-template") + addParameter(parameters.appTemplate) + } + is AppBasedImageTemplate -> { + addParameter("--package-type") + addParameter("Image") + addParameter("--name") + addParameter(parameters.name) + addParameter("--base-image") + addParameter(parameters.baseImage) + if (parameters.architecture != LambdaArchitecture.DEFAULT) { + addParameter("--architecture") + addParameter(parameters.architecture.toString()) + } + addParameter("--dependency-manager") + addParameter(parameters.dependencyManager) + addParameter("--app-template") + addParameter(parameters.appTemplate) + } + is LocationBasedTemplate -> { + addParameter("--location") + addParameter(parameters.location) + } } + + if (extraContext.isNotEmpty()) { + val extraContextAsJson = jacksonObjectMapper().writeValueAsString(extraContext) + + addParameter("--extra-context") + addParameter(extraContextAsJson) + } +} + +fun GeneralCommandLine.samSyncCommand( + environmentVariables: Map, + templatePath: Path, + settings: SyncServerlessApplicationSettings +) = this.apply { + withEnvironment(environmentVariables) + withWorkDirectory(templatePath.toAbsolutePath().parent.toString()) + addParameter("sync") + addParameter("--stack-name") + addParameter(settings.stackName) + addParameter("--template-file") + addParameter(templatePath.toString()) + addParameter("--s3-bucket") + addParameter(settings.bucket) + settings.ecrRepo?.let { + addParameter("--image-repository") + addParameter(it) + } + if (settings.capabilities.isNotEmpty()) { + addParameter("--capabilities") + addParameters(settings.capabilities.map { it.capability }) + } + if (settings.parameters.isNotEmpty()) { + addParameter("--parameter-overrides") + settings.parameters.forEach { (key, value) -> + addParameter( + "${escapeParameter(key)}=${escapeParameter(value)}" + ) + } + } + + if (settings.tags.isNotEmpty()) { + addParameter("--tags") + settings.tags.forEach { (key, value) -> + addParameter( + "${escapeParameter(key)}=${escapeParameter(value)}" + ) + } + } + if (settings.useContainer) { + addParameter("--use-container") + } + addParameter("--no-dependency-layer") + + addParameter("--no-watch") } 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 2c01aeacf8..216036d3f2 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 @@ -3,115 +3,31 @@ package software.aws.toolkits.jetbrains.services.lambda.sam import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.project.Project -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.AwsConnectionManager +import software.aws.toolkits.core.ConnectionSettings 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 import software.aws.toolkits.jetbrains.services.schemas.code.SchemaCodeDownloader -import software.aws.toolkits.jetbrains.utils.notifyError -import software.aws.toolkits.resources.message -import java.io.File import java.nio.file.Path -private val NOTIFICATION_TITLE = message("schemas.service_name") - class SamSchemaDownloadPostCreationAction { fun downloadCodeIntoWorkspace( schemaTemplateParameters: SchemaTemplateParameters, - contentRoot: VirtualFile, schemaSourceRoot: Path, language: SchemaCodeLangs, - sourceCreatingProject: Project, - newApplicationProject: Project, + connectionSettings: ConnectionSettings, indicator: ProgressIndicator ) { - // Use sourceCreatingProject instead of rootModel.project because the new project may not have AWS credentials configured yet - val codeGenDownloader = SchemaCodeDownloader.create(AwsClientManager.getInstance(sourceCreatingProject)) + val codeGenDownloader = SchemaCodeDownloader.create(connectionSettings) codeGenDownloader.downloadCode( SchemaCodeDownloadRequestDetails( - schemaTemplateParameters.schema, schemaTemplateParameters.schemaVersion, language, schemaSourceRoot.toString() + schemaTemplateParameters.schema, + schemaTemplateParameters.schemaVersion, + language, + schemaSourceRoot.toFile() ), indicator ).toCompletableFuture().get() - - VfsUtil.markDirtyAndRefresh(false, true, true, contentRoot) - - initializeNewProjectCredentialsFromSourceCreatingProject(newApplicationProject, sourceCreatingProject) - - validateDownloadedCodeAgainstSchema(schemaTemplateParameters, contentRoot, language, newApplicationProject) - } - - private fun initializeNewProjectCredentialsFromSourceCreatingProject(newApplicationProject: Project, sourceCreatingProject: Project) { - val newApplicationProjectSettings = AwsConnectionManager.getInstance(newApplicationProject) - if (newApplicationProjectSettings.isValidConnectionSettings()) { - return - } - - val sourceCreatingProjectSettings = AwsConnectionManager.getInstance(sourceCreatingProject) - if (!sourceCreatingProjectSettings.isValidConnectionSettings()) { - return - } - - sourceCreatingProjectSettings.selectedCredentialIdentifier?.let { newApplicationProjectSettings.changeCredentialProvider(it) } - newApplicationProjectSettings.changeRegion(sourceCreatingProjectSettings.activeRegion) - } - - // SchemaTemplateParameters were provided to the SAM template intended to match the downloaded code - // But because as of the Schemas 2019 launch these were not provided by the server, there is a risk that the client has a bug, - // or the server changes and diverges. So just to be sure, let's validate the primary downloaded code file, and if something went wrong warn the user - private fun validateDownloadedCodeAgainstSchema( - schemaTemplateParameters: SchemaTemplateParameters, - contentRoot: VirtualFile, - language: SchemaCodeLangs, - newApplicationProject: Project - ) { - - val schemaRootEventName = schemaTemplateParameters.templateExtraContext.schemaRootEventName - val schemaRootEventFileName = "$schemaRootEventName.${language.extension}" - val schemaPackageHierarchy = schemaTemplateParameters.templateExtraContext.schemaPackageHierarchy - - val contentRootFile = VfsUtil.virtualToIoFile(contentRoot) - val schemaRootEventFile = FileUtil.fileTraverser(contentRootFile).bfsTraversal().firstOrNull { it.name == schemaRootEventFileName } - - if (schemaRootEventFile == null) { - // File not found - notifyOnValidationFailure( - schemaRootEventName, - schemaPackageHierarchy, - message("sam.init.schema.validation_failed.file_not_found"), - newApplicationProject - ) - return - } - - val filePathMatchesPackage = schemaRootEventFile.parentFile.toPath().endsWith(schemaPackageHierarchy.replace(".", File.separator)) - if (!filePathMatchesPackage) { - notifyOnValidationFailure( - schemaRootEventName, - schemaPackageHierarchy, - message("sam.init.schema.validation_failed.package_not_found", schemaRootEventFile.parent), - newApplicationProject - ) - return - } - } - - private fun notifyOnValidationFailure( - schemaRootEventName: String, - schemaPackageHierarchy: String, - specificValidationError: String, - newApplicationProject: Project - ) { - notifyError( - title = NOTIFICATION_TITLE, - content = message("sam.init.schema.validation_failed", schemaRootEventName, schemaPackageHierarchy, specificValidationError), - project = newApplicationProject - ) } } 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 e8ce31a3cd..69d101013c 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 @@ -2,37 +2,143 @@ // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.jetbrains.services.lambda.sam +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.convertValue import com.intellij.openapi.application.ReadAction 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.Architecture +import software.amazon.awssdk.services.lambda.model.PackageType 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.inputStream 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 software.aws.toolkits.jetbrains.services.lambda.LambdaLimits +import software.aws.toolkits.jetbrains.services.lambda.steps.UploadedCode +import software.aws.toolkits.jetbrains.services.lambda.steps.UploadedEcrCode +import software.aws.toolkits.jetbrains.services.lambda.steps.UploadedS3Code +import software.aws.toolkits.jetbrains.utils.YamlWriter +import software.aws.toolkits.jetbrains.utils.yaml +import software.aws.toolkits.resources.message import java.io.File +import java.nio.file.Files import java.nio.file.Path object SamTemplateUtils { private val LOG = getLogger() + private val MAPPER = ObjectMapper(YAMLFactory()) + private const val S3_URI_PREFIX = "s3://" - @JvmStatic - fun findFunctionsFromTemplate(project: Project, file: File): List { - if (!file.isFile) { - return emptyList() + fun getFunctionEnvironmentVariables(template: Path, logicalId: String): Map = readTemplate(template) { + val function = requiredAt("/Resources").get(logicalId) + ?: throw IllegalArgumentException("No resource with the logical ID $logicalId") + val globals = at("/Globals/Function/Environment/Variables") + val variables = function.at("/Properties/Environment/Variables") + val globalVars = runCatching { MAPPER.convertValue?>(globals) ?: emptyMap() }.getOrDefault(emptyMap()) + val vars = runCatching { MAPPER.convertValue?>(variables) ?: emptyMap() }.getOrDefault(emptyMap()) + // function vars overwrite global ones if they overlap, so this works as expected + globalVars + vars + } + + fun getUploadedCodeUri(template: Path, logicalId: String): UploadedCode = readTemplate(template) { + val function = findFunction(logicalId) + if (function.isImageBased()) { + UploadedEcrCode(function.requiredAt("/Properties/ImageUri").textValue()) + } else { + val codeUri = function.requiredAt("/Properties/CodeUri") + + // CodeUri: s3://>/ + // or + // CodeUri: + // Bucket: mybucket-name + // Key: code.zip + // Version: 121212 + + when { + codeUri.isTextual -> convertCodeUriString(codeUri.textValue()) + codeUri.isObject -> convertCodeUriObject(codeUri) + else -> throw IllegalStateException("Unable to parse codeUri $codeUri") + } + } + } + + private fun convertCodeUriString(codeUri: String): UploadedS3Code { + if (!codeUri.startsWith(S3_URI_PREFIX)) { + throw IllegalStateException("$codeUri does not start with $S3_URI_PREFIX") } - // Use in-memory file since we can't refresh since we are most likely in a read action - val templateContent = file.readText() - val virtualFile = LightVirtualFile(file.name, templateContent) + val s3bucketKey = codeUri.removePrefix(S3_URI_PREFIX) + val split = s3bucketKey.split("/", limit = 2) + if (split.size != 2) { + throw IllegalStateException("$codeUri does not follow the format $S3_URI_PREFIX/") + } + return UploadedS3Code( + bucket = split.first(), + key = split.last(), + version = null + ) + } + + private fun convertCodeUriObject(codeUri: JsonNode): UploadedS3Code = UploadedS3Code( + bucket = codeUri.required("Bucket").textValue(), + key = codeUri.required("Key").textValue(), + version = codeUri.get("Version").textValue() + ) + + /** + * Returns the location of the Lambda source code as per SAM build requirements + */ + fun getCodeLocation(template: Path, logicalId: String): String = readTemplate(template) { + val function = findFunction(logicalId) + if (function.isServerlessFunction()) { + if (function.isImageBased()) { + function.getPathOrThrow(logicalId, "/Metadata/DockerContext").textValue() + } else { + function.getPathOrThrow(logicalId, "/Properties/CodeUri").textValue() + } + } else { + function.getPathOrThrow(logicalId, "/Properties/Code").textValue() + } + } + + private fun JsonNode.getPathOrThrow(logicalId: String, path: String): JsonNode { + val node = at(path) + if (node.isMissingNode) { + throw RuntimeException(message("cloudformation.key_not_found", path, logicalId)) + } + return node + } + + private fun JsonNode.findFunction(logicalId: String): JsonNode = this.requiredAt("/Resources").get(logicalId) + ?: throw IllegalArgumentException("No resource with the logical ID $logicalId") + + private fun JsonNode.isImageBased(): Boolean = this.packageType() == PackageType.IMAGE + + private fun JsonNode.packageType(): PackageType { + val type = this.at("/Properties/PackageType")?.textValue() ?: return PackageType.ZIP + return PackageType.knownValues().firstOrNull { it.toString() == type } + ?: throw IllegalStateException(message("cloudformation.invalid_property", "PackageType", type)) + } + + private fun JsonNode.isServerlessFunction(): Boolean = this.get("Type")?.textValue() == SERVERLESS_FUNCTION_TYPE + + private fun readTemplate(template: Path, function: JsonNode.() -> T): T = template.inputStream().use { + function(MAPPER.readTree(it)) + } + + @JvmStatic + fun findFunctionsFromTemplate(project: Project, file: File): List { + val virtualFile = file.readFileIntoMemory() ?: return emptyList() return findFunctionsFromTemplate(project, virtualFile) } @@ -48,6 +154,19 @@ object SamTemplateUtils { emptyList() } + fun findImageFunctionsFromTemplate(project: Project, file: VirtualFile): List = + findFunctionsFromTemplate(project, file).filter { it.packageType() == PackageType.IMAGE } + + @JvmStatic + fun findZipFunctionsFromTemplate(project: Project, file: File): List { + val virtualFile = file.readFileIntoMemory() ?: return emptyList() + return findZipFunctionsFromTemplate(project, virtualFile) + } + + @JvmStatic + fun findZipFunctionsFromTemplate(project: Project, file: VirtualFile): List = + findFunctionsFromTemplate(project, file).filter { it.packageType() == PackageType.ZIP } + @JvmStatic fun functionFromElement(element: PsiElement): Function? = CloudFormationTemplate.convertPsiToResource(element) as? Function @@ -55,38 +174,107 @@ object SamTemplateUtils { tempFile: Path, logicalId: String, runtime: Runtime, + architecture: Architecture? = Architecture.X86_64, codeUri: String, handler: String, - timeout: Int, - memorySize: Int, + timeout: Int = LambdaLimits.DEFAULT_TIMEOUT, + memorySize: Int = LambdaLimits.DEFAULT_MEMORY_SIZE, envVars: Map = emptyMap() + ) { + templateCommon( + tempFile = tempFile, + logicalId = logicalId, + timeout = timeout, + memorySize = memorySize, + envVars = envVars, + properties = { + keyValue("Handler", handler) + keyValue("CodeUri", codeUri) + keyValue("Runtime", runtime.toString()) + mapping("Architectures") { + listValue(architecture.toString()) + } + } + ) + } + + fun writeDummySamImageTemplate( + tempFile: Path, + logicalId: String, + dockerfile: Path, + timeout: Int = LambdaLimits.DEFAULT_TIMEOUT, + memorySize: Int = LambdaLimits.DEFAULT_MEMORY_SIZE, + envVars: Map = emptyMap() + ) { + templateCommon( + tempFile = tempFile, + logicalId = logicalId, + timeout = timeout, + memorySize = memorySize, + envVars = envVars, + properties = { + keyValue("PackageType", "Image") + }, + metadata = { + keyValue("DockerContext", dockerfile.parent.toString()) + keyValue("Dockerfile", dockerfile.fileName.toString()) + } + ) + } + + private fun templateCommon( + tempFile: Path, + logicalId: String, + timeout: Int = LambdaLimits.DEFAULT_TIMEOUT, + memorySize: Int = LambdaLimits.DEFAULT_MEMORY_SIZE, + envVars: Map = emptyMap(), + properties: YamlWriter.() -> Unit, + metadata: (YamlWriter.() -> Unit)? = null ) { if (!tempFile.exists()) { - tempFile.createFile() + Files.createDirectories(tempFile.parent) + Files.createFile(tempFile) } - tempFile.writeText(yamlWriter { - mapping("Resources") { - mapping(logicalId) { - keyValue("Type", SERVERLESS_FUNCTION_TYPE) - mapping("Properties") { - keyValue("Handler", handler) - keyValue("CodeUri", codeUri) - keyValue("Runtime", runtime.toString()) - keyValue("Timeout", timeout.toString()) - keyValue("MemorySize", memorySize.toString()) - - if (envVars.isNotEmpty()) { - mapping("Environment") { - mapping("Variables") { - envVars.forEach { (key, value) -> - keyValue(key, value) + tempFile.writeText( + yaml { + mapping("Resources") { + mapping(logicalId) { + keyValue("Type", SERVERLESS_FUNCTION_TYPE) + mapping("Properties") { + keyValue("Timeout", timeout.toString()) + keyValue("MemorySize", memorySize.toString()) + + properties(this) + + if (envVars.isNotEmpty()) { + mapping("Environment") { + mapping("Variables") { + envVars.forEach { (key, value) -> + keyValue(key, value) + } } } } } + + metadata?.let { + mapping("Metadata") { + metadata.invoke(this) + } + } } } } - }) + ) + } + + private fun File.readFileIntoMemory(): VirtualFile? { + if (!isFile) { + return null + } + + // Use in-memory file since we can't refresh since we are most likely in a read action + val templateContent = readText() + return LightVirtualFile(name, templateContent) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamVersionCache.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamVersionCache.kt index ebac3d7cac..daafa525a0 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamVersionCache.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/SamVersionCache.kt @@ -7,35 +7,41 @@ import com.intellij.execution.process.CapturingProcessHandler import com.intellij.util.text.SemVer import com.intellij.util.text.nullize import software.aws.toolkits.jetbrains.core.executables.ExecutableCommon +import software.aws.toolkits.jetbrains.core.executables.ExecutableType import software.aws.toolkits.jetbrains.utils.FileInfoCache import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SamTelemetry object SamVersionCache : FileInfoCache() { override fun getFileInfo(path: String): SemVer { val executableName = "sam" val sanitizedPath = path.nullize(true) ?: throw RuntimeException(message("executableCommon.cli_not_configured", executableName)) - val commandLine = ExecutableCommon.getCommandLine(sanitizedPath, executableName).withParameters("--info") + val commandLine = ExecutableCommon.getCommandLine(sanitizedPath, executableName, ExecutableType.getInstance()).withParameters("--info") val process = CapturingProcessHandler(commandLine).runProcess() if (process.exitCode != 0) { val output = process.stderr.trimEnd() if (output.contains(SamCommon.SAM_INVALID_OPTION_SUBSTRING)) { + SamTelemetry.info(result = Result.Failed, reason = "SamCliUnexpectedOutput") throw IllegalStateException(message("executableCommon.unexpected_output", SamCommon.SAM_NAME, output)) } throw IllegalStateException(output) } else { val output = process.stdout.trimEnd() if (output.isEmpty()) { + SamTelemetry.info(result = Result.Failed, reason = "SamCliNoOutput") throw IllegalStateException(message("executableCommon.empty_info", SamCommon.SAM_NAME)) } val tree = SamCommon.mapper.readTree(output) val version = tree.get(SamCommon.SAM_INFO_VERSION_KEY).asText() - return SemVer.parseFromText(version) ?: throw IllegalStateException(message("executableCommon.version_parse_error", SamCommon.SAM_NAME, version)) + val semVerVersion = SemVer.parseFromText(version) + if (semVerVersion == null) { + SamTelemetry.info(result = Result.Failed, reason = "UndetectableSamCliVersion") + } else { + SamTelemetry.info(result = Result.Succeeded) + } + return semVerVersion ?: throw IllegalStateException(message("executableCommon.version_parse_error", SamCommon.SAM_NAME, version)) } } - - // This is the timeout to evaluate SAM version. On slow computers, or computers that have security scanners, - // this can take longer than the default 500ms timeout of FileInfoCache. Since this is run in the background, - // we can afford to bump it much higher. - const val DEFAULT_TIMEOUT_MS = 5000 } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/sync/SyncApplicationRunProfile.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/sync/SyncApplicationRunProfile.kt new file mode 100644 index 0000000000..7a1538427d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/sync/SyncApplicationRunProfile.kt @@ -0,0 +1,98 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.sam.sync + +import com.intellij.execution.Executor +import com.intellij.execution.configurations.CommandLineState +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.RunProfile +import com.intellij.execution.configurations.RunProfileState +import com.intellij.execution.process.KillableColoredProcessHandler +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.execution.process.ProcessTerminatedListener +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.runners.ProgramRunner +import com.intellij.execution.ui.RunContentManager +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import icons.AwsIcons +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.toEnvironmentVariables +import software.aws.toolkits.jetbrains.services.lambda.sam.getSamCli +import software.aws.toolkits.jetbrains.services.lambda.sam.samSyncCommand +import java.io.IOException +import java.nio.charset.Charset +import java.nio.file.Path +import javax.swing.Icon + +class SyncApplicationRunProfile( + private val project: Project, + private val settings: SyncServerlessApplicationSettings, + private val connection: ConnectionSettings, + private val templatePath: Path +) : RunProfile { + override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState = SyncApplicationRunProfileState(environment) + + override fun getName(): String = settings.stackName + + override fun getIcon(): Icon? = AwsIcons.Resources.SERVERLESS_APP + + inner class SyncApplicationRunProfileState(environment: ExecutionEnvironment) : CommandLineState(environment) { + + override fun startProcess(): ProcessHandler { + val processHandler = KillableColoredProcessHandler(getSamSyncCommand()) + ProcessTerminatedListener.attach(processHandler) + return processHandler + } + + private fun getSamSyncCommand(): GeneralCommandLine = getSamCli().samSyncCommand( + connection.toEnvironmentVariables(), + templatePath, + settings + ) + + override fun execute(executor: Executor, runner: ProgramRunner<*>) = + super.execute(executor, runner).apply { + var isDevStack = false + processHandler?.addProcessListener(object : ProcessAdapter() { + override fun startNotified(event: ProcessEvent) { + super.startNotified(event) + runInEdt { + RunContentManager.getInstance(project).toFrontRunContent(executor, processHandler) + } + } + private var insertAssertionNow = false + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + if (outputType === ProcessOutputTypes.STDOUT || + outputType === ProcessOutputTypes.STDERR + ) { + if (event.text.contains("Confirm that you are synchronizing a development stack.")) { + isDevStack = true + } + if (event.text.contains("[Y/n]:") && isDevStack) { + insertAssertionNow = true + isDevStack = false + ApplicationManager.getApplication().executeOnPooledThread { + try { + while (insertAssertionNow) { + processHandler.processInput?.write("Y\n".toByteArray(Charset.defaultCharset())) + } + } catch (e: IOException) { + /* no-op */ + } + } + } else { + insertAssertionNow = false + } + } + } + }) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/sync/SyncServerlessApplicationDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/sync/SyncServerlessApplicationDialog.kt new file mode 100644 index 0000000000..88a1d9f113 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/sam/sync/SyncServerlessApplicationDialog.kt @@ -0,0 +1,455 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.sam.sync + +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.module.ModuleUtilCore +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.MutableCollectionComboBoxModel +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.actionListener +import com.intellij.ui.dsl.builder.bind +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.toMutableProperty +import com.intellij.ui.dsl.gridLayout.HorizontalAlign +import com.intellij.ui.layout.selected +import com.intellij.util.text.nullize +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.cloudformation.CloudFormationClient +import software.amazon.awssdk.services.cloudformation.model.StackSummary +import software.amazon.awssdk.services.cloudformation.model.Tag +import software.amazon.awssdk.services.ecr.EcrClient +import software.amazon.awssdk.services.lambda.model.PackageType +import software.amazon.awssdk.services.s3.S3Client +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.map +import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationTemplate +import software.aws.toolkits.jetbrains.services.cloudformation.Parameter +import software.aws.toolkits.jetbrains.services.cloudformation.SamFunction +import software.aws.toolkits.jetbrains.services.cloudformation.describeStackForSync +import software.aws.toolkits.jetbrains.services.cloudformation.mergeRemoteParameters +import software.aws.toolkits.jetbrains.services.cloudformation.resources.CloudFormationResources +import software.aws.toolkits.jetbrains.services.ecr.CreateEcrRepoDialog +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository +import software.aws.toolkits.jetbrains.services.lambda.deploy.CreateCapabilities +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import software.aws.toolkits.jetbrains.services.lambda.sam.ValidateSamParameters.validateParameters +import software.aws.toolkits.jetbrains.services.lambda.sam.ValidateSamParameters.validateStackName +import software.aws.toolkits.jetbrains.services.s3.CreateS3BucketDialog +import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources +import software.aws.toolkits.jetbrains.settings.SyncSettings +import software.aws.toolkits.jetbrains.settings.relativeSamPath +import software.aws.toolkits.jetbrains.ui.KeyValueTextField +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.jetbrains.utils.ui.validationInfo +import software.aws.toolkits.jetbrains.utils.ui.withBinding +import software.aws.toolkits.resources.message +import java.awt.Component + +data class SyncServerlessApplicationSettings( + val stackName: String, + val bucket: String, + val ecrRepo: String?, + val parameters: Map, + val tags: Map, + val useContainer: Boolean, + val capabilities: List +) + +class SyncServerlessApplicationDialog( + private val project: Project, + private val templateFile: VirtualFile, + private val activeStacks: List, + private val loadResourcesOnCreate: Boolean = true +) : DialogWrapper(project) { + var useContainer: Boolean = false + var newStackName: String = "" + var templateParameters: Map = emptyMap() + var tags: Map = emptyMap() + var showImageOptions: Boolean = false + + private val stackNameField = JBTextField().apply { + this.isEnabled = false + } + + private val stackSelector = ResourceSelector.builder() + .resource(CloudFormationResources.ACTIVE_STACKS) + .awsConnection(project) + .customRenderer(SimpleListCellRenderer.create("", StackSummary::stackName)) + .apply { + if (!loadResourcesOnCreate) { + disableAutomaticLoading() + } + } + .build() + + private val s3BucketSelector = ResourceSelector.builder() + .resource(S3Resources.LIST_BUCKETS.map { it.name() }) + .awsConnection(project) + .apply { + if (!loadResourcesOnCreate) { + disableAutomaticLoading() + } + } + .build() + + private val ecrRepoSelector = ResourceSelector.builder() + .resource(EcrResources.LIST_REPOS) + .awsConnection(project) + .customRenderer(SimpleListCellRenderer.create("", Repository::repositoryName)) + .apply { + if (!loadResourcesOnCreate) { + disableAutomaticLoading() + } + } + .build() + + private val parametersField = KeyValueTextField(message("serverless.application.sync.template.parameters")) + private val tagsField = KeyValueTextField(message("tags.title")) + private var templateFileParameters = CloudFormationTemplate.parse(project, templateFile).parameters().toList() + private val module = ModuleUtilCore.findModuleForFile(templateFile, project) + private val settings: SyncSettings? = module?.let { SyncSettings.getInstance(it) } + private val samPath: String = module?.let { relativeSamPath(it, templateFile) } ?: templateFile.name + private val templateFunctions = SamTemplateUtils.findFunctionsFromTemplate(project, templateFile) + private val hasImageFunctions: Boolean = templateFunctions.any { (it as? SamFunction)?.packageType() == PackageType.IMAGE } + private val checkStack = checkIfStackInSettingsExists() + + private var syncType: SyncType = if (checkStack) SyncType.CREATE else SyncType.UPDATE + private var createNewStack = checkStack + private val capabilitiesList = settings?.enabledCapabilities(samPath)?.toMutableList() + ?: mutableListOf(CreateCapabilities.NAMED_IAM, CreateCapabilities.AUTO_EXPAND) + + private val s3Client: S3Client = project.awsClient() + private val ecrClient: EcrClient = project.awsClient() + private val cloudFormationClient: CloudFormationClient = project.awsClient() + private fun checkIfStackInSettingsExists(): Boolean = if (!settings?.samStackName(samPath).isNullOrEmpty()) { + !activeStacks.map { it.stackName() }.contains(settings?.samStackName(samPath)) + } else { + true + } + + fun settings() = SyncServerlessApplicationSettings( + stackName = if (createNewStack) { + newStackName.nullize() + } else { + stackSelector.selected()?.stackName() + } ?: throw RuntimeException(message("serverless.application.sync.validation.stack.missing")), + bucket = s3BucketSelector.selected() ?: throw RuntimeException("s3 bucket selected was null"), + ecrRepo = if (hasImageFunctions) { + ecrRepoSelector.selected()?.repositoryUri + } else { + null + }, + parameters = templateParameters, + tags = tags, + useContainer = useContainer, + capabilities = capabilitiesList + ) + + // TODO: Add Help for Dialog + + private val component by lazy { + panel { + buttonsGroup { + row { + // TODO: Find a better way to bind the radio buttons + val createStackButton = radioButton(message("serverless.application.sync.label.stack.new"), true).applyToComponent { + this.isSelected = createNewStack + this.toolTipText = (message("serverless.application.sync.tooltip.createStack")) + }.bindSelected( + { createNewStack }, + { + if (it) { + createNewStack = true + syncType = SyncType.CREATE + } + } + ) + .actionListener { event, component -> + if (syncType != SyncType.CREATE) { + syncType = SyncType.CREATE + refreshTemplateParametersAndTags() + createNewStack = true + } + } + cell(stackNameField) + .horizontalAlign(HorizontalAlign.FILL) + .enabledIf(createStackButton.component.selected) + .bindText(::newStackName) + .validationOnApply { field -> + if (!field.isEnabled) { + null + } else { + validateStackName(field.text, stackSelector)?.let { field.validationInfo(it) } + } + }.component.toolTipText = message("serverless.application.sync.tooltip.createStack") + } + + row { + val updateStackButton = radioButton(message("serverless.application.sync.label.stack.select"), false).applyToComponent { + isSelected = !createNewStack + this.toolTipText = (message("serverless.application.sync.tooltip.createStack")) + }.bindSelected( + { !createNewStack }, + { + if (it) { + createNewStack = false + syncType = SyncType.UPDATE + } + } + ).actionListener { event, component -> + if (syncType != SyncType.UPDATE) { + syncType = SyncType.UPDATE + refreshTemplateParametersAndTags() + createNewStack = false + } + } + stackSelector.reload(forceFetch = true) + cell(stackSelector) + .horizontalAlign(HorizontalAlign.FILL) + .enabledIf(updateStackButton.component.selected) + .errorOnApply(message("serverless.application.sync.validation.stack.missing")) { + it.isEnabled && (it.isLoading || it.selected() == null) + }.component.toolTipText = message("serverless.application.sync.tooltip.updateStack") + } + }.bind({ createNewStack }, { createNewStack = it }) + + row(message("serverless.application.sync.template.parameters")) { + cell(parametersField) + .withBinding(::templateParameters.toMutableProperty()) + .validationOnApply { + validateParameters(it, templateFileParameters) + }.horizontalAlign(HorizontalAlign.FILL) + .component.toolTipText = message("serverless.application.sync.tooltip.template.parameters") + } + val tagsString = message("tags.title") + row(tagsString) { + cell(tagsField) + .horizontalAlign(HorizontalAlign.FILL) + .withBinding(::tags.toMutableProperty()) + } + + row(message("serverless.application.sync.label.bucket")) { + cell(s3BucketSelector) + .horizontalAlign(HorizontalAlign.FILL) + .errorOnApply(message("serverless.application.sync.validation.s3.bucket.empty")) { it.isLoading || it.selected() == null } + .component.toolTipText = message("serverless.application.sync.tooltip.s3Bucket") + + button(message("general.create")) { actionEvent -> + val bucketDialog = CreateS3BucketDialog( + project = project, + s3Client = s3Client, + parent = actionEvent.source as? Component + ) + + if (bucketDialog.showAndGet()) { + bucketDialog.bucketName().let { + s3BucketSelector.reload(forceFetch = true) + s3BucketSelector.selectedItem = it + } + } + } + } + + row(message("serverless.application.sync.label.repo")) { + cell(ecrRepoSelector) + .horizontalAlign(HorizontalAlign.FILL) + .errorOnApply(message("serverless.application.sync.validation.ecr.repo.empty")) { + it.isVisible && (it.isLoading || it.selected() == null) + }.component.toolTipText = message("serverless.application.sync.tooltip.ecrRepo") + + button(message("general.create")) { actionEvent -> + val ecrDialog = CreateEcrRepoDialog( + project = project, + ecrClient = ecrClient, + parent = actionEvent.source as? Component + ) + + if (ecrDialog.showAndGet()) { + ecrRepoSelector.reload(forceFetch = true) + ecrRepoSelector.selectedItem { it.repositoryName == ecrDialog.repoName } + } + } + }.visible(showImageOptions) + + row { + label(message("cloudformation.capabilities")) + .component.toolTipText = message("cloudformation.capabilities.toolTipText") + CreateCapabilities.values().forEach { + checkBox(it.text).actionListener { event, component -> + if (component.isSelected) capabilitiesList.add(it) else capabilitiesList.remove(it) + }.applyToComponent { + this.isSelected = it in capabilitiesList + this.toolTipText = it.toolTipText + } + } + } + + row { + checkBox(message("serverless.application.sync.use_container")) + .bindSelected(::useContainer) + .component.toolTipText = message("lambda.sam.buildInContainer.tooltip") + } + } + } + + override fun createCenterPanel() = component + + init { + title = message("serverless.application.sync") + setOKButtonText(message("serverless.application.sync.action.name")) + setOKButtonTooltip(message("serverless.application.sync.action.description")) + showImageOptions = hasImageFunctions + + settings?.samStackName(samPath)?.let { stackName -> + if (activeStacks.map { it.stackName() }.contains(stackName)) { + syncType = SyncType.UPDATE + createNewStack = false + stackSelector.selectedItem { it.stackName() == stackName } + refreshTemplateParametersAndTags(stackName) + } + } ?: refreshTemplateParametersAndTags() + + if (showImageOptions) { + ecrRepoSelector.selectedItem = settings?.samEcrRepoUri(samPath) + } + + s3BucketSelector.selectedItem = settings?.samBucketName(samPath) + useContainer = (settings?.samUseContainer(samPath) ?: false) + tagsField.envVars = settings?.samTags(samPath).orEmpty() + parametersField.envVars = settings?.samTempParameterOverrides(samPath).orEmpty() + + init() + } + + private fun refreshTemplateParametersAndTags(stackName: String? = null) { + when (createNewStack) { + true -> { + populateParameters(templateFileParameters) + } + + false -> { + val selectedStackName = stackName ?: stackSelector.selected()?.stackName() + if (selectedStackName == null) { + populateParameters(emptyList()) + } else { + cloudFormationClient.describeStackForSync(selectedStackName, ::enableParamsAndTags) { + it?.let { + runInEdt(ModalityState.any()) { + // This check is here in-case createStack was selected before we got this update back + // TODO: should really create a queuing pattern here so we can cancel on user-action + if (!createNewStack) { + populateParameters(templateFileParameters.mergeRemoteParameters(it.parameters())) + populateTags(it.tags()) + } + } + } ?: populateParameters(templateFileParameters) + } + } + } + } + } + + private fun enableParamsAndTags(enabled: Boolean) { + runInEdt(ModalityState.any()) { + tagsField.isEnabled = enabled + parametersField.isEnabled = enabled + } + } + + @TestOnly + fun getParameterDialog(): DialogPanel = component + + @TestOnly + fun forceUi( + panel: DialogPanel, + isCreateStack: Boolean? = null, + hasImageFunctions: Boolean? = null, + stacks: List? = null, + buckets: List? = null, + ecrRepos: List? = null, + forceStackName: Boolean = false, + stackName: String? = null, + forceBucket: Boolean = false, + bucket: String? = null, + forceEcrRepo: Boolean = false, + ecrRepo: String? = null, + useContainer: Boolean? = null + ) { + if (stacks != null) { + stackSelector.model = MutableCollectionComboBoxModel(stacks) + stackSelector.forceLoaded() + } + + if (isCreateStack == true) { + syncType = SyncType.CREATE + stackNameField.isEnabled = true + stackSelector.isEnabled = false + } else if (isCreateStack == false) { + syncType = SyncType.UPDATE + stackNameField.isEnabled = false + stackSelector.isEnabled = true + } + + if (forceStackName || stackName != null) { + if (syncType == SyncType.CREATE) { + newStackName = stackName.orEmpty() + } else { + stackSelector.selectedItem = stacks?.first { it.stackName() == stackName } + } + } + + if (buckets != null) { + s3BucketSelector.model = MutableCollectionComboBoxModel(buckets) + s3BucketSelector.forceLoaded() + } + + if (forceBucket || bucket != null) { + s3BucketSelector.selectedItem = bucket + } + + if (ecrRepos != null) { + ecrRepoSelector.model = MutableCollectionComboBoxModel(ecrRepos) + ecrRepoSelector.forceLoaded() + } + + if (hasImageFunctions != null) { + showImageOptions = hasImageFunctions + } + + if (forceEcrRepo || ecrRepo != null) { + ecrRepoSelector.selectedItem = ecrRepo + } + + if (useContainer != null) { + this.useContainer = useContainer + } + + panel.reset() + } + + // visible for testing + internal fun populateParameters(parameters: List, templateFileDeclarationOverrides: List? = null) { + // TODO: would be nice to be able to pipe through the description + parametersField.envVars = parameters.associate { it.logicalName to (it.defaultValue().orEmpty()) } + templateFileParameters = templateFileDeclarationOverrides ?: CloudFormationTemplate.parse(project, templateFile).parameters().toList() + } + + private fun populateTags(tags: List) { + tagsField.envVars = tags.associate { it.key() to it.value() } + } + + enum class SyncType { + CREATE, + UPDATE + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/settings/LambdaSettingsConfigurable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/settings/LambdaSettingsConfigurable.kt new file mode 100644 index 0000000000..06ac5b925b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/settings/LambdaSettingsConfigurable.kt @@ -0,0 +1,24 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.settings + +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.project.Project +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.jetbrains.settings.LambdaSettings +import software.aws.toolkits.resources.message + +class LambdaSettingsConfigurable(private val project: Project) : BoundConfigurable(message("aws.settings.lambda.configurable.title")), SearchableConfigurable { + private val lambdaSettings get() = LambdaSettings.getInstance(project) + override fun getId() = "aws.lambda" + override fun createPanel() = panel { + row { + checkBox(message("aws.settings.sam.show_all_gutter_icons")) + .bindSelected(lambdaSettings::showAllHandlerGutterIcons) + .comment(message("aws.settings.sam.show_all_gutter_icons_tooltip")) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/AttachDebugger.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/AttachDebugger.kt new file mode 100644 index 0000000000..d1bd39da8f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/AttachDebugger.kt @@ -0,0 +1,115 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import com.intellij.execution.ExecutionException +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.ui.ConsoleView +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.application.invokeAndWaitIfNeeded +import com.intellij.openapi.util.Key +import com.intellij.xdebugger.XDebuggerManager +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.SamDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.SamRunningState +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.resolveDebuggerSupport +import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable +import software.aws.toolkits.jetbrains.services.lambda.steps.GetPorts.Companion.DEBUG_PORTS +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.Step +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.resources.message +import java.util.concurrent.atomic.AtomicBoolean + +class AttachDebugger(val environment: ExecutionEnvironment, val state: SamRunningState) : Step() { + override val stepName = message("sam.debug.attach") + override val hidden = false + + override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) { + try { + val samProcessHandler = getSamProcess(context) + val samCompleted = AtomicBoolean(false) + + samProcessHandler.addProcessListener(object : ProcessAdapter() { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + if (event.text.contains(SamExecutable.endDebuggingText)) { + samCompleted.set(true) + } + } + }) + + val debugPorts = context.getRequiredAttribute(DEBUG_PORTS) + val debugProcessStarter = prepAndCreateDebugProcessStart(context, debugPorts) + val debugManager = XDebuggerManager.getInstance(environment.project) + + // Note: Do NOT use coroutines here due to it will deadlock Rider when starting the DotnetDebugProcess due to in storeUtil.kt + // saveDocumentsAndProjectsAndApp has a runBlocking call that needs EDT and deadlocks coroutines + invokeAndWaitIfNeeded { + val session = debugManager.startSessionAndShowTab(environment.runProfile.name, environment.contentToReuse, debugProcessStarter) + // Tie SAM output to the Debug tab's console so that it is viewable in both spots + samProcessHandler.addProcessListener(buildProcessAdapter { session.consoleView }) + + // Tie the debug session to the overall workflow, so if it gets cancelled (Stop button on SAM tab), we tell the debugger to stop too + context.addListener(object : Context.Listener { + override fun onCancel() { + session.stop() + } + + override fun onComplete() { + session.stop() + } + }) + + session.debugProcess.processHandler.addProcessListener(object : ProcessAdapter() { + override fun processWillTerminate(event: ProcessEvent, willBeDestroyed: Boolean) { + // If the user pressed Stop on the debug tab, cancel the entire flow + val requestedTermination = event.processHandler.getUserData(ProcessHandler.TERMINATION_REQUESTED) ?: false + if (requestedTermination) { + context.cancel() + } + } + }) + } + } catch (e: TimeoutCancellationException) { + throw ExecutionException(message("lambda.debug.process.start.timeout")) + } catch (e: Throwable) { + throw ExecutionException(e) + } + } + + private fun prepAndCreateDebugProcessStart(context: Context, debugPorts: List) = runBlocking { + withTimeout(SamDebugSupport.debuggerConnectTimeoutMs()) { + state.settings + .resolveDebuggerSupport() + .createDebugProcess(context, environment, state, state.settings.debugHost, debugPorts) + } + } + + private fun getSamProcess(context: Context) = runBlocking { + withTimeout(SamDebugSupport.debuggerConnectTimeoutMs()) { + context.pollingGet(SamRunnerStep.SAM_PROCESS_HANDLER) + } + } + + private fun buildProcessAdapter(console: (() -> ConsoleView?)) = object : ProcessAdapter() { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + // Skip system messages + if (outputType == ProcessOutputTypes.SYSTEM) { + return + } + val viewType = if (outputType == ProcessOutputTypes.STDERR) { + ConsoleViewContentType.ERROR_OUTPUT + } else { + ConsoleViewContentType.NORMAL_OUTPUT + } + console()?.print(event.text, viewType) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/AttachDebuggerParent.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/AttachDebuggerParent.kt new file mode 100644 index 0000000000..34c8c71ae3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/AttachDebuggerParent.kt @@ -0,0 +1,19 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.Step +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.resources.message + +class AttachDebuggerParent(private val childSteps: List) : Step() { + override val stepName = message("sam.debug.attach.parent") + override val hidden = childSteps.size <= 1 + override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) { + childSteps.forEach { + it.run(context, stepEmitter, ignoreCancellation) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/BuildLambda.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/BuildLambda.kt new file mode 100644 index 0000000000..91c62e8648 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/BuildLambda.kt @@ -0,0 +1,54 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import com.intellij.execution.configurations.GeneralCommandLine +import software.aws.toolkits.core.utils.AttributeBagKey +import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions +import software.aws.toolkits.jetbrains.services.lambda.sam.samBuildCommand +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.Step +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.resources.message +import java.nio.file.Path + +data class BuildLambdaRequest( + val templatePath: Path, + val logicalId: String? = null, + val buildDir: Path, + val buildEnvVars: Map = emptyMap(), + val samOptions: SamOptions, + val preBuildSteps: List = emptyList() +) + +class BuildLambda(private val request: BuildLambdaRequest) : SamCliStep() { + override val stepName: String = message("lambda.create.step.build") + + override fun constructCommandLine(context: Context): GeneralCommandLine = getCli().samBuildCommand( + templatePath = request.templatePath, + logicalId = request.logicalId, + buildDir = request.buildDir, + environmentVariables = request.buildEnvVars, + samOptions = request.samOptions + ) + + override fun handleSuccessResult(output: String, stepEmitter: StepEmitter, context: Context) { + context.putAttribute(BUILT_LAMBDA, BuiltLambda(request.buildDir.resolve("template.yaml"), request.logicalId)) + } + + companion object { + val BUILT_LAMBDA = AttributeBagKey.create("BUILT_LAMBDA") + } +} + +/** + * Represents the result of building a Lambda + * + * @param templateLocation The path to the build generated template + * @param logicalId Optional logical id if we are building a specific function + */ +data class BuiltLambda( + val templateLocation: Path, + val logicalId: String? +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/CreateLambda.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/CreateLambda.kt new file mode 100644 index 0000000000..e9a0faf01e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/CreateLambda.kt @@ -0,0 +1,65 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import software.amazon.awssdk.services.lambda.LambdaClient +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.core.utils.AttributeBagKey +import software.aws.toolkits.jetbrains.services.lambda.steps.PackageLambda.Companion.UPLOADED_CODE_LOCATION +import software.aws.toolkits.jetbrains.services.lambda.upload.FunctionDetails +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.Step +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.resources.message + +class CreateLambda(private val lambdaClient: LambdaClient, private val details: FunctionDetails) : Step() { + override val stepName = message("lambda.create.step.create_lambda") + + override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) { + lambdaClient.createFunction { + it.functionName(details.name) + it.description(details.description) + it.role(details.iamRole.arn) + it.timeout(details.timeout) + it.memorySize(details.memorySize) + + it.code { code -> + when (val codeLocation = context.getRequiredAttribute(UPLOADED_CODE_LOCATION)) { + is UploadedS3Code -> { + it.packageType(PackageType.ZIP) + it.handler(details.handler) + it.runtime(details.runtime) + + code.s3Bucket(codeLocation.bucket) + code.s3Key(codeLocation.key) + code.s3ObjectVersion(codeLocation.version) + } + is UploadedEcrCode -> { + it.packageType(PackageType.IMAGE) + code.imageUri(codeLocation.imageUri) + } + } + } + + it.environment { env -> + env.variables(details.envVars) + } + it.tracingConfig { tracing -> + tracing.mode(details.tracingMode) + } + } + + stepEmitter.emitMessage(message("lambda.workflow.update_code.wait_for_stable"), isError = false) + val response = lambdaClient.waiter().waitUntilFunctionExists { it.functionName(details.name) }.matched().response().get() + + // Also wait for it to become active + lambdaClient.waiter().waitUntilFunctionActive { it.functionName(details.name) } + + context.putAttribute(FUNCTION_ARN, response.configuration().functionArn()) + } + + companion object { + val FUNCTION_ARN = AttributeBagKey.create("LAMBDA_FUNCTION_ARN") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/DeployLambda.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/DeployLambda.kt new file mode 100644 index 0000000000..5e64eba863 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/DeployLambda.kt @@ -0,0 +1,39 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import com.intellij.execution.configurations.GeneralCommandLine +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.AttributeBagKey +import software.aws.toolkits.jetbrains.services.lambda.deploy.DeployServerlessApplicationSettings +import software.aws.toolkits.jetbrains.services.lambda.sam.samDeployCommand +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.resources.message +import java.nio.file.Path + +class DeployLambda( + private val packagedTemplateFile: Path, + private val envVars: Map, + private val settings: DeployServerlessApplicationSettings, + region: AwsRegion +) : SamCliStep() { + override val stepName = message("serverless.application.deploy.step_name.create_change_set") + private val changeSetRegex = "(arn:${region.partitionId}:cloudformation:.*changeSet/[^\\s]*)".toRegex() + + override fun constructCommandLine(context: Context): GeneralCommandLine = getCli().samDeployCommand( + environmentVariables = envVars, + templatePath = packagedTemplateFile, + settings = settings + ) + + override fun handleSuccessResult(output: String, stepEmitter: StepEmitter, context: Context) { + val changeSet = changeSetRegex.find(output)?.value ?: throw RuntimeException(message("serverless.application.deploy.change_set_not_found")) + context.putAttribute(CHANGE_SET_ARN, changeSet) + } + + companion object { + val CHANGE_SET_ARN = AttributeBagKey.create("CHANGE_SET_ARN") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/GetPorts.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/GetPorts.kt new file mode 100644 index 0000000000..8d9447848a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/GetPorts.kt @@ -0,0 +1,27 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import com.intellij.util.net.NetUtils +import software.aws.toolkits.core.utils.AttributeBagKey +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.LocalLambdaRunSettings +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.resolveDebuggerSupport +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.Step +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter + +class GetPorts(val settings: LocalLambdaRunSettings) : Step() { + override val stepName: String = "" + override val hidden: Boolean = true + + override fun execute(context: Context, messageEmitter: StepEmitter, ignoreCancellation: Boolean) { + val debugExtension = settings.resolveDebuggerSupport() + val debugPorts = NetUtils.findAvailableSocketPorts(debugExtension.numberOfDebugPorts()).toList() + context.putAttribute(DEBUG_PORTS, debugPorts) + } + + companion object { + val DEBUG_PORTS = AttributeBagKey.create>("DEBUG_PORTS") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/LambdaWorkflows.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/LambdaWorkflows.kt new file mode 100644 index 0000000000..b03d67ab5a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/LambdaWorkflows.kt @@ -0,0 +1,288 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.core.credentials.toEnvironmentVariables +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.jetbrains.services.lambda.deploy.DeployServerlessApplicationSettings +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.ValidateDocker +import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon +import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import software.aws.toolkits.jetbrains.services.lambda.upload.FunctionDetails +import software.aws.toolkits.jetbrains.services.lambda.upload.ImageBasedCode +import software.aws.toolkits.jetbrains.services.lambda.upload.ZipBasedCode +import software.aws.toolkits.jetbrains.utils.execution.steps.StepWorkflow +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +fun createLambdaWorkflowForZip( + project: Project, + functionDetails: FunctionDetails, + codeDetails: ZipBasedCode, + buildDir: Path, + buildEnvVars: Map, + codeStorageLocation: String, + samOptions: SamOptions +): StepWorkflow { + val (dummyTemplate, dummyLogicalId) = createTemporaryZipTemplate(buildDir, codeDetails) + val packagedTemplate = buildDir.resolve("packaged-temp-template.yaml") + val builtTemplate = buildDir.resolve("template.yaml") + val envVars = createAwsEnvVars(project) + + return StepWorkflow( + buildList { + if (samOptions.buildInContainer) { + add(ValidateDocker()) + } + + add( + BuildLambda( + BuildLambdaRequest( + templatePath = dummyTemplate, + buildDir = buildDir, + buildEnvVars = buildEnvVars, + samOptions = samOptions + ) + ), + ) + add( + PackageLambda( + templatePath = builtTemplate, + packagedTemplatePath = packagedTemplate, + logicalId = dummyLogicalId, + envVars = envVars, + s3Bucket = codeStorageLocation + ), + ) + add( + CreateLambda( + lambdaClient = project.awsClient(), + details = functionDetails + ) + ) + } + ) +} + +fun createLambdaWorkflowForImage( + project: Project, + functionDetails: FunctionDetails, + codeDetails: ImageBasedCode, + codeStorageLocation: String, + samOptions: SamOptions +): StepWorkflow { + val (dummyTemplate, dummyLogicalId) = createTemporaryImageTemplate(codeDetails) + val buildDir = codeDetails.dockerfile.resolveSibling(".aws-sam").resolve("build") + val builtTemplate = buildDir.resolve("template.yaml") + val packagedTemplate = buildDir.resolve("packaged-temp-template.yaml") + val envVars = createAwsEnvVars(project) + + return StepWorkflow( + ValidateDocker(), + BuildLambda( + BuildLambdaRequest( + templatePath = dummyTemplate, + buildDir = buildDir, + samOptions = samOptions + ) + ), + PackageLambda( + templatePath = builtTemplate, + packagedTemplatePath = packagedTemplate, + logicalId = dummyLogicalId, + envVars = envVars, + ecrRepo = codeStorageLocation + ), + CreateLambda(project.awsClient(), functionDetails) + ) +} + +/** + * Creates a [StepWorkflow] for updating a Lambda's code and optionally its handler + * + * @param updatedHandler If provided, we will call update function configuration with the provided handler. + */ +fun updateLambdaCodeWorkflowForZip( + project: Project, + functionName: String, + codeDetails: ZipBasedCode, + buildDir: Path, + buildEnvVars: Map, + codeStorageLocation: String, + samOptions: SamOptions, + updatedHandler: String? +): StepWorkflow { + val (dummyTemplate, dummyLogicalId) = createTemporaryZipTemplate(buildDir, codeDetails) + val builtTemplate = buildDir.resolve("template.yaml") + val packagedTemplate = buildDir.resolve("packaged-temp-template.yaml") + val envVars = createAwsEnvVars(project) + + return StepWorkflow( + buildList { + if (samOptions.buildInContainer) { + add(ValidateDocker()) + } + + add( + BuildLambda( + BuildLambdaRequest( + templatePath = dummyTemplate, + buildDir = buildDir, + buildEnvVars = buildEnvVars, + samOptions = samOptions + ) + ), + ) + add( + PackageLambda( + templatePath = builtTemplate, + packagedTemplatePath = packagedTemplate, + logicalId = dummyLogicalId, + envVars = envVars, + s3Bucket = codeStorageLocation + ), + ) + add( + UpdateLambdaCode( + lambdaClient = project.awsClient(), + functionName = functionName, + updatedHandler = updatedHandler + ) + ) + } + ) +} + +fun updateLambdaCodeWorkflowForImage( + project: Project, + functionName: String, + codeDetails: ImageBasedCode, + codeStorageLocation: String, + samOptions: SamOptions +): StepWorkflow { + val (dummyTemplate, dummyLogicalId) = createTemporaryImageTemplate(codeDetails) + val buildDir = codeDetails.dockerfile.resolveSibling(".aws-sam").resolve("build") + val builtTemplate = buildDir.resolve("template.yaml") + val packagedTemplate = buildDir.resolve("packaged-temp-template.yaml") + val envVars = createAwsEnvVars(project) + + return StepWorkflow( + ValidateDocker(), + BuildLambda( + BuildLambdaRequest( + templatePath = dummyTemplate, + buildDir = buildDir, + samOptions = samOptions + ) + ), + PackageLambda( + templatePath = builtTemplate, + packagedTemplatePath = packagedTemplate, + logicalId = dummyLogicalId, + envVars = envVars, + ecrRepo = codeStorageLocation + ), + UpdateLambdaCode( + lambdaClient = project.awsClient(), + functionName = functionName, + updatedHandler = null + ) + ) +} + +fun createDeployWorkflow( + project: Project, + template: VirtualFile, + settings: DeployServerlessApplicationSettings +): StepWorkflow { + val envVars = createAwsEnvVars(project) + val region = AwsConnectionManager.getInstance(project).activeRegion + val buildDir = Paths.get(template.parent.path, SamCommon.SAM_BUILD_DIR, "build") + val builtTemplate = buildDir.resolve("template.yaml") + val packagedTemplate = builtTemplate.parent.resolve("packaged-${builtTemplate.fileName}") + val templatePath = Paths.get(template.path) + + Files.createDirectories(buildDir) + + return StepWorkflow( + buildList { + if (settings.useContainer) { + add(ValidateDocker()) + } + + add( + BuildLambda( + BuildLambdaRequest( + templatePath = templatePath, + logicalId = null, + buildDir = buildDir, + buildEnvVars = envVars, + samOptions = SamOptions(buildInContainer = settings.useContainer) + ) + ) + ) + add( + PackageLambda( + templatePath = builtTemplate, + packagedTemplatePath = packagedTemplate, + logicalId = null, + envVars = envVars, + s3Bucket = settings.bucket, + ecrRepo = settings.ecrRepo + ) + ) + add( + DeployLambda( + packagedTemplateFile = packagedTemplate, + region = region, + envVars = envVars, + settings = settings + ) + ) + } + ) +} + +private fun createAwsEnvVars(project: Project): Map { + val connectSettings = AwsConnectionManager.getInstance(project).connectionSettings() + ?: throw IllegalStateException("Tried to update a lambda without valid AWS connection") + + return connectSettings.credentials.resolveCredentials().toEnvironmentVariables() + connectSettings.region.toEnvironmentVariables() +} + +private fun createTemporaryZipTemplate(buildDir: Path, codeDetails: ZipBasedCode): Pair { + Files.createDirectories(buildDir) + + val dummyTemplate = Files.createTempFile("temp-template", ".yaml") + val dummyLogicalId = "Function" + + SamTemplateUtils.writeDummySamTemplate( + tempFile = dummyTemplate, + logicalId = dummyLogicalId, + runtime = codeDetails.runtime, + handler = codeDetails.handler, + codeUri = codeDetails.baseDir.toString() + ) + + return Pair(dummyTemplate, dummyLogicalId) +} + +private fun createTemporaryImageTemplate(codeDetails: ImageBasedCode): Pair { + val dummyTemplate = Files.createTempFile("temp-template", ".yaml") + val dummyLogicalId = "Function" + + SamTemplateUtils.writeDummySamImageTemplate( + tempFile = dummyTemplate, + logicalId = dummyLogicalId, + dockerfile = codeDetails.dockerfile + ) + + return Pair(dummyTemplate, dummyLogicalId) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/PackageLambda.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/PackageLambda.kt new file mode 100644 index 0000000000..92c6da0af3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/PackageLambda.kt @@ -0,0 +1,43 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import com.intellij.execution.configurations.GeneralCommandLine +import software.aws.toolkits.core.utils.AttributeBagKey +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import software.aws.toolkits.jetbrains.services.lambda.sam.samPackageCommand +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.resources.message +import java.nio.file.Path + +class PackageLambda( + private val templatePath: Path, + private val packagedTemplatePath: Path, + private val logicalId: String?, + private val envVars: Map, + private val s3Bucket: String? = null, + private val ecrRepo: String? = null +) : SamCliStep() { + override val stepName: String = message("lambda.create.step.package") + + override fun constructCommandLine(context: Context): GeneralCommandLine = getCli().samPackageCommand( + templatePath = templatePath, + packagedTemplatePath = packagedTemplatePath, + environmentVariables = envVars, + s3Bucket = s3Bucket, + ecrRepo = ecrRepo + ) + + override fun handleSuccessResult(output: String, stepEmitter: StepEmitter, context: Context) { + // We finished the upload, extract out the uploaded code location if we have a logicalId + logicalId ?: return + + context.putAttribute(UPLOADED_CODE_LOCATION, SamTemplateUtils.getUploadedCodeUri(packagedTemplatePath, logicalId)) + } + + companion object { + val UPLOADED_CODE_LOCATION = AttributeBagKey.create("UPLOADED_CODE_LOCATION") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/SamCliStep.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/SamCliStep.kt new file mode 100644 index 0000000000..e8ad090b1e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/SamCliStep.kt @@ -0,0 +1,12 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import com.intellij.execution.configurations.GeneralCommandLine +import software.aws.toolkits.jetbrains.services.lambda.sam.getSamCli +import software.aws.toolkits.jetbrains.utils.execution.steps.CliBasedStep + +abstract class SamCliStep : CliBasedStep() { + fun getCli(): GeneralCommandLine = getSamCli() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/SamRunnerStep.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/SamRunnerStep.kt new file mode 100644 index 0000000000..39f8fddca7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/SamRunnerStep.kt @@ -0,0 +1,94 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.encoding.EncodingProjectManager +import software.aws.toolkits.core.credentials.toEnvironmentVariables +import software.aws.toolkits.core.utils.AttributeBagKey +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.LocalLambdaRunSettings +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.TemplateSettings +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.resolveDebuggerSupport +import software.aws.toolkits.jetbrains.services.lambda.steps.GetPorts.Companion.DEBUG_PORTS +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.resources.message + +class SamRunnerStep(val environment: ExecutionEnvironment, val settings: LocalLambdaRunSettings, val debug: Boolean) : SamCliStep() { + override val stepName: String = message("lambda.debug.step.start_sam") + override fun onProcessStart(context: Context, processHandler: ProcessHandler) { + context.putAttribute(SAM_PROCESS_HANDLER, processHandler) + } + + override fun constructCommandLine(context: Context): GeneralCommandLine { + val builtLambda = context.getRequiredAttribute(BuildLambda.BUILT_LAMBDA) + val totalEnvVars = settings.environmentVariables + + settings.connection.credentials.resolveCredentials().toEnvironmentVariables() + + settings.connection.region.toEnvironmentVariables() + + val commandLine = getCli() + .withParameters("local") + .withParameters("invoke") + .apply { + if (ApplicationManager.getApplication().isUnitTestMode) { + withParameters("--debug") + } + + if (settings is TemplateSettings) { + withParameters(settings.logicalId) + } + } + .withParameters("--template") + .withParameters(builtLambda.templateLocation.toString()) + .withParameters("--event") + .withParameters(createEventFile()) + // the default charset may not be compatible with the executable. + // matches pycharm behavior:https://github.com/JetBrains/intellij-community/blob/9aadf09286f1c3707ad25b855b7ce7c45810c9ae/python/src/com/jetbrains/python/run/PythonScriptCommandLineState.java#L243 + .withCharset(EncodingProjectManager.getInstance(environment.project).defaultCharset) + .withEnvironment(totalEnvVars) + .withEnvironment("PYTHONUNBUFFERED", "1") // Force SAM to not buffer stdout/stderr so it gets shown in IDE + + if (debug) { + val debugExtension = settings.resolveDebuggerSupport() + val debugPorts = context.getRequiredAttribute(DEBUG_PORTS) + commandLine.addParameters(debugExtension.samArguments(debugPorts)) + debugPorts.forEach { + commandLine.withParameters("--debug-port").withParameters(it.toString()) + } + } + + val samOptions = settings.samOptions + if (samOptions.skipImagePull) { + commandLine.withParameters("--skip-pull-image") + } + + samOptions.dockerNetwork?.let { + if (it.isNotBlank()) { + commandLine.withParameters("--docker-network") + .withParameters(it.trim()) + } + } + + samOptions.additionalLocalArgs?.let { + if (it.isNotBlank()) { + commandLine.withParameters(*it.split(" ").toTypedArray()) + } + } + + return commandLine + } + + private fun createEventFile(): String { + val eventFile = FileUtil.createTempFile("${environment.runProfile.name}-event", ".json", true) + eventFile.writeText(settings.input) + return eventFile.absolutePath + } + + companion object { + val SAM_PROCESS_HANDLER = AttributeBagKey.create("samProcessHandler") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/UpdateLambdaCode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/UpdateLambdaCode.kt new file mode 100644 index 0000000000..40df98c49c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/UpdateLambdaCode.kt @@ -0,0 +1,47 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +import software.amazon.awssdk.services.lambda.LambdaClient +import software.aws.toolkits.jetbrains.services.lambda.steps.PackageLambda.Companion.UPLOADED_CODE_LOCATION +import software.aws.toolkits.jetbrains.services.lambda.waitForUpdatableState +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.Step +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.resources.message + +class UpdateLambdaCode(private val lambdaClient: LambdaClient, private val functionName: String, private val updatedHandler: String?) : Step() { + override val stepName = message("lambda.create.step.update_lambda") + + override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) { + stepEmitter.emitMessageLine(message("lambda.workflow.update_code.wait_for_updatable"), isError = false) + lambdaClient.waitForUpdatableState(functionName) + lambdaClient.updateFunctionCode { + it.functionName(functionName) + + when (val codeLocation = context.getRequiredAttribute(UPLOADED_CODE_LOCATION)) { + is UploadedS3Code -> { + it.s3Bucket(codeLocation.bucket) + it.s3Key(codeLocation.key) + it.s3ObjectVersion(codeLocation.version) + } + is UploadedEcrCode -> { + it.imageUri(codeLocation.imageUri) + } + } + } + + updatedHandler?.let { _ -> + stepEmitter.emitMessageLine(message("lambda.workflow.update_code.wait_for_updatable"), isError = false) + lambdaClient.waitForUpdatableState(functionName) + lambdaClient.updateFunctionConfiguration { + it.functionName(functionName) + it.handler(updatedHandler) + } + } + + stepEmitter.emitMessageLine(message("lambda.workflow.update_code.wait_for_stable"), isError = false) + lambdaClient.waiter().waitUntilFunctionUpdated { it.functionName(functionName) } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/UploadedCode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/UploadedCode.kt new file mode 100644 index 0000000000..f353aa1bb7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/steps/UploadedCode.kt @@ -0,0 +1,8 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.steps + +sealed class UploadedCode +data class UploadedS3Code(val bucket: String, val key: String, val version: String?) : UploadedCode() +data class UploadedEcrCode(val imageUri: String) : UploadedCode() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/BuildSettingsPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/BuildSettingsPanel.form new file mode 100644 index 0000000000..357d39ba87 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/BuildSettingsPanel.form @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/BuildSettingsPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/BuildSettingsPanel.kt new file mode 100644 index 0000000000..14d90b5722 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/BuildSettingsPanel.kt @@ -0,0 +1,29 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.ui.IdeBorderFactory +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import javax.swing.JCheckBox +import javax.swing.JPanel +import kotlin.properties.Delegates + +class BuildSettingsPanel : JPanel(BorderLayout()) { + lateinit var content: JPanel + private set + lateinit var buildInContainerCheckbox: JCheckBox + private set + + var packagingType: PackageType by Delegates.observable(PackageType.ZIP) { _, _, _ -> updateComponents() } + + init { + content.border = IdeBorderFactory.createTitledBorder(message("lambda.upload.build_settings"), false) + add(content, BorderLayout.CENTER) + } + + private fun updateComponents() { + content.isVisible = packagingType == PackageType.ZIP + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CodeDetails.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CodeDetails.kt new file mode 100644 index 0000000000..f65d16227b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CodeDetails.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.jetbrains.services.lambda.upload + +import software.amazon.awssdk.services.lambda.model.Runtime +import java.nio.file.Path + +sealed class CodeDetails +data class ZipBasedCode(val baseDir: Path, val handler: String, val runtime: Runtime) : CodeDetails() +data class ImageBasedCode(val dockerfile: Path) : CodeDetails() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CodeStoragePanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CodeStoragePanel.form new file mode 100644 index 0000000000..d142ce241f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CodeStoragePanel.form @@ -0,0 +1,73 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CodeStoragePanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CodeStoragePanel.kt new file mode 100644 index 0000000000..29339198af --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CodeStoragePanel.kt @@ -0,0 +1,124 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.SimpleListCellRenderer +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.map +import software.aws.toolkits.jetbrains.services.ecr.CreateEcrRepoDialog +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository +import software.aws.toolkits.jetbrains.services.s3.CreateS3BucketDialog +import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources.LIST_BUCKETS +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.jetbrains.utils.ui.validationInfo +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JPanel +import kotlin.properties.Delegates.observable + +class CodeStoragePanel(private val project: Project) : JPanel(BorderLayout()) { + lateinit var content: JPanel + private set + lateinit var sourceBucket: ResourceSelector + private set + lateinit var ecrRepo: ResourceSelector + private set + + private lateinit var s3Label: JLabel + private lateinit var createBucketButton: JButton + private lateinit var ecrLabel: JLabel + private lateinit var createEcrRepoButton: JButton + + var packagingType: PackageType by observable(PackageType.ZIP) { _, _, _ -> updateComponents() } + + init { + createBucketButton.addActionListener { + val bucketDialog = CreateS3BucketDialog( + project = project, + s3Client = project.awsClient(), + parent = content + ) + + if (bucketDialog.showAndGet()) { + bucketDialog.bucketName().let { + sourceBucket.reload(forceFetch = true) + sourceBucket.selectedItem = it + } + } + } + + createEcrRepoButton.addActionListener { + val ecrDialog = CreateEcrRepoDialog( + project = project, + ecrClient = project.awsClient(), + parent = content + ) + + if (ecrDialog.showAndGet()) { + ecrRepo.reload(forceFetch = true) + ecrRepo.selectedItem { it.repositoryName == ecrDialog.repoName } + } + } + + content.border = IdeBorderFactory.createTitledBorder(message("lambda.upload.deployment_settings"), false) + + updateComponents() + + add(content, BorderLayout.CENTER) + } + + private fun createUIComponents() { + sourceBucket = ResourceSelector.builder().resource(LIST_BUCKETS.map { it.name() }).awsConnection(project).build() + ecrRepo = ResourceSelector.builder() + .resource(EcrResources.LIST_REPOS) + .customRenderer(SimpleListCellRenderer.create("") { it.repositoryName }) + .awsConnection(project) + .build() + } + + private fun updateComponents() { + val isZip = packagingType == PackageType.ZIP + s3Label.isVisible = isZip + sourceBucket.isVisible = isZip + createBucketButton.isVisible = isZip + + ecrLabel.isVisible = !isZip + ecrRepo.isVisible = !isZip + createEcrRepoButton.isVisible = !isZip + } + + fun codeLocation() = if (packagingType == PackageType.ZIP) { + sourceBucket.selected() as String + } else { + ecrRepo.selected()?.repositoryUri as String + } + + fun validatePanel(): ValidationInfo? { + if (packagingType == PackageType.ZIP) { + if (sourceBucket.isLoading) { + return sourceBucket.validationInfo(message("serverless.application.deploy.validation.s3.bucket.loading")) + } + + if (sourceBucket.selected() == null) { + return sourceBucket.validationInfo(message("lambda.upload_validation.source_bucket")) + } + } else { + if (ecrRepo.isLoading) { + return ecrRepo.validationInfo(message("serverless.application.deploy.validation.ecr.repo.loading")) + } + + if (ecrRepo.selected() == null) { + return ecrRepo.validationInfo(message("lambda.upload_validation.repo")) + } + } + + return null + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionDialog.kt new file mode 100644 index 0000000000..7fa53a91cd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionDialog.kt @@ -0,0 +1,215 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.util.text.nullize +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.lambda.model.PackageType +import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.core.lambda.validOrNull +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.services.lambda.Lambda.findPsiElementsForHandler +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder +import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.DEFAULT_MEMORY_SIZE +import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.DEFAULT_TIMEOUT +import software.aws.toolkits.jetbrains.services.lambda.resources.LambdaResources +import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions +import software.aws.toolkits.jetbrains.services.lambda.steps.CreateLambda.Companion.FUNCTION_ARN +import software.aws.toolkits.jetbrains.services.lambda.steps.createLambdaWorkflowForImage +import software.aws.toolkits.jetbrains.services.lambda.steps.createLambdaWorkflowForZip +import software.aws.toolkits.jetbrains.settings.UpdateLambdaSettings +import software.aws.toolkits.jetbrains.utils.execution.steps.BuildViewWorkflowEmitter +import software.aws.toolkits.jetbrains.utils.execution.steps.StepExecutor +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.LambdaPackageType +import software.aws.toolkits.telemetry.LambdaTelemetry +import software.aws.toolkits.telemetry.Result +import java.nio.file.Paths +import javax.swing.JComponent + +class CreateFunctionDialog(private val project: Project, private val initialRuntime: Runtime?, private val handlerName: String?) : DialogWrapper(project) { + private val view = CreateFunctionPanel(project) + + init { + super.init() + title = message("lambda.upload.create.title") + setOKButtonText(message("lambda.upload.create.title")) + + with(view.configSettings) { + timeoutSlider.value = DEFAULT_TIMEOUT + memorySlider.value = DEFAULT_MEMORY_SIZE + + // show a filtered list of runtimes to only ones we can build (since we have to build) + runtimeModel.setAll(LambdaBuilder.supportedRuntimeGroups().flatMap { it.supportedSdkRuntimes }) + + handlerName?.let { handler -> + handlerPanel.handler.text = handler + } + initialRuntime?.validOrNull?.let { + runtimeModel.selectedItem = it + handlerPanel.setRuntime(it) + } + } + } + + override fun createCenterPanel(): JComponent = view.content + + override fun getPreferredFocusedComponent(): JComponent = view.name + + override fun doValidate(): ValidationInfo? = view.validatePanel() + + override fun doCancelAction() { + LambdaTelemetry.deploy( + project, + result = Result.Cancelled, + lambdaPackageType = LambdaPackageType.from(view.configSettings.packageType().toString()), + initialDeploy = true + ) + super.doCancelAction() + } + + override fun doOKAction() { + upsertLambdaCode() + } + + override fun getHelpId(): String = HelpIds.CREATE_FUNCTION_DIALOG.id + + private fun upsertLambdaCode() { + if (!okAction.isEnabled) { + return + } + + val functionName = view.name.text + val workflow = createWorkflow() + val packageType = LambdaPackageType.from(view.configSettings.packageType().toString()) + + workflow.onSuccess = { + saveSettings(it.getRequiredAttribute(FUNCTION_ARN)) + + notifyInfo( + project = project, + title = message("lambda.service_name"), + content = message("lambda.function.created.notification", functionName) + ) + LambdaTelemetry.deploy( + project, + result = Result.Succeeded, + lambdaPackageType = packageType, + initialDeploy = true + ) + project.refreshAwsTree(LambdaResources.LIST_FUNCTIONS) + } + + workflow.onError = { + it.notifyError(project = project, title = message("lambda.service_name")) + LambdaTelemetry.deploy( + project, + result = Result.Failed, + lambdaPackageType = packageType, + initialDeploy = true + ) + } + + workflow.startExecution() + + close(OK_EXIT_CODE) + } + + @TestOnly + fun createWorkflow(): StepExecutor { + FileDocumentManager.getInstance().saveAllDocuments() + + val samOptions = SamOptions( + buildInContainer = view.buildSettings.buildInContainerCheckbox.isSelected + ) + + val workflow = when (val packageType = view.configSettings.packageType()) { + PackageType.ZIP -> { + val runtime = view.configSettings.runtime.selected() ?: throw IllegalStateException("Runtime is missing when package type is Zip") + val handler = view.configSettings.handlerPanel.handler.text + + val functionDetails = viewToFunctionDetails(runtime, handler) + + // TODO: Move this so we can share it with CreateFunctionDialog, but don't move it lower since passing PsiElement lower needs to go away since + // it is causing customer complaints. We need to prompt for baseDir and try to infer it if we can but only as a default value... + val element = findPsiElementsForHandler(project, runtime, handler).first() + val module = ModuleUtil.findModuleForPsiElement(element) ?: throw IllegalStateException("Failed to locate module for $element") + val lambdaBuilder = runtime.runtimeGroup?.let { LambdaBuilder.getInstanceOrNull(it) } + ?: throw IllegalStateException("LambdaBuilder for $runtime not found") + + val codeDetails = ZipBasedCode( + baseDir = lambdaBuilder.handlerBaseDirectory(module, element), + handler = handler, + runtime = runtime + ) + + createLambdaWorkflowForZip( + project = project, + functionDetails = functionDetails, + codeDetails = codeDetails, + buildDir = lambdaBuilder.getBuildDirectory(module), + buildEnvVars = lambdaBuilder.additionalBuildEnvironmentVariables(project, module, samOptions), + codeStorageLocation = view.codeStorage.codeLocation(), + samOptions = samOptions + ) + } + PackageType.IMAGE -> { + val functionDetails = viewToFunctionDetails() + val codeDetails = ImageBasedCode( + dockerfile = Paths.get(view.configSettings.dockerFile.text) + ) + + createLambdaWorkflowForImage( + project = project, + functionDetails = functionDetails, + codeDetails = codeDetails, + codeStorageLocation = view.codeStorage.codeLocation(), + samOptions = samOptions + ) + } + else -> throw UnsupportedOperationException("$packageType is not supported") + } + + val emitter = BuildViewWorkflowEmitter.createEmitter( + project, + message("lambda.workflow.create_new.name"), + view.name.text + ) + return StepExecutor(project, workflow, emitter) + } + + private fun viewToFunctionDetails(runtime: Runtime? = null, handler: String? = null): FunctionDetails = FunctionDetails( + name = view.name.text.trim(), + description = view.description.text, + packageType = view.configSettings.packageType(), + runtime = runtime, + handler = handler, + iamRole = view.configSettings.iamRole.selected()!!, + envVars = view.configSettings.envVars.envVars, + timeout = view.configSettings.timeoutSlider.value, + memorySize = view.configSettings.memorySlider.value, + xrayEnabled = view.configSettings.xrayEnabled.isSelected + ) + + private fun saveSettings(arn: String) { + val settings = UpdateLambdaSettings.getInstance(arn) + settings.bucketName = view.codeStorage.sourceBucket.selected() + settings.ecrRepo = view.codeStorage.ecrRepo.selected()?.repositoryArn + settings.dockerfile = view.configSettings.dockerFile.text.nullize() + settings.useContainer = view.buildSettings.buildInContainerCheckbox.isSelected + } + + @TestOnly + fun getViewForTestAssertions() = view +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionPanel.form new file mode 100644 index 0000000000..dc1dfa278e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionPanel.form @@ -0,0 +1,70 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionPanel.kt new file mode 100644 index 0000000000..d97585a7d8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateFunctionPanel.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.services.lambda.upload + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.IdeBorderFactory +import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.FUNCTION_NAME_PATTERN +import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.MAX_FUNCTION_NAME_LENGTH +import software.aws.toolkits.jetbrains.utils.ui.blankAsNull +import software.aws.toolkits.jetbrains.utils.ui.validationInfo +import software.aws.toolkits.resources.message +import javax.swing.JPanel +import javax.swing.JTextField + +class CreateFunctionPanel(private val project: Project) { + lateinit var name: JTextField + private set + lateinit var description: JTextField + private set + lateinit var content: JPanel + private set + lateinit var buildSettings: BuildSettingsPanel + private set + lateinit var configSettings: LambdaConfigPanel + private set + lateinit var codeStorage: CodeStoragePanel + private set + + init { + configSettings.border = IdeBorderFactory.createTitledBorder(message("lambda.upload.configuration_settings"), false) + codeStorage.border = IdeBorderFactory.createTitledBorder(message("lambda.upload.code_location_settings"), false) + buildSettings.border = IdeBorderFactory.createTitledBorder(message("lambda.upload.build_settings"), false) + } + + private fun createUIComponents() { + configSettings = LambdaConfigPanel(project, isUpdate = false) + codeStorage = CodeStoragePanel(project) + + configSettings.packageZip.addChangeListener { + val packageType = configSettings.packageType() + codeStorage.packagingType = packageType + buildSettings.packagingType = packageType + } + } + + fun validatePanel(): ValidationInfo? { + val nameValue = name.blankAsNull() + ?: return name.validationInfo(message("lambda.upload_validation.function_name")) + + if (!FUNCTION_NAME_PATTERN.matches(nameValue)) { + return name.validationInfo(message("lambda.upload_validation.function_name_invalid")) + } + + if (nameValue.length > MAX_FUNCTION_NAME_LENGTH) { + return name.validationInfo(message("lambda.upload_validation.function_name_too_long", 64)) + } + + return configSettings.validatePanel() ?: codeStorage.validatePanel() + } +} 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 deleted file mode 100644 index 0ff4739c96..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateLambdaFunction.kt +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.upload - -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.LangDataKeys -import com.intellij.psi.PsiElement -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.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 -import software.aws.toolkits.jetbrains.services.lambda.runtime -import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions -import software.aws.toolkits.jetbrains.services.lambda.upload.EditFunctionMode.NEW -import software.aws.toolkits.jetbrains.utils.notifyNoActiveCredentialsError -import software.aws.toolkits.resources.message - -class CreateLambdaFunction( - private val handlerName: String?, - private val elementPointer: SmartPsiElementPointer?, - private val lambdaHandlerResolver: LambdaHandlerResolver? -) : AnAction(message("lambda.create_new"), null, AwsIcons.Actions.LAMBDA_FUNCTION_NEW) { - - @Suppress("unused") // Used by ActionManager in plugin.xml - constructor() : this(null, null, null) - - init { - if (handlerName != null) { - elementPointer ?: throw IllegalArgumentException("elementPointer must be provided if handlerName is provided") - lambdaHandlerResolver - ?: throw IllegalArgumentException("lambdaHandlerResolver must be provided if handlerName is provided") - } - } - - override fun actionPerformed(e: AnActionEvent) { - val project = e.getRequiredData(LangDataKeys.PROJECT) - val runtime = e.runtime() - - if (!AwsConnectionManager.getInstance(project).isValidConnectionSettings()) { - notifyNoActiveCredentialsError(project = project) - return - } - - val dialog = if (handlerName != null) { - EditFunctionDialog(project = project, mode = NEW, runtime = runtime, handlerName = handlerName) - } else { - EditFunctionDialog(project = project, mode = NEW, runtime = runtime) - } - - dialog.show() - } - - override fun update(e: AnActionEvent) { - super.update(e) - - val element: PsiElement? = elementPointer?.element - if (handlerName == null || element == null || lambdaHandlerResolver == null) { - e.presentation.isVisible = true - return - } - - val templateFunctionHandlers = CloudFormationTemplateIndex.listFunctions(element.project) - .mapNotNull { it.handler() } - .toSet() - - val allowAction = lambdaHandlerResolver.determineHandlers(element, element.containingFile.virtualFile) - .none { it in templateFunctionHandlers } - - e.presentation.isVisible = allowAction - } -} - -data class FunctionUploadDetails( - val name: String, - val handler: String, - val iamRole: IamRole, - val runtime: Runtime, - val description: String?, - val envVars: Map, - val timeout: Int, - val memorySize: Int, - val xrayEnabled: Boolean, - val samOptions: SamOptions -) { - val tracingMode: TracingMode = - if (xrayEnabled) { - TracingMode.ACTIVE - } else { - TracingMode.PASS_THROUGH - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateLambdaFunctionAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateLambdaFunctionAction.kt new file mode 100644 index 0000000000..9b092d3240 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/CreateLambdaFunctionAction.kt @@ -0,0 +1,68 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.actionSystem.UpdateInBackground +import com.intellij.psi.PsiElement +import com.intellij.psi.SmartPsiElementPointer +import icons.AwsIcons +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.services.cloudformation.CloudFormationTemplateIndex +import software.aws.toolkits.jetbrains.services.lambda.LambdaHandlerResolver +import software.aws.toolkits.jetbrains.services.lambda.runtime +import software.aws.toolkits.jetbrains.utils.notifyNoActiveCredentialsError +import software.aws.toolkits.resources.message + +class CreateLambdaFunctionAction( + private val handlerName: String?, + private val elementPointer: SmartPsiElementPointer?, + private val lambdaHandlerResolver: LambdaHandlerResolver? +) : AnAction(message("lambda.create_new"), null, AwsIcons.Actions.LAMBDA_FUNCTION_NEW), UpdateInBackground { + + @Suppress("unused") // Used by ActionManager in plugin.xml + constructor() : this(null, null, null) + + init { + if (handlerName != null) { + elementPointer ?: throw IllegalArgumentException("elementPointer must be provided if handlerName is provided") + lambdaHandlerResolver + ?: throw IllegalArgumentException("lambdaHandlerResolver must be provided if handlerName is provided") + } + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(LangDataKeys.PROJECT) + val runtime = e.runtime() + + if (!AwsConnectionManager.getInstance(project).isValidConnectionSettings()) { + notifyNoActiveCredentialsError(project = project) + return + } + + CreateFunctionDialog(project = project, initialRuntime = runtime?.toSdkRuntime(), handlerName = handlerName).show() + } + + override fun update(e: AnActionEvent) { + super.update(e) + + val element: PsiElement? = elementPointer?.element + if (handlerName == null || element == null || lambdaHandlerResolver == null) { + // It was created from ActionManager, so only show it if we have supported runtime groups + e.presentation.isVisible = LambdaHandlerResolver.supportedRuntimeGroups().isNotEmpty() + return + } + + val templateFunctionHandlers = CloudFormationTemplateIndex.listFunctions(element.project) + .mapNotNull { it.handler() } + .toSet() + + val allowAction = lambdaHandlerResolver.determineHandlers(element, element.containingFile.virtualFile) + .none { it in templateFunctionHandlers } + + e.presentation.isVisible = allowAction + } +} 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 deleted file mode 100644 index efbffefa47..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionDialog.kt +++ /dev/null @@ -1,368 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.upload - -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.fileEditor.FileDocumentManager -import com.intellij.openapi.module.ModuleUtil -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.DialogWrapper -import com.intellij.openapi.ui.ValidationInfo -import com.intellij.util.ExceptionUtil -import com.intellij.util.text.nullize -import com.intellij.util.ui.UIUtil -import org.jetbrains.annotations.TestOnly -import software.amazon.awssdk.services.iam.IamClient -import software.amazon.awssdk.services.lambda.LambdaClient -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.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 -import software.aws.toolkits.jetbrains.services.iam.IamRole -import software.aws.toolkits.jetbrains.services.lambda.Lambda.findPsiElementsForHandler -import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder -import software.aws.toolkits.jetbrains.services.lambda.LambdaFunction -import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.DEFAULT_MEMORY_SIZE -import software.aws.toolkits.jetbrains.services.lambda.LambdaLimits.DEFAULT_TIMEOUT -import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup -import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions -import software.aws.toolkits.jetbrains.services.lambda.upload.EditFunctionMode.NEW -import software.aws.toolkits.jetbrains.services.lambda.upload.EditFunctionMode.UPDATE_CODE -import software.aws.toolkits.jetbrains.services.lambda.upload.EditFunctionMode.UPDATE_CONFIGURATION -import software.aws.toolkits.jetbrains.services.lambda.validOrNull -import software.aws.toolkits.jetbrains.services.s3.CreateS3BucketDialog -import software.aws.toolkits.jetbrains.utils.lambdaTracingConfigIsAvailable -import software.aws.toolkits.jetbrains.utils.notifyError -import software.aws.toolkits.jetbrains.utils.notifyInfo -import software.aws.toolkits.jetbrains.utils.ui.blankAsNull -import software.aws.toolkits.jetbrains.utils.ui.selected -import software.aws.toolkits.jetbrains.utils.ui.validationInfo -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.LambdaTelemetry -import software.aws.toolkits.telemetry.Result -import java.awt.event.ActionEvent -import javax.swing.Action -import javax.swing.JComponent - -private val NOTIFICATION_TITLE = message("lambda.service_name") - -enum class EditFunctionMode { - NEW, UPDATE_CONFIGURATION, UPDATE_CODE -} - -class EditFunctionDialog( - private val project: Project, - private val mode: EditFunctionMode, - private val name: String = "", - private val description: String = "", - private val runtime: Runtime? = null, - private val handlerName: String = "", - private val envVariables: Map = emptyMap(), - private val timeout: Int = DEFAULT_TIMEOUT, - private val memorySize: Int = DEFAULT_MEMORY_SIZE, - private val xrayEnabled: Boolean = false, - private val role: IamRole? = null -) : DialogWrapper(project) { - - constructor(project: Project, lambdaFunction: LambdaFunction, mode: EditFunctionMode = UPDATE_CONFIGURATION) : - this( - project = project, - mode = mode, - name = lambdaFunction.name, - description = lambdaFunction.description ?: "", - runtime = lambdaFunction.runtime, - handlerName = lambdaFunction.handler, - envVariables = lambdaFunction.envVariables ?: emptyMap(), - timeout = lambdaFunction.timeout, - memorySize = lambdaFunction.memorySize, - xrayEnabled = lambdaFunction.xrayEnabled, - role = lambdaFunction.role - ) - - private val view = EditFunctionPanel(project) - private val validator = UploadToLambdaValidator() - private val s3Client: S3Client = project.awsClient() - private val iamClient: IamClient = project.awsClient() - - private val action: OkAction = when (mode) { - NEW -> CreateNewLambdaOkAction() - UPDATE_CONFIGURATION -> UpdateFunctionOkAction({ validator.validateConfigurationSettings(view) }, ::updateConfiguration) - UPDATE_CODE -> UpdateFunctionOkAction({ validator.validateCodeSettings(project, view) }, ::upsertLambdaCode) - } - - init { - super.init() - title = when (mode) { - NEW -> message("lambda.upload.create.title") - UPDATE_CONFIGURATION -> message("lambda.upload.updateConfiguration.title", name) - UPDATE_CODE -> message("lambda.upload.updateCode.title", name) - } - - view.name.text = name - - view.handlerPanel.handler.text = handlerName - view.timeoutSlider.value = timeout - view.memorySlider.value = memorySize - view.description.text = description - view.envVars.envVars = envVariables - - if (mode == UPDATE_CONFIGURATION) { - view.name.isEnabled = false - view.deploySettings.isVisible = false - view.buildSettings.isVisible = false - } else { - view.createBucket.addActionListener { - val bucketDialog = CreateS3BucketDialog( - project = project, - s3Client = s3Client, - parent = view.content - ) - - if (bucketDialog.showAndGet()) { - bucketDialog.bucketName().let { - view.sourceBucket.reload(forceFetch = true) - view.sourceBucket.selectedItem = it - } - } - } - } - - if (mode == UPDATE_CODE) { - UIUtil.uiChildren(view.configurationSettings) - .filter { it !== view.handlerPanel && it !== view.handlerLabel } - .forEach { it.isVisible = false } - } - - view.setRuntimes(Runtime.knownValues()) - view.runtime.selectedItem = runtime?.validOrNull - - view.xrayEnabled.isSelected = xrayEnabled - - val regionProvider = AwsRegionProvider.getInstance() - val settings = AwsConnectionManager.getInstance(project) - view.setXrayControlVisibility(mode != UPDATE_CODE && lambdaTracingConfigIsAvailable(settings.activeRegion)) - - view.iamRole.selectedItem = role - - view.createRole.addActionListener { - val iamRoleDialog = CreateIamRoleDialog( - project = project, - iamClient = iamClient, - parent = view.content, - defaultAssumeRolePolicyDocument = DEFAULT_ASSUME_ROLE_POLICY, - defaultPolicyDocument = DEFAULT_POLICY - ) - if (iamRoleDialog.showAndGet()) { - iamRoleDialog.iamRole?.let { newRole -> - view.iamRole.reload(forceFetch = true) - view.iamRole.selectedItem = newRole - } - } - } - } - - private fun configurationChanged(): Boolean = mode != NEW && !(name == view.name.text && - description == view.description.text && - runtime == view.runtime.selected() && - handlerName == view.handlerPanel.handler.text && - envVariables.entries == view.envVars.envVars.entries && - timeout == view.timeoutSlider.value && - memorySize == view.memorySlider.value && - xrayEnabled == view.xrayEnabled.isSelected && - role == view.iamRole.selected()) - - override fun createCenterPanel(): JComponent? = view.content - - override fun getPreferredFocusedComponent(): JComponent? = view.name - - override fun doValidate(): ValidationInfo? = when (mode) { - NEW -> validator.validateConfigurationSettings(view) ?: validator.validateCodeSettings(project, view) - UPDATE_CONFIGURATION -> validator.validateConfigurationSettings(view) - UPDATE_CODE -> validator.validateCodeSettings(project, view) - } - - override fun getOKAction(): Action = action - - override fun doCancelAction() { - LambdaTelemetry.editFunction(project, result = Result.Cancelled) - super.doCancelAction() - } - - override fun doOKAction() { - // Do nothing, close logic is handled separately - } - - override fun getHelpId(): String? = - when (mode) { - NEW -> HelpIds.CREATE_FUNCTION_DIALOG.id - UPDATE_CONFIGURATION -> HelpIds.UPDATE_FUNCTION_CONFIGURATION_DIALOG.id - UPDATE_CODE -> HelpIds.UPDATE_FUNCTION_CODE_DIALOG.id - } - - private fun upsertLambdaCode() { - if (!okAction.isEnabled) { - return - } - val functionDetails = viewToFunctionDetails() - val element = findPsiElementsForHandler(project, functionDetails.runtime, functionDetails.handler).first() - val psiFile = element.containingFile - val module = ModuleUtil.findModuleForFile(psiFile) ?: throw IllegalStateException("Failed to locate module for $psiFile") - - val s3Bucket = view.sourceBucket.selectedItem as String - - val lambdaBuilder = psiFile.language.runtimeGroup?.let { LambdaBuilder.getInstance(it) } ?: return - val lambdaCreator = LambdaCreatorFactory.create(AwsClientManager.getInstance(project), lambdaBuilder) - - FileDocumentManager.getInstance().saveAllDocuments() - - val (future, message) = if (mode == UPDATE_CODE) { - lambdaCreator.updateLambda(module, element, functionDetails, s3Bucket, configurationChanged()) to - message("lambda.function.code_updated.notification", functionDetails.name) - } else { - lambdaCreator.createLambda(module, element, functionDetails, s3Bucket) to - message("lambda.function.created.notification", functionDetails.name) - } - - future.whenComplete { _, error -> - when (error) { - null -> { - notifyInfo(title = NOTIFICATION_TITLE, content = message, project = project) - LambdaTelemetry.editFunction(project, update = false, result = Result.Succeeded) - } - is Exception -> { - error.notifyError(title = NOTIFICATION_TITLE) - LambdaTelemetry.editFunction(project, update = false, result = Result.Failed) - } - } - } - close(OK_EXIT_CODE) - } - - private fun updateConfiguration() { - if (okAction.isEnabled) { - - val functionDetails = viewToFunctionDetails() - val lambdaClient: LambdaClient = project.awsClient() - - ApplicationManager.getApplication().executeOnPooledThread { - LambdaFunctionCreator(lambdaClient).update(functionDetails) - .thenAccept { - notifyInfo( - title = NOTIFICATION_TITLE, - content = message("lambda.function.configuration_updated.notification", functionDetails.name) - ) - runInEdt(ModalityState.any()) { close(OK_EXIT_CODE) } - LambdaTelemetry.editFunction(project, update = true, result = Result.Succeeded) - }.exceptionally { error -> - setErrorText(ExceptionUtil.getNonEmptyMessage(error, error.toString())) - LambdaTelemetry.editFunction(project, update = true, result = Result.Failed) - null - } - } - } - } - - private fun viewToFunctionDetails(): FunctionUploadDetails = FunctionUploadDetails( - name = view.name.text!!, - handler = view.handlerPanel.handler.text, - iamRole = view.iamRole.selected()!!, - runtime = view.runtime.selected()!!, - description = view.description.text, - envVars = view.envVars.envVars, - timeout = view.timeoutSlider.value, - memorySize = view.memorySlider.value, - xrayEnabled = view.xrayEnabled.isSelected, - samOptions = SamOptions( - buildInContainer = view.buildInContainer.isSelected - ) - ) - - private inner class CreateNewLambdaOkAction : OkAction() { - init { - putValue(Action.NAME, message("lambda.upload.create.title")) - } - - override fun doAction(e: ActionEvent?) { - // We normally don't validate the deploy settings in case they are editing settings only, but they requested - // to deploy so start validating that too - super.doAction(e) - if (doValidateAll().isNotEmpty()) return - upsertLambdaCode() - } - } - - // Using an OkAction to force the validation logic to trigger as well - private inner class UpdateFunctionOkAction(private val validation: () -> ValidationInfo?, private val performUpdate: () -> Unit) : OkAction() { - init { - putValue(Action.NAME, message("lambda.upload.update_settings_button.title")) - } - - override fun doAction(e: ActionEvent?) { - super.doAction(e) - if (validation() == null) { - performUpdate() - } - } - } - - @TestOnly - fun getViewForTestAssertions() = view -} - -class UploadToLambdaValidator { - fun validateConfigurationSettings(view: EditFunctionPanel): ValidationInfo? { - val name = view.name.blankAsNull() ?: return ValidationInfo( - message("lambda.upload_validation.function_name"), - view.name - ) - validateFunctionName(name)?.run { return@validateConfigurationSettings ValidationInfo(this, view.name) } - view.handlerPanel.handler.text.nullize(true) ?: return ValidationInfo( - message("lambda.upload_validation.handler"), - view.handlerPanel.handler - ) - view.runtime.selected() ?: return ValidationInfo(message("lambda.upload_validation.runtime"), view.runtime) - view.iamRole.selected() ?: return view.iamRole.validationInfo(message("lambda.upload_validation.iam_role")) - - return view.timeoutSlider.validate() ?: view.memorySlider.validate() - } - - fun validateCodeSettings(project: Project, view: EditFunctionPanel): ValidationInfo? { - val handler = view.handlerPanel.handler.text - val runtime = view.runtime.selected() - ?: return ValidationInfo(message("lambda.upload_validation.runtime"), view.runtime) - - runtime.runtimeGroup?.let { LambdaBuilder.getInstance(it) } ?: return ValidationInfo( - message("lambda.upload_validation.unsupported_runtime", runtime), - view.runtime - ) - - findPsiElementsForHandler(project, runtime, handler).firstOrNull() ?: return ValidationInfo( - message("lambda.upload_validation.handler_not_found"), - view.handlerPanel.handler - ) - - view.sourceBucket.selected() ?: return view.sourceBucket.validationInfo(message("lambda.upload_validation.source_bucket")) - return null - } - - private fun validateFunctionName(name: String): String? { - if (!FUNCTION_NAME_PATTERN.matches(name)) { - return message("lambda.upload_validation.function_name_invalid") - } - if (name.length > 64) { - return message("lambda.upload_validation.function_name_too_long", 64) - } - return null - } - - companion object { - private val FUNCTION_NAME_PATTERN = "[a-zA-Z0-9-_]+".toRegex() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionPanel.form deleted file mode 100644 index f47dcaeed3..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionPanel.form +++ /dev/null @@ -1,238 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionPanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionPanel.java deleted file mode 100644 index e5ae7e534a..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/EditFunctionPanel.java +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.upload; - -import static software.aws.toolkits.resources.Localization.message; - -import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.ComboBox; -import com.intellij.ui.IdeBorderFactory; -import com.intellij.ui.SortedComboBoxModel; - -import java.util.Collection; -import java.util.Comparator; -import javax.swing.JButton; -import javax.swing.JCheckBox; -import javax.swing.JComboBox; -import javax.swing.JLabel; -import javax.swing.JPanel; -import javax.swing.JTextField; - -import org.jetbrains.annotations.NotNull; -import software.amazon.awssdk.services.lambda.model.Runtime; -import software.aws.toolkits.jetbrains.services.iam.IamResources; -import software.aws.toolkits.jetbrains.services.iam.IamRole; -import software.aws.toolkits.jetbrains.services.lambda.LambdaWidgets; -import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources; -import software.aws.toolkits.jetbrains.ui.EnvironmentVariablesTextField; -import software.aws.toolkits.jetbrains.ui.HandlerPanel; -import software.aws.toolkits.jetbrains.ui.ResourceSelector; -import software.aws.toolkits.jetbrains.ui.SliderPanel; - -@SuppressWarnings("NullableProblems") -public class EditFunctionPanel { - @NotNull JTextField name; - @NotNull JTextField description; - @NotNull HandlerPanel handlerPanel; - @NotNull JButton createRole; - @NotNull JButton createBucket; - @NotNull JPanel content; - @NotNull ResourceSelector iamRole; - @NotNull JComboBox runtime; - @NotNull ResourceSelector sourceBucket; - @NotNull EnvironmentVariablesTextField envVars; - @NotNull JPanel deploySettings; - @NotNull SliderPanel memorySlider; - @NotNull SliderPanel timeoutSlider; - @NotNull JPanel configurationSettings; - @NotNull JLabel handlerLabel; - @NotNull JCheckBox xrayEnabled; - @NotNull JPanel buildSettings; - @NotNull JCheckBox buildInContainer; - - private SortedComboBoxModel runtimeModel; - private Runtime lastSelectedRuntime = null; - private final Project project; - - EditFunctionPanel(Project project) { - this.project = project; - - deploySettings.setBorder(IdeBorderFactory.createTitledBorder(message("lambda.upload.deployment_settings"), false)); - configurationSettings.setBorder(IdeBorderFactory.createTitledBorder(message("lambda.upload.configuration_settings"), false)); - buildSettings.setBorder(IdeBorderFactory.createTitledBorder(message("lambda.upload.build_settings"), false)); - - runtime.addActionListener(e -> { - int index = runtime.getSelectedIndex(); - if (index < 0) return; - Runtime selectedRuntime = runtime.getItemAt(index); - if (selectedRuntime == lastSelectedRuntime) return; - lastSelectedRuntime = selectedRuntime; - handlerPanel.setRuntime(selectedRuntime); - }); - } - - public void setXrayControlVisibility(boolean visible) { - xrayEnabled.setVisible(visible); - - if (!visible) { - xrayEnabled.setSelected(false); - } - } - - private void createUIComponents() { - handlerPanel = new HandlerPanel(project); - runtimeModel = new SortedComboBoxModel<>(Comparator.comparing(Runtime::toString, Comparator.naturalOrder())); - runtime = new ComboBox<>(runtimeModel); - envVars = new EnvironmentVariablesTextField(); - memorySlider = LambdaWidgets.lambdaMemory(); - timeoutSlider = LambdaWidgets.lambdaTimeout(); - iamRole = ResourceSelector.builder(project).resource(IamResources.LIST_LAMBDA_ROLES).build(); - sourceBucket = ResourceSelector.builder(project).resource(S3Resources.listBucketNamesByActiveRegion(project)).build(); - } - - public void setRuntimes(Collection runtimes) { - runtimeModel.setAll(runtimes); - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/FunctionDetails.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/FunctionDetails.kt new file mode 100644 index 0000000000..67f6a801ba --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/FunctionDetails.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.lambda.upload + +import software.amazon.awssdk.services.lambda.LambdaClient +import software.amazon.awssdk.services.lambda.model.PackageType +import software.amazon.awssdk.services.lambda.model.Runtime +import software.amazon.awssdk.services.lambda.model.TracingMode +import software.amazon.awssdk.services.lambda.model.UpdateFunctionConfigurationResponse +import software.aws.toolkits.jetbrains.services.iam.IamRole + +data class FunctionDetails( + val name: String, + val description: String?, + val packageType: PackageType, + val handler: String?, + val iamRole: IamRole, + val runtime: Runtime?, + val envVars: Map, + val timeout: Int, + val memorySize: Int, + val xrayEnabled: Boolean +) { + val tracingMode: TracingMode = + if (xrayEnabled) { + TracingMode.ACTIVE + } else { + TracingMode.PASS_THROUGH + } +} + +fun LambdaClient.updateFunctionConfiguration(config: FunctionDetails): UpdateFunctionConfigurationResponse = this.updateFunctionConfiguration { + it.functionName(config.name) + it.description(config.description) + if (config.packageType == PackageType.ZIP) { + it.runtime(config.runtime) + it.handler(config.handler) + } + it.role(config.iamRole.arn) + it.timeout(config.timeout) + it.memorySize(config.memorySize) + it.environment { env -> + env.variables(config.envVars) + } + it.tracingConfig { tracing -> + tracing.mode(config.tracingMode) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaConfigPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaConfigPanel.form new file mode 100644 index 0000000000..c41a726c4b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaConfigPanel.form @@ -0,0 +1,200 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaConfigPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaConfigPanel.kt new file mode 100644 index 0000000000..d1f0013a48 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaConfigPanel.kt @@ -0,0 +1,179 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.TextBrowseFolderListener +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.SortedComboBoxModel +import com.intellij.util.io.isFile +import software.amazon.awssdk.services.lambda.model.PackageType +import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.services.iam.CreateIamRoleDialog +import software.aws.toolkits.jetbrains.services.iam.IamResources +import software.aws.toolkits.jetbrains.services.iam.IamRole +import software.aws.toolkits.jetbrains.services.lambda.LambdaWidgets.lambdaMemory +import software.aws.toolkits.jetbrains.services.lambda.LambdaWidgets.lambdaTimeout +import software.aws.toolkits.jetbrains.ui.HandlerPanel +import software.aws.toolkits.jetbrains.ui.KeyValueTextField +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.jetbrains.ui.ResourceSelector.Companion.builder +import software.aws.toolkits.jetbrains.ui.SliderPanel +import software.aws.toolkits.jetbrains.utils.lambdaTracingConfigIsAvailable +import software.aws.toolkits.jetbrains.utils.ui.validationInfo +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import java.nio.file.Paths +import java.util.function.Function +import javax.swing.JButton +import javax.swing.JCheckBox +import javax.swing.JComboBox +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JRadioButton + +class LambdaConfigPanel(private val project: Project, private val isUpdate: Boolean) : JPanel(BorderLayout()) { + lateinit var handlerPanel: HandlerPanel + private set + lateinit var createRole: JButton + private set + lateinit var iamRole: ResourceSelector + private set + lateinit var runtime: JComboBox + private set + lateinit var runtimeLabel: JLabel + private set + lateinit var envVars: KeyValueTextField + private set + lateinit var memorySlider: SliderPanel + private set + lateinit var timeoutSlider: SliderPanel + private set + lateinit var handlerLabel: JLabel + private set + lateinit var xrayEnabled: JCheckBox + private set + lateinit var packageZip: JRadioButton + private set + lateinit var packageImage: JRadioButton + private set + lateinit var dockerFileLabel: JLabel + private set + lateinit var dockerFile: TextFieldWithBrowseButton + private set + private lateinit var content: JPanel + + var runtimeModel = SortedComboBoxModel(compareBy(Comparator.naturalOrder(), Runtime::toString)) + private set + private var lastSelectedRuntime: Runtime? = null + + init { + runtimeModel.setAll(Runtime.knownValues()) + + runtime.model = runtimeModel + runtime.addActionListener { + val selectedRuntime = runtime.selectedItem as? Runtime? + if (selectedRuntime == lastSelectedRuntime) { + return@addActionListener + } + lastSelectedRuntime = selectedRuntime + handlerPanel.setRuntime(selectedRuntime) + } + + createRole.addActionListener { + val iamRoleDialog = CreateIamRoleDialog( + project = project, + iamClient = project.awsClient(), + parent = createRole, + defaultAssumeRolePolicyDocument = DEFAULT_LAMBDA_ASSUME_ROLE_POLICY, + defaultPolicyDocument = DEFAULT_POLICY + ) + if (iamRoleDialog.showAndGet()) { + iamRoleDialog.iamRole?.let { newRole -> + iamRole.reload(forceFetch = true) + iamRole.selectedItem { role -> role.arn == newRole.arn() } + } + } + } + + content.border = IdeBorderFactory.createTitledBorder(message("lambda.upload.configuration_settings"), false) + + packageZip.addChangeListener { + updateVisibility() + } + updateVisibility() + + dockerFile.addBrowseFolderListener(TextBrowseFolderListener(FileChooserDescriptorFactory.createSingleFileDescriptor())) + + setXrayControlVisibility(lambdaTracingConfigIsAvailable(AwsConnectionManager.getInstance(project).activeRegion)) + + add(content, BorderLayout.CENTER) + } + + private fun createUIComponents() { + handlerPanel = HandlerPanel(project) + runtimeModel = SortedComboBoxModel(Comparator.comparing(Function { obj: Runtime -> obj.toString() }, Comparator.naturalOrder())) + runtime = ComboBox(runtimeModel) + envVars = KeyValueTextField() + memorySlider = lambdaMemory() + timeoutSlider = lambdaTimeout() + iamRole = builder().resource(IamResources.LIST_LAMBDA_ROLES).awsConnection(project).build() + } + + private fun updateVisibility() { + val isZip = packageZip.isSelected + + handlerLabel.isVisible = isZip + handlerPanel.isVisible = isZip + handlerLabel.isVisible = isZip + + runtime.isVisible = isZip + runtimeLabel.isVisible = isZip + + // If updating, do not allow to set the docker file, todo: should we allow setting an ECR URI? + dockerFileLabel.isVisible = !isZip && !isUpdate + dockerFile.isVisible = !isZip && !isUpdate + } + + private fun setXrayControlVisibility(visible: Boolean) { + xrayEnabled.isVisible = visible + if (!visible) { + xrayEnabled.isSelected = false + } + } + + fun validatePanel(): ValidationInfo? { + when (packageType()) { + PackageType.ZIP -> { + // Check runtime first, since handler panel needs it to be there + runtimeModel.selectedItem ?: return runtime.validationInfo(message("lambda.upload_validation.runtime")) + + // When we do a create, we must make sure the we can find the handler, update we dont have to since we dont do a build + handlerPanel.validateHandler(handlerMustExist = !isUpdate)?.let { return it } + } + PackageType.IMAGE -> { + if (dockerFile.isVisible && (dockerFile.text.isEmpty() || !Paths.get(dockerFile.text).isFile())) { + return dockerFile.validationInfo(message("lambda.upload_validation.dockerfile_not_found")) + } + } + else -> { + throw IllegalStateException("Unsupported package type ${packageType()}") + } + } + + iamRole.selected() ?: return iamRole.validationInfo(message("lambda.upload_validation.iam_role")) + + return timeoutSlider.validate() ?: memorySlider.validate() + } + + fun packageType(): PackageType = when { + packageZip.isSelected -> PackageType.ZIP + else -> PackageType.IMAGE + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaCreator.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaCreator.kt deleted file mode 100644 index 8bf3e25de3..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaCreator.kt +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.lambda.upload - -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.module.Module -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiElement -import software.amazon.awssdk.services.lambda.LambdaClient -import software.amazon.awssdk.services.lambda.model.CreateFunctionRequest -import software.amazon.awssdk.services.lambda.model.FunctionCode -import software.amazon.awssdk.services.lambda.model.UpdateFunctionCodeRequest -import software.amazon.awssdk.services.lambda.model.UpdateFunctionConfigurationRequest -import software.amazon.awssdk.services.s3.S3Client -import software.aws.toolkits.core.ToolkitClientManager -import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder -import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilderUtils -import software.aws.toolkits.jetbrains.services.lambda.LambdaFunction -import software.aws.toolkits.jetbrains.services.lambda.PackageLambdaFromHandler -import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup -import software.aws.toolkits.jetbrains.services.lambda.toDataClass -import software.aws.toolkits.jetbrains.services.s3.upload -import software.aws.toolkits.resources.message -import java.nio.file.Path -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CompletionStage - -object LambdaCreatorFactory { - fun create(clientManager: ToolkitClientManager, builder: LambdaBuilder): LambdaCreator = LambdaCreator( - builder, - CodeUploader(clientManager.getClient()), - LambdaFunctionCreator(clientManager.getClient()) - ) -} - -class LambdaCreator internal constructor( - private val builder: LambdaBuilder, - private val uploader: CodeUploader, - private val functionCreator: LambdaFunctionCreator -) { - fun createLambda( - module: Module, - handler: PsiElement, - functionDetails: FunctionUploadDetails, - s3Bucket: String - ): CompletionStage = packageLambda(handler, functionDetails, module, builder) - .thenCompose { uploader.upload(functionDetails, it, s3Bucket, module.project) } - .thenCompose { functionCreator.create(functionDetails, it) } - - fun updateLambda( - module: Module, - handler: PsiElement, - functionDetails: FunctionUploadDetails, - s3Bucket: String, - replaceConfiguration: Boolean = true - ): CompletionStage = packageLambda(handler, functionDetails, module, builder) - .thenCompose { uploader.upload(functionDetails, it, s3Bucket, module.project) } - .thenCompose { functionCreator.update(functionDetails, it, replaceConfiguration) } - - private fun packageLambda( - handler: PsiElement, - functionDetails: FunctionUploadDetails, - module: Module, - builder: LambdaBuilder - ): CompletionStage { - val request = PackageLambdaFromHandler( - handler, - functionDetails.handler, - functionDetails.runtime, - functionDetails.samOptions - ) - - // We should never hit this point since validation logic of the UI should validate this cant be null - val runtimeGroup = functionDetails.runtime.runtimeGroup - ?: throw IllegalArgumentException("RuntimeGroup not defined for ${functionDetails.runtime}") - - return LambdaBuilderUtils.packageAndReport(module, runtimeGroup, request, builder) - } -} - -class LambdaFunctionCreator(private val lambdaClient: LambdaClient) { - fun create(details: FunctionUploadDetails, uploadedCode: UploadedCode): CompletionStage { - val future = CompletableFuture() - ApplicationManager.getApplication().executeOnPooledThread { - try { - val code = FunctionCode.builder().s3Bucket(uploadedCode.bucket).s3Key(uploadedCode.key) - uploadedCode.version?.run { code.s3ObjectVersion(this) } - val req = CreateFunctionRequest.builder() - .handler(details.handler) - .functionName(details.name) - .role(details.iamRole.arn) - .runtime(details.runtime) - .description(details.description) - .timeout(details.timeout) - .memorySize(details.memorySize) - .code(code.build()) - .environment { - it.variables(details.envVars) - } - .tracingConfig { - it.mode(details.tracingMode) - } - .build() - - val result = lambdaClient.createFunction(req) - future.complete(result.toDataClass()) - } catch (e: Exception) { - future.completeExceptionally(e) - } - } - return future - } - - fun update(details: FunctionUploadDetails, uploadedCode: UploadedCode, replaceConfiguration: Boolean): CompletionStage { - val future = CompletableFuture() - ApplicationManager.getApplication().executeOnPooledThread { - try { - val req = UpdateFunctionCodeRequest.builder() - .functionName(details.name) - .s3Bucket(uploadedCode.bucket) - .s3Key(uploadedCode.key) - - uploadedCode.version?.let { version -> req.s3ObjectVersion(version) } - - lambdaClient.updateFunctionCode(req.build()) - if (replaceConfiguration) { - updateInternally(details) - } - future.complete(null) - } catch (e: Exception) { - future.completeExceptionally(e) - } - } - return future - } - - fun update(details: FunctionUploadDetails): CompletionStage { - val future = CompletableFuture() - ApplicationManager.getApplication().executeOnPooledThread { - try { - updateInternally(details) - future.complete(null) - } catch (e: Exception) { - future.completeExceptionally(e) - } - } - return future - } - - private fun updateInternally(details: FunctionUploadDetails) { - val req = UpdateFunctionConfigurationRequest.builder() - .handler(details.handler) - .functionName(details.name) - .role(details.iamRole.arn) - .runtime(details.runtime) - .description(details.description) - .timeout(details.timeout) - .memorySize(details.memorySize) - .environment { - it.variables(details.envVars) - } - .tracingConfig { - it.mode(details.tracingMode) - } - .build() - - lambdaClient.updateFunctionConfiguration(req) - } -} - -class CodeUploader(private val s3Client: S3Client) { - fun upload( - functionDetails: FunctionUploadDetails, - code: Path, - s3Bucket: String, - project: Project - ): CompletionStage { - val key = "${functionDetails.name}.zip" - return s3Client.upload(project, code, s3Bucket, key, message = message("lambda.create.uploading"), startInBackground = true).thenApply { result -> - UploadedCode(s3Bucket, key, result.versionId()) - } - } -} - -data class UploadedCode(val bucket: String, val key: String, val version: String?) 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 a3f624d45e..c6d6c25a90 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 @@ -3,7 +3,6 @@ package software.aws.toolkits.jetbrains.services.lambda.upload -import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer import com.intellij.codeInsight.daemon.LineMarkerInfo import com.intellij.codeInsight.daemon.LineMarkerProviderDescriptor import com.intellij.execution.lineMarker.ExecutorAction @@ -11,7 +10,6 @@ import com.intellij.execution.lineMarker.LineMarkerActionWrapper import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.application.runReadAction import com.intellij.openapi.editor.markup.GutterIconRenderer import com.intellij.openapi.project.Project import com.intellij.psi.PsiElement @@ -19,23 +17,21 @@ import com.intellij.psi.PsiFile 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.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 import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup -import software.aws.toolkits.jetbrains.services.lambda.resources.LambdaResources import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup import software.aws.toolkits.jetbrains.settings.LambdaSettings +import software.aws.toolkits.jetbrains.utils.isTestOrInjectedText import software.aws.toolkits.resources.message import javax.swing.Icon class LambdaLineMarker : LineMarkerProviderDescriptor() { - override fun getName(): String? = message("lambda.service_name") + override fun getName(): String = message("lambda.service_name") - override fun getIcon(): Icon? = AwsIcons.Resources.LAMBDA_FUNCTION + override fun getIcon(): Icon = AwsIcons.Resources.LAMBDA_FUNCTION override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? { // Only process leaf elements @@ -44,7 +40,12 @@ class LambdaLineMarker : LineMarkerProviderDescriptor() { } val runtimeGroup = element.language.runtimeGroup ?: return null - val handlerResolver = LambdaHandlerResolver.getInstance(runtimeGroup) ?: return null + + if (element.isTestOrInjectedText()) { + return null + } + + val handlerResolver = LambdaHandlerResolver.getInstanceOrNull(runtimeGroup) ?: return null val handler = handlerResolver.determineHandler(element) ?: return null return if (handlerResolver.shouldShowLineMarker(handler) || shouldShowLineMarker(element.containingFile, handler, runtimeGroup)) { @@ -52,13 +53,13 @@ class LambdaLineMarker : LineMarkerProviderDescriptor() { val smartPsiElementPointer = SmartPointerManager.createPointer(element) - if (element.language in LambdaBuilder.supportedLanguages) { + if (element.language in LambdaBuilder.supportedLanguages()) { val executorActions = ExecutorAction.getActions(1) executorActions.forEach { actionGroup.add(LineMarkerActionWrapper(element, it)) } - actionGroup.add(CreateLambdaFunction(handler, smartPsiElementPointer, handlerResolver)) + actionGroup.add(CreateLambdaFunctionAction(handler, smartPsiElementPointer, handlerResolver)) } object : LineMarkerInfo( @@ -69,45 +70,27 @@ class LambdaLineMarker : LineMarkerProviderDescriptor() { null, GutterIconRenderer.Alignment.CENTER ) { - override fun createGutterRenderer(): GutterIconRenderer? = LambdaGutterIcon(this, actionGroup) + override fun createGutterRenderer(): GutterIconRenderer = LambdaGutterIcon(this, actionGroup) } - } else null + } else { + null + } } private fun shouldShowLineMarker(psiFile: PsiFile, handler: String, runtimeGroup: RuntimeGroup): Boolean { val project = psiFile.project return LambdaSettings.getInstance(project).showAllHandlerGutterIcons || - handlerInTemplate(project, handler, runtimeGroup) || - handlerInRemote(psiFile, handler, runtimeGroup) + handlerInTemplate(project, handler, runtimeGroup) } // Handler defined in template with the same runtime group is valid private fun handlerInTemplate(project: Project, handler: String, runtimeGroup: RuntimeGroup): Boolean = listFunctions(project).any { - it.handler() == handler && Runtime.fromValue(it.runtime())?.runtimeGroup == runtimeGroup - } - - // Handler defined in remote Lambda with the same runtime group is valid - private fun handlerInRemote(psiFile: PsiFile, handler: String, runtimeGroup: RuntimeGroup): Boolean { - if (!AwsConnectionManager.getInstance(psiFile.project).isValidConnectionSettings()) { - return false + Runtime.fromValue(it.runtime())?.runtimeGroup == runtimeGroup && + // if user has a custom makefile, assume they know what they're doing with the handler since we don't have enough information + (it.handler() == handler || it.buildMethod() == "makefile") } - val cache = AwsResourceCache.getInstance(psiFile.project) - - return when (val functions = cache.getResourceIfPresent(LambdaResources.LIST_FUNCTIONS)) { - null -> { - cache.getResource(LambdaResources.LIST_FUNCTIONS).whenComplete { _, _ -> - runReadAction { - DaemonCodeAnalyzer.getInstance(psiFile.project).restart(psiFile) - } - } - false - } - else -> functions.any { it.handler() == handler && it.runtime().runtimeGroup == runtimeGroup } - } - } - class LambdaGutterIcon(markerInfo: LineMarkerInfo, private val actionGroup: ActionGroup) : LineMarkerInfo.LineMarkerGutterIconRenderer(markerInfo) { override fun getClickAction(): AnAction? = null diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaPolicies.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaPolicies.kt index f3f1256241..252a2de0de 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaPolicies.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/LambdaPolicies.kt @@ -8,35 +8,56 @@ import org.intellij.lang.annotations.Language const val LAMBDA_PRINCIPAL = "lambda.amazonaws.com" @Language("JSON") -val DEFAULT_ASSUME_ROLE_POLICY = """ -{ - "Version": "2012-10-17", - "Statement": [ +val DEFAULT_LAMBDA_ASSUME_ROLE_POLICY = + """ { - "Effect": "Allow", - "Principal": { - "Service": "lambda.amazonaws.com" - }, - "Action": "sts:AssumeRole" + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] } - ] -} -""".trim() + """.trim() @Language("JSON") -val DEFAULT_POLICY = """ -{ - "Version": "2012-10-17", - "Statement": [ +val DEFAULT_POLICY = + """ { - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ], - "Resource": "*" + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "*" + } + ] } - ] -} -""".trim() + """.trim() + +@Language("JSON") +fun createSqsPollerPolicy(arn: String): String = + """ + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:ReceiveMessage" + ], + "Resource": "$arn" + } + ] + } + """.trim() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionCodeDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionCodeDialog.kt new file mode 100644 index 0000000000..f39d438459 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionCodeDialog.kt @@ -0,0 +1,187 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.module.ModuleUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.util.text.nullize +import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.services.lambda.Lambda.findPsiElementsForHandler +import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder +import software.aws.toolkits.jetbrains.services.lambda.LambdaFunction +import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import software.aws.toolkits.jetbrains.services.lambda.sam.SamOptions +import software.aws.toolkits.jetbrains.services.lambda.steps.updateLambdaCodeWorkflowForImage +import software.aws.toolkits.jetbrains.services.lambda.steps.updateLambdaCodeWorkflowForZip +import software.aws.toolkits.jetbrains.settings.UpdateLambdaSettings +import software.aws.toolkits.jetbrains.utils.execution.steps.BuildViewWorkflowEmitter +import software.aws.toolkits.jetbrains.utils.execution.steps.StepExecutor +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.LambdaPackageType +import software.aws.toolkits.telemetry.LambdaTelemetry +import software.aws.toolkits.telemetry.Result +import java.nio.file.Paths +import javax.swing.JComponent + +class UpdateFunctionCodeDialog(private val project: Project, private val initialSettings: LambdaFunction) : DialogWrapper(project) { + private val view = UpdateFunctionCodePanel(project, initialSettings.packageType) + private val updateSettings = UpdateLambdaSettings.getInstance(initialSettings.arn) + + init { + super.init() + title = message("lambda.upload.updateCode.title", initialSettings.name) + setOKButtonText(message("general.update_button")) + + initialSettings.handler?.let { + view.handlerPanel.handler.text = it + } + view.handlerPanel.setRuntime(initialSettings.runtime) + + loadSettings() + } + + override fun createCenterPanel(): JComponent = view.content + + override fun getPreferredFocusedComponent(): JComponent = view.handlerPanel.handler + + override fun doValidate(): ValidationInfo? = view.validatePanel() + + override fun doCancelAction() { + LambdaTelemetry.deploy( + project, + result = Result.Cancelled, + lambdaPackageType = LambdaPackageType.from(initialSettings.packageType.toString()), + initialDeploy = false + ) + super.doCancelAction() + } + + override fun doOKAction() { + saveSettings() + upsertLambdaCode() + } + + override fun getHelpId(): String = HelpIds.UPDATE_FUNCTION_CODE_DIALOG.id + + private fun upsertLambdaCode() { + if (!okAction.isEnabled) { + return + } + + val workflow = createWorkflow() + + workflow.onSuccess = { + notifyInfo( + project = project, + title = message("lambda.service_name"), + content = message("lambda.function.code_updated.notification", initialSettings.name) + ) + LambdaTelemetry.deploy( + project, + result = Result.Succeeded, + lambdaPackageType = LambdaPackageType.from(initialSettings.packageType.toString()), + initialDeploy = false + ) + } + + workflow.onError = { + it.notifyError(project = project, title = message("lambda.service_name")) + LambdaTelemetry.deploy( + project, + result = Result.Failed, + lambdaPackageType = LambdaPackageType.from(initialSettings.packageType.toString()), + initialDeploy = false + ) + } + + workflow.startExecution() + + close(OK_EXIT_CODE) + } + + @TestOnly + fun createWorkflow(): StepExecutor { + FileDocumentManager.getInstance().saveAllDocuments() + + val samOptions = SamOptions( + buildInContainer = view.buildSettings.buildInContainerCheckbox.isSelected + ) + + val workflow = when (val packageType = initialSettings.packageType) { + PackageType.ZIP -> { + val runtime = initialSettings.runtime ?: throw IllegalStateException("Runtime is missing when package type is Zip") + val handler = view.handlerPanel.handler.text + + // TODO: Move this so we can share it with CreateFunctionDialog, but don't move it lower since passing PsiElement lower needs to go away since + // it is causing customer complaints. We need to prompt for baseDir and try to infer it if we can but only as a default value... + val element = findPsiElementsForHandler(project, runtime, handler).first() + val module = ModuleUtil.findModuleForPsiElement(element) ?: throw IllegalStateException("Failed to locate module for $element") + val lambdaBuilder = initialSettings.runtime.runtimeGroup?.let { LambdaBuilder.getInstanceOrNull(it) } + ?: throw IllegalStateException("LambdaBuilder for ${initialSettings.runtime} not found") + + val codeDetails = ZipBasedCode( + baseDir = lambdaBuilder.handlerBaseDirectory(module, element), + handler = handler, + runtime = runtime + ) + + updateLambdaCodeWorkflowForZip( + project = project, + functionName = initialSettings.name, + codeDetails = codeDetails, + buildDir = lambdaBuilder.getBuildDirectory(module), + buildEnvVars = lambdaBuilder.additionalBuildEnvironmentVariables(project, module, samOptions), + codeStorageLocation = view.codeStorage.codeLocation(), + samOptions = samOptions, + updatedHandler = handler.takeIf { it != initialSettings.handler } + ) + } + PackageType.IMAGE -> { + val codeDetails = ImageBasedCode( + dockerfile = Paths.get(view.dockerFile.text) + ) + + updateLambdaCodeWorkflowForImage( + project = project, + functionName = initialSettings.name, + codeDetails = codeDetails, + codeStorageLocation = view.codeStorage.codeLocation(), + samOptions = samOptions + ) + } + else -> throw UnsupportedOperationException("$packageType is not supported") + } + + val emitter = BuildViewWorkflowEmitter.createEmitter(project, message("lambda.workflow.update_code.name"), initialSettings.name) + return StepExecutor(project, workflow, emitter) + } + + private fun loadSettings() { + view.codeStorage.sourceBucket.selectedItem = updateSettings.bucketName + updateSettings.ecrRepo?.let { savedArn -> + view.codeStorage.ecrRepo.selectedItem { it.repositoryArn == savedArn } + } + updateSettings.dockerfile?.let { + view.dockerFile.text = it + } + view.buildSettings.buildInContainerCheckbox.isSelected = updateSettings.useContainer ?: false + } + + private fun saveSettings() { + updateSettings.bucketName = view.codeStorage.sourceBucket.selected() + updateSettings.ecrRepo = view.codeStorage.ecrRepo.selected()?.repositoryArn + updateSettings.dockerfile = view.dockerFile.text.nullize() + updateSettings.useContainer = view.buildSettings.buildInContainerCheckbox.isSelected + } + + @TestOnly + fun getViewForTestAssertions() = view +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionCodePanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionCodePanel.form new file mode 100644 index 0000000000..4b9ce2e611 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionCodePanel.form @@ -0,0 +1,77 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionCodePanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionCodePanel.kt new file mode 100644 index 0000000000..6dbf89c48d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionCodePanel.kt @@ -0,0 +1,78 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.TextBrowseFolderListener +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.IdeBorderFactory +import com.intellij.util.io.isFile +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.jetbrains.ui.HandlerPanel +import software.aws.toolkits.jetbrains.utils.ui.validationInfo +import software.aws.toolkits.resources.message +import java.nio.file.Paths +import javax.swing.JLabel +import javax.swing.JPanel + +class UpdateFunctionCodePanel internal constructor(private val project: Project, private val packageType: PackageType) { + lateinit var content: JPanel + private set + lateinit var buildSettings: BuildSettingsPanel + private set + lateinit var codeStorage: CodeStoragePanel + private set + lateinit var handlerLabel: JLabel + private set + lateinit var handlerPanel: HandlerPanel + private set + lateinit var dockerFileLabel: JLabel + private set + lateinit var dockerFile: TextFieldWithBrowseButton + private set + private lateinit var lambdaConfigurationPanel: JPanel + + init { + dockerFile.addBrowseFolderListener(TextBrowseFolderListener(FileChooserDescriptorFactory.createSingleFileDescriptor())) + + updateVisibility() + + lambdaConfigurationPanel.border = IdeBorderFactory.createTitledBorder(message("lambda.upload.configuration_settings"), false) + } + + private fun createUIComponents() { + codeStorage = CodeStoragePanel(project) + handlerPanel = HandlerPanel(project) + } + + private fun updateVisibility() { + val isZip = packageType == PackageType.ZIP + + handlerLabel.isVisible = isZip + handlerPanel.isVisible = isZip + + dockerFileLabel.isVisible = !isZip + dockerFile.isVisible = !isZip + + codeStorage.packagingType = packageType + buildSettings.packagingType = packageType + } + + fun validatePanel(): ValidationInfo? = when (packageType) { + PackageType.ZIP -> { + handlerPanel.validateHandler(handlerMustExist = true) ?: codeStorage.validatePanel() + } + PackageType.IMAGE -> { + if (dockerFile.text.isEmpty() || !Paths.get(dockerFile.text).isFile()) { + dockerFile.validationInfo(message("lambda.upload_validation.dockerfile_not_found")) + } else { + codeStorage.validatePanel() + } + } + else -> { + throw IllegalStateException("Unsupported package type $packageType") + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionConfigDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionConfigDialog.kt new file mode 100644 index 0000000000..c8abc810e1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionConfigDialog.kt @@ -0,0 +1,129 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.util.ExceptionUtil +import software.amazon.awssdk.services.lambda.LambdaClient +import software.amazon.awssdk.services.lambda.model.PackageType +import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.services.lambda.LambdaFunction +import software.aws.toolkits.jetbrains.services.lambda.waitForUpdatableState +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.LambdaPackageType +import software.aws.toolkits.telemetry.LambdaTelemetry +import software.aws.toolkits.telemetry.Result +import javax.swing.JComponent + +class UpdateFunctionConfigDialog(private val project: Project, private val initialSettings: LambdaFunction) : DialogWrapper(project) { + private val view = UpdateFunctionConfigPanel(project) + + init { + super.init() + title = message("lambda.upload.updateConfiguration.title", initialSettings.name) + setOKButtonText(message("general.update_button")) + + view.name.text = initialSettings.name + view.description.text = initialSettings.description + + with(view.configSettings) { + if (initialSettings.packageType == PackageType.IMAGE) { + packageImage.isSelected = true + } else { + packageZip.isSelected + runtimeModel.selectedItem = initialSettings.runtime + handlerPanel.setRuntime(initialSettings.runtime) + initialSettings.handler?.let { + handlerPanel.handler.text = initialSettings.handler + } + } + envVars.envVars = initialSettings.envVariables ?: emptyMap() + timeoutSlider.value = initialSettings.timeout + memorySlider.value = initialSettings.memorySize + iamRole.selectedItem = initialSettings.role + xrayEnabled.isSelected = initialSettings.xrayEnabled + } + } + + override fun createCenterPanel(): JComponent = view.content + + override fun getPreferredFocusedComponent(): JComponent = view.configSettings.handlerPanel.handler + + override fun doValidate(): ValidationInfo? = view.validatePanel() + + override fun doCancelAction() { + LambdaTelemetry.editFunction( + project, + update = true, + lambdaPackageType = LambdaPackageType.from(view.configSettings.packageType().toString()), + result = Result.Cancelled + ) + super.doCancelAction() + } + + override fun doOKAction() { + if (!isOKActionEnabled) { + return + } + + setOKButtonText(message("general.in_progress_button")) + isOKActionEnabled = false + + val functionDetails = viewToFunctionDetails() + val lambdaClient: LambdaClient = project.awsClient() + + ApplicationManager.getApplication().executeOnPooledThread { + try { + lambdaClient.waitForUpdatableState(functionDetails.name) + lambdaClient.updateFunctionConfiguration(functionDetails) + + notifyInfo( + project = project, + title = message("lambda.service_name"), + content = message("lambda.function.configuration_updated.notification", functionDetails.name) + ) + runInEdt(ModalityState.any()) { close(OK_EXIT_CODE) } + LambdaTelemetry.editFunction( + project, + update = true, + lambdaPackageType = LambdaPackageType.from(functionDetails.packageType.toString()), + result = Result.Succeeded + ) + } catch (e: Exception) { + setErrorText(ExceptionUtil.getNonEmptyMessage(e, ExceptionUtil.getNonEmptyMessage(e, e::class.java.simpleName))) + LambdaTelemetry.editFunction( + project, + update = true, + lambdaPackageType = LambdaPackageType.from(functionDetails.packageType.toString()), + result = Result.Failed + ) + setOKButtonText(message("general.update_button")) + isOKActionEnabled = true + } + } + } + + private fun viewToFunctionDetails(): FunctionDetails = FunctionDetails( + name = initialSettings.name, + description = view.description.text, + packageType = view.configSettings.packageType(), + runtime = if (view.configSettings.packageType() == PackageType.ZIP) view.configSettings.runtime.selectedItem as Runtime else null, + handler = view.configSettings.handlerPanel.handler.text, + iamRole = view.configSettings.iamRole.selected()!!, + envVars = view.configSettings.envVars.envVars, + timeout = view.configSettings.timeoutSlider.value, + memorySize = view.configSettings.memorySlider.value, + xrayEnabled = view.configSettings.xrayEnabled.isSelected + ) + + override fun getHelpId(): String = HelpIds.UPDATE_FUNCTION_CONFIGURATION_DIALOG.id +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionConfigPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionConfigPanel.kt new file mode 100644 index 0000000000..d74a6ebcff --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionConfigPanel.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.lambda.upload + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.IdeBorderFactory +import software.aws.toolkits.resources.message +import javax.swing.JPanel +import javax.swing.JTextField + +class UpdateFunctionConfigPanel(private val project: Project) { + lateinit var content: JPanel + private set + lateinit var name: JTextField + private set + lateinit var description: JTextField + private set + lateinit var configSettings: LambdaConfigPanel + private set + + init { + configSettings.border = IdeBorderFactory.createTitledBorder(message("lambda.upload.configuration_settings"), false) + } + + private fun createUIComponents() { + configSettings = LambdaConfigPanel(project, isUpdate = true) + } + + fun validatePanel(): ValidationInfo? = configSettings.validatePanel() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionConfigurationPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionConfigurationPanel.form new file mode 100644 index 0000000000..324b1ec669 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UpdateFunctionConfigurationPanel.form @@ -0,0 +1,61 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UploadFunctionContinueDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UploadFunctionContinueDialog.kt new file mode 100644 index 0000000000..faa2975ce0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/upload/UploadFunctionContinueDialog.kt @@ -0,0 +1,30 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.upload + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.ui.layout.panel +import software.aws.toolkits.resources.message +import javax.swing.JComponent + +// TODO we can add `Filter`s to the step executor which will allow us to take +// actions on certain text, so we can put it in the output window to click to continue +class UploadFunctionContinueDialog(private val project: Project, private val changeSet: String) : DialogWrapper(project) { + init { + super.init() + title = message("serverless.application.deploy.change_set.title") + setOKButtonText(message("serverless.application.deploy.execute_change_set")) + setCancelButtonText(message("general.close_button")) + } + + override fun createCenterPanel(): JComponent = panel { + row { + JBLabel(message("serverless.application.deploy.change_set"))() + JBTextField(changeSet).apply { this.isEditable = false }() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/IntelliJSdkSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/IntelliJSdkSelectionPanel.kt new file mode 100644 index 0000000000..a30db07b34 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/IntelliJSdkSelectionPanel.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.lambda.wizard + +import com.intellij.ide.util.projectWizard.EmptyModuleBuilder +import com.intellij.ide.util.projectWizard.SdkSettingsStep +import com.intellij.ide.util.projectWizard.WizardContext +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.ui.ValidationInfo +import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup +import software.aws.toolkits.jetbrains.utils.ui.validationInfo +import software.aws.toolkits.resources.message +import javax.swing.JComponent +import javax.swing.JLabel + +class IntelliJSdkSelectionPanel(private val runtimeGroupId: String) : SdkSelector { + private val dummyContext = object : WizardContext(null, {}) {} + private val currentSdkPanel: SdkSettingsStep = buildSdkSettingsPanel() + + private var currentSdk: Sdk? = null + + override fun sdkSelectionPanel(): JComponent = currentSdkPanel.component + + override fun sdkSelectionLabel(): JLabel? = JLabel(message("sam.init.sdk.label")) + + override fun validateSelection(): ValidationInfo? { + if (!currentSdkPanel.validate()) { + return currentSdkPanel.component.validationInfo(message("sam.init.sdk.error")) + } + return null + } + + override fun getSdk(): Sdk? = currentSdk + + // don't validate on init of the SettingsStep or weird things will happen if the user has no SDK + private fun buildSdkSettingsPanel(): SdkSettingsStep = + object : SdkSettingsStep( + dummyContext, + EmptyModuleBuilder(), // not used + { it == RuntimeGroup.getById(runtimeGroupId).getIdeSdkType() }, + null + ) { + override fun onSdkSelected(sdk: Sdk?) { + currentSdk = sdk + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitProjectBuilderCommon.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitProjectBuilderCommon.kt new file mode 100644 index 0000000000..d95e61c54b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitProjectBuilderCommon.kt @@ -0,0 +1,65 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +@file:JvmName("SamInitProjectBuilderCommon") + +package software.aws.toolkits.jetbrains.services.lambda.wizard + +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.project.DefaultProjectFactory +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.executables.getExecutableIfPresent +import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable +import software.aws.toolkits.jetbrains.settings.AwsSettingsConfigurable +import software.aws.toolkits.resources.message +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JTextField + +@JvmOverloads +fun setupSamSelectionElements(samExecutableField: JTextField, editButton: JButton, label: JComponent, postEditCallback: Runnable? = null) { + fun getSamExecutable(): ExecutableInstance.ExecutableWithPath? = + ExecutableManager.getInstance().getExecutableIfPresent().let { it as? ExecutableInstance.ExecutableWithPath } + + fun updateUi(validSamPath: Boolean) { + runInEdt(ModalityState.any()) { + samExecutableField.isVisible = !validSamPath + editButton.isVisible = !validSamPath + label.isVisible = !validSamPath + } + } + + samExecutableField.text = getSamExecutable()?.executablePath?.toString() + + editButton.addActionListener { + ShowSettingsUtil.getInstance().showSettingsDialog(DefaultProjectFactory.getInstance().defaultProject, AwsSettingsConfigurable::class.java) + samExecutableField.text = getSamExecutable()?.executablePath?.toString() + postEditCallback?.run() + } + + val toolTipText = message("aws.settings.find.description", "SAM") + label.toolTipText = toolTipText + samExecutableField.toolTipText = toolTipText + editButton.toolTipText = toolTipText + + ProgressManager.getInstance().runProcessWithProgressSynchronously( + { + try { + val validSamPath = when (ExecutableManager.getInstance().getExecutable().toCompletableFuture().get()) { + is ExecutableInstance.Executable -> true + else -> false + } + updateUi(validSamPath) + } catch (e: Throwable) { + updateUi(validSamPath = false) + } + }, + message("lambda.run_configuration.sam.validating"), + false, + null + ) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitRunner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitRunner.kt new file mode 100644 index 0000000000..26f5163ea0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitRunner.kt @@ -0,0 +1,79 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.wizard + +import com.fasterxml.jackson.module.kotlin.convertValue +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +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 +import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable +import software.aws.toolkits.jetbrains.services.lambda.sam.samInitCommand +import software.aws.toolkits.jetbrains.services.schemas.SchemaTemplateParameters +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SamTelemetry + +object SamInitRunner { + private val LOG = getLogger() + + fun execute( + outputDir: VirtualFile, + templateParameters: TemplateParameters, + schemaParameters: SchemaTemplateParameters? + ) { + // set output to a temp dir + val tempDir = createTempDir() + + ExecutableManager.getInstance().getExecutable().thenApply { + val samExecutable = when (it) { + is ExecutableInstance.Executable -> it + else -> { + SamTelemetry.init( + result = Result.Failed, + reason = "InvalidSamCli" + ) + throw RuntimeException((it as? ExecutableInstance.BadExecutable)?.validationError) + } + } + + val extraContent = if (schemaParameters?.templateExtraContext != null) { + jacksonObjectMapper().convertValue>(schemaParameters.templateExtraContext) + } else { + emptyMap() + } + + val commandLine = samExecutable.getCommandLine().samInitCommand( + tempDir.toPath(), + templateParameters, + extraContent + ) + + LOG.info { "Running SAM command ${commandLine.commandLineString}" } + + val process = CapturingProcessHandler(commandLine).runProcess() + if (process.exitCode != 0) { + throw RuntimeException("${message("sam.init.execution_error")}: ${process.stderrLines}") + } else { + LOG.info { "SAM init output stdout:\n${process.stdout}" } + LOG.info { "SAM init output stderr:\n${process.stderr}" } + } + + val subFolders = tempDir.listFiles()?.toList() ?: emptyList() + + assert(subFolders.size == 1 && subFolders.first().isDirectory) { + message("sam.init.error.subfolder_not_one", tempDir.name) + } + + FileUtil.copyDirContent(subFolders.first(), VfsUtil.virtualToIoFile(outputDir)) + FileUtil.delete(tempDir) + }.toCompletableFuture().join() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitSelectionPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitSelectionPanel.form new file mode 100644 index 0000000000..282285d58c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitSelectionPanel.form @@ -0,0 +1,162 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitSelectionPanel.kt new file mode 100644 index 0000000000..2b57dac11f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamInitSelectionPanel.kt @@ -0,0 +1,229 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.lambda.wizard + +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.text.SemVer +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.core.lambda.LambdaArchitecture +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance.BadExecutable +import software.aws.toolkits.jetbrains.core.executables.ExecutableManager +import software.aws.toolkits.jetbrains.core.executables.ExecutableType.Companion.getExecutable +import software.aws.toolkits.jetbrains.services.lambda.minSamInitVersion +import software.aws.toolkits.jetbrains.services.lambda.minSamVersion +import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon +import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable +import software.aws.toolkits.jetbrains.utils.ui.validationInfo +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JRadioButton +import javax.swing.JTextField + +class SamInitSelectionPanel( + wizardFragmentList: List, + private val projectLocation: TextFieldWithBrowseButton? = null, /* Only available in PyCharm! */ + private val runtimeFilter: (LambdaRuntime) -> Boolean = { true }, + private val wizardUpdateCallback: () -> Unit = {} /* Used in Rider to refresh the validation */ +) { + lateinit var mainPanel: JPanel + + private lateinit var runtimeComboBox: ComboBox + private lateinit var architectureComboBox: ComboBox + private lateinit var samExecutableField: JTextField + private lateinit var editSamExecutableButton: JButton + private lateinit var samLabel: JBLabel + private lateinit var packageZip: JRadioButton + private lateinit var templateComboBox: ComboBox + private lateinit var fragments: Wrapper + + private val wizardFragments: Map + private val runtimes = CollectionComboBoxModel(supportedRuntimes()) + private val architectures = CollectionComboBoxModel(supportedArchitectures()) + + init { + setupSamSelectionElements(samExecutableField, editSamExecutableButton, samLabel) + + runtimeComboBox.model = runtimes + runtimeComboBox.addActionListener { + runtimeUpdate() + architectureUpdate() + wizardUpdate() + } + + architectureComboBox.model = architectures + + templateComboBox.addActionListener { wizardUpdate() } + templateComboBox.renderer = SimpleListCellRenderer.create { label, value, _ -> + label.text = value?.displayName() + label.toolTipText = value?.description() + } + + packageZip.addChangeListener { + runtimeUpdate() + } + + wizardFragments = wizardFragmentList.associateWith { + val panel = JPanel(BorderLayout()) + val fragmentTitle = it.title() + if (fragmentTitle != null) { + panel.border = IdeBorderFactory.createTitledBorder(it.title(), false) + } + panel.add(it.component(), BorderLayout.CENTER) + panel + } + + fragments.setContent( + panel { + wizardFragments.values.forEach { + row { + cell(it).align(AlignX.FILL) + } + } + } + ) + + // this will also fire wizardUpdate since templateComboBox will change + // otherwise we make 2 of them + runtimeUpdate() + architectureUpdate() + } + + // Source all templates, find all the runtimes they support, then filter those by what the IDE supports + private fun supportedRuntimes(): MutableList = SamProjectTemplate.supportedTemplates().asSequence() + .flatMap { + when (packageType()) { + PackageType.ZIP -> it.supportedZipRuntimes().asSequence() + else -> it.supportedImageRuntimes().asSequence() + } + } + .filter(runtimeFilter) + .distinct() + .sorted() + .toMutableList() + + private fun supportedArchitectures(): MutableList = runtimes.selected?.architectures?.toMutableList() + ?: mutableListOf(LambdaArchitecture.X86_64) + + private fun packageType() = when { + packageZip.isSelected -> PackageType.ZIP + else -> PackageType.IMAGE + } + + fun setRuntime(runtime: LambdaRuntime) { + runtimeComboBox.selectedItem = runtime + } + + private fun runtimeUpdate() { + // Refresh the runtimes list since zip and image differ + runtimes.removeAll() + runtimes.add(supportedRuntimes()) + + val selectedTemplate = templateComboBox.selectedItem as? SamProjectTemplate + templateComboBox.removeAllItems() + val selectedRuntime = runtimes.selected ?: return + + val packagingType = packageType() + SamProjectTemplate.supportedTemplates().asSequence() + .filter { + when (packagingType) { + PackageType.ZIP -> it.supportedZipRuntimes().contains(selectedRuntime) + else -> it.supportedImageRuntimes().contains(selectedRuntime) + } + } + .forEach { templateComboBox.addItem(it) } + + // repopulate template after runtime updates + // this should no-op if the previous selection is not applicable to the current runtime + if (selectedTemplate != null) { + templateComboBox.selectedItem = selectedTemplate + } + } + + private fun architectureUpdate() { + val selectedArchitecture = architectureComboBox.selectedItem as? LambdaArchitecture + + architectures.removeAll() + architectures.add(supportedArchitectures()) + architectureComboBox.isEnabled = architectures.size > 1 + + if (selectedArchitecture != null) { + templateComboBox.selectedItem = selectedArchitecture + } + } + + /** + * Updates UI fragments in the wizard after a combobox update + */ + private fun wizardUpdate() { + val selectedRuntime = runtimes.selected + val selectedTemplate = templateComboBox.selectedItem as? SamProjectTemplate + wizardFragments.forEach { (wizardFragment, jComponent) -> + val isApplicable = wizardFragment.isApplicable(selectedTemplate) + if (isApplicable) { + wizardFragment.updateUi(projectLocation, selectedRuntime?.runtimeGroup, selectedTemplate) + } + jComponent.isVisible = isApplicable + } + wizardUpdateCallback() + } + + fun validate(): ValidationInfo? { + val samExecutable = ExecutableManager.getInstance().getExecutableIfPresent(getExecutable(SamExecutable::class.java)) + if (samExecutable is BadExecutable) { + return ValidationInfo(samExecutable.validationError, samExecutableField) + } + + val samVersion = SemVer.parseFromText(samExecutable.version) + ?: throw IllegalStateException("SemVer is invalid even with valid SAM executable") + + if (packageType() == PackageType.IMAGE && samVersion < SamCommon.minImageVersion) { + return ValidationInfo(message("lambda.image.sam_version_too_low", samVersion, SamCommon.minImageVersion)) + } + + val selectedRuntime = runtimes.selected ?: return templateComboBox.validationInfo(message("sam.init.error.no.runtime.selected")) + + val minRuntimeSamVersion = selectedRuntime.minSamInitVersion() + if (samVersion < minRuntimeSamVersion) { + return ValidationInfo(message("sam.executable.minimum_too_low_runtime", selectedRuntime, minRuntimeSamVersion), runtimeComboBox) + } + + val selectedArchitecture = architectures.selected ?: return templateComboBox.validationInfo(message("sam.init.error.no.architecture.selected")) + val minArchitectureSamVersion = selectedArchitecture.minSamVersion() + if (samVersion < minArchitectureSamVersion) { + return ValidationInfo(message("sam.executable.minimum_too_low_architecture", selectedArchitecture, minArchitectureSamVersion), runtimeComboBox) + } + + val samProjectTemplate = templateComboBox.selectedItem as? SamProjectTemplate + ?: return templateComboBox.validationInfo(message("sam.init.error.no.template.selected")) + + return wizardFragments.keys + .filter { it.isApplicable(samProjectTemplate) } + .mapNotNull { it.validateFragment() } + .firstOrNull() + } + + fun getNewProjectSettings(): SamNewProjectSettings { + val lambdaRuntime = runtimes.selected + ?: throw RuntimeException("No Runtime is supported in this Platform.") + val lambdaArchitecture = architectures.selected + ?: throw RuntimeException("No architecture is supported for this runtime: $lambdaRuntime") + val samProjectTemplate = templateComboBox.selectedItem as? SamProjectTemplate + ?: throw RuntimeException("No SAM template is supported for this runtime: $lambdaRuntime") + + return SamNewProjectSettings(template = samProjectTemplate, runtime = lambdaRuntime, architecture = lambdaArchitecture, packagingType = packageType()) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamProjectGenerator.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamProjectGenerator.kt new file mode 100644 index 0000000000..47618bb87c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamProjectGenerator.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.services.lambda.wizard + +import com.intellij.ide.util.projectWizard.AbstractNewProjectStep +import com.intellij.ide.util.projectWizard.CustomStepProjectGenerator +import com.intellij.ide.util.projectWizard.ProjectSettingsStepBase +import com.intellij.ide.util.projectWizard.SettingsStep +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.module.Module +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.wm.impl.welcomeScreen.AbstractActionWithPanel +import com.intellij.platform.DirectoryProjectGenerator +import com.intellij.platform.DirectoryProjectGeneratorBase +import com.intellij.platform.HideableProjectGenerator +import com.intellij.platform.ProjectGeneratorPeer +import com.intellij.platform.ProjectTemplate +import icons.AwsIcons +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.resources.message +import javax.swing.Icon +import javax.swing.JComponent + +/** + * [DirectoryProjectGeneratorBase] so it shows up in Light IDEs + * [ProjectTemplate] To allow for us to shim it into ProjectTemplatesFactory and use this in IntelliJ + * [CustomStepProjectGenerator] so we have full control over the panel + * [HideableProjectGenerator] so that we can hide it if the IDE doesnt support any of our runtimes + */ +class SamProjectGenerator : + DirectoryProjectGeneratorBase(), + ProjectTemplate, + CustomStepProjectGenerator, + HideableProjectGenerator { + + // Public for the metrics..is there a better way? + val schemaPanel = SchemaSelectionPanel() + + val wizardFragments = listOf( + SdkSelectionPanel(), + schemaPanel + ) + + private val builder = SamProjectBuilder(this) + val peer = SamProjectGeneratorSettingsPeer(this, wizardFragments) + + // Only show our wizard if we have SAM templates to show + override fun isHidden(): Boolean = SamProjectTemplate.supportedTemplates().isEmpty() + + override fun createStep( + projectGenerator: DirectoryProjectGenerator?, + callback: AbstractNewProjectStep.AbstractCallback? + ): AbstractActionWithPanel = SamProjectRuntimeSelectionStep(this) + + // entry point for the Wizard, both light and heavy IDEs eventually hit this spot through our shims + override fun generateProject( + project: Project, + baseDir: VirtualFile, + settings: SamNewProjectSettings, + module: Module + ) { + val rootModel = ModuleRootManager.getInstance(module).modifiableModel + builder.contentEntryPath = baseDir.path + builder.setupRootModel(rootModel) + + runWriteAction { + rootModel.commit() + } + } + + // the peer is in control of the first pane + override fun createPeer(): ProjectGeneratorPeer = peer + + // these overrides will give us a section for non-IntelliJ IDEs + override fun getName() = message("sam.init.name") + + override fun getDescription(): String = message("sam.init.description") + + override fun getLogo(): Icon = AwsIcons.Resources.SERVERLESS_APP + + override fun getIcon(): Icon = logo + + override fun createModuleBuilder(): SamProjectBuilder = builder + + // validation is done in the peer + override fun validateSettings(): ValidationInfo? = null + + override fun getHelpId(): String = HelpIds.NEW_SERVERLESS_PROJECT_DIALOG.id +} + +/** + * Used to overwrite the entire panel in the "light" IDEs so we don't put our settings under "More Settings" + */ +class SamProjectRuntimeSelectionStep(projectGenerator: SamProjectGenerator) : + ProjectSettingsStepBase(projectGenerator, AbstractNewProjectStep.AbstractCallback()) + +class SamProjectGeneratorSettingsPeer(val generator: SamProjectGenerator, private val wizardFragments: List) : + ProjectGeneratorPeer { + private lateinit var samInitSelectionPanel: SamInitSelectionPanel + + /** + * This hook is used in PyCharm and is called via {@link SamProjectBuilder#modifySettingsStep} for IntelliJ + */ + override fun validate(): ValidationInfo? = samInitSelectionPanel.validate() + + override fun getSettings(): SamNewProjectSettings = samInitSelectionPanel.getNewProjectSettings() + + // "Deprecated" but required to implement. Not importing to avoid the import deprecation warning. + @Suppress("OverridingDeprecatedMember", "DEPRECATION") + override fun addSettingsStateListener(listener: com.intellij.platform.WebProjectGenerator.SettingsStateListener) { + } + + // we sacrifice a lot of convenience so we can build the UI here... + override fun buildUI(settingsStep: SettingsStep) { + // delegate to another panel instead of trying to write UI as code + settingsStep.addSettingsComponent(component) + } + + override fun isBackgroundJobRunning(): Boolean = false + + // PyCharm uses this + override fun getComponent(locationField: TextFieldWithBrowseButton, checkValid: Runnable): JComponent = getPanel(locationField).mainPanel + + // IntelliJ uses this + override fun getComponent(): JComponent = getPanel(null).mainPanel + + private fun getPanel(locationField: TextFieldWithBrowseButton?): SamInitSelectionPanel { + if (!::samInitSelectionPanel.isInitialized) { + samInitSelectionPanel = SamInitSelectionPanel(wizardFragments, locationField) + } + + return samInitSelectionPanel + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamProjectGeneratorIntelliJShims.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamProjectGeneratorIntelliJShims.kt new file mode 100644 index 0000000000..a7382367e7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamProjectGeneratorIntelliJShims.kt @@ -0,0 +1,198 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.wizard + +import com.intellij.execution.RunManager +import com.intellij.ide.util.projectWizard.ModuleBuilder +import com.intellij.ide.util.projectWizard.ModuleWizardStep +import com.intellij.ide.util.projectWizard.SettingsStep +import com.intellij.ide.util.projectWizard.WizardContext +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.fileEditor.TextEditorWithPreview +import com.intellij.openapi.module.ModuleType +import com.intellij.openapi.options.ConfigurationException +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModifiableRootModel +import com.intellij.openapi.roots.ModuleRootModificationUtil +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.ProjectTemplatesFactory +import icons.AwsIcons +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.lambda.execution.local.LocalLambdaRunConfiguration +import software.aws.toolkits.jetbrains.services.lambda.execution.local.LocalLambdaRunConfigurationProducer +import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import software.aws.toolkits.jetbrains.services.schemas.SchemaTemplateParameters +import software.aws.toolkits.jetbrains.services.schemas.resources.SchemasResources +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.LambdaPackageType +import software.aws.toolkits.telemetry.SamTelemetry +import software.aws.toolkits.telemetry.Runtime.Companion as TelemetryRuntime + +// Meshing of two worlds. IntelliJ wants validation errors to be thrown exceptions. Non-IntelliJ wants validation errors +// to be returned as a ValidationInfo object. We have a shim to convert thrown exceptions into objects, +// but then we lose the ability in IntelliJ to fail validation without showing an error. This is a workaround for that case. +class ValidationException : Exception() + +// IntelliJ shim requires a ModuleBuilder +// UI is centralized in generator and is passed in to have access to UI elements +// TODO: Does this need to be a module builder, or can we decouple it? +class SamProjectBuilder(private val generator: SamProjectGenerator) : ModuleBuilder() { + // hide this from the new project menu + override fun isAvailable() = false + + // dummy type to fulfill the interface, will be replaced in setupRootModel() + override fun getModuleType(): ModuleType<*>? = ModuleType.EMPTY + + // IntelliJ create commit step + override fun setupRootModel(rootModel: ModifiableRootModel) { + val settings = generator.peer.settings + + // Set module type + val selectedRuntime = settings.runtime + // TODO luckily this works for dotnet5.0 but if we ever need a module type for a runtime that is + // not supported by zip and image we will need ot reexamine this + val moduleType = selectedRuntime.toSdkRuntime()?.runtimeGroup?.getModuleType() ?: ModuleType.EMPTY + rootModel.module.setModuleType(moduleType.id) + + val contentEntry = doAddContentEntry(rootModel) ?: throw Exception(message("sam.init.error.no.project.basepath")) + val outputDir = contentEntry.file ?: throw Exception(message("sam.init.error.no.virtual.file")) + + val project = rootModel.project + ProgressManager.getInstance().run( + object : Task.Backgroundable(project, message("sam.init.generating.template"), false) { + override fun run(indicator: ProgressIndicator) { + ModuleRootModificationUtil.updateModel(rootModel.module) { model -> + runSamInit(project, rootModel.module.name, settings, generator.schemaPanel.schemaInfo(), outputDir) + + runPostSamInit(project, model, indicator, settings, outputDir) + } + } + } + ) + } + + fun runSamInit( + project: Project?, + name: String, + settings: SamNewProjectSettings, + schemaParameters: SchemaTemplateParameters?, + outputDir: VirtualFile + ) { + var success = true + try { + SamInitRunner.execute( + outputDir, + settings.template.templateParameters(name, settings.runtime, settings.architecture, settings.packagingType), + schemaParameters?.takeIf { settings.template.supportsDynamicSchemas() } + ) + } catch (e: Throwable) { + success = false + throw e + } finally { + SamTelemetry.init( + project = project, + success = success, + runtime = TelemetryRuntime.from(settings.runtime.toString()), + version = SamCommon.getVersionString(), + templateName = getName(), + lambdaPackageType = LambdaPackageType.from(settings.packagingType.toString()), + eventBridgeSchema = if (schemaParameters?.schema?.registryName == SchemasResources.AWS_EVENTS_REGISTRY) schemaParameters.schema.name else null + ) + } + } + + fun runPostSamInit( + project: Project, + model: ModifiableRootModel, + indicator: ProgressIndicator, + settings: SamNewProjectSettings, + outputDir: VirtualFile + ) { + generator.wizardFragments.forEach { it.postProjectGeneration(model, settings.template, settings.runtime, indicator) } + + settings.template.postCreationAction(settings, outputDir, model, indicator) + + // Perform a refresh to load any generated files + outputDir.refresh(false, true) + + openReadmeFile(project, outputDir) + createRunConfigurations(project, outputDir, settings.runtime) + } + + private fun openReadmeFile(project: Project, contentRoot: VirtualFile) { + VfsUtil.findRelativeFile(contentRoot, "README.md")?.let { readme -> + readme.putUserData(TextEditorWithPreview.DEFAULT_LAYOUT_FOR_FILE, TextEditorWithPreview.Layout.SHOW_PREVIEW) + + val fileEditorManager = FileEditorManager.getInstance(project) + runInEdt { + fileEditorManager.openTextEditor(OpenFileDescriptor(project, readme), true) ?: LOG.warn { "Failed to open README.md" } + } + } + } + + private fun createRunConfigurations(project: Project, contentRoot: VirtualFile, runtime: LambdaRuntime) { + val template = SamCommon.getTemplateFromDirectory(contentRoot) ?: return + + val factory = LocalLambdaRunConfigurationProducer.getFactory() + val runManager = RunManager.getInstance(project) + SamTemplateUtils.findFunctionsFromTemplate(project, template).forEach { + val runConfigurationAndSettings = runManager.createConfiguration(it.logicalName, factory) + + val runConfiguration = runConfigurationAndSettings.configuration as LocalLambdaRunConfiguration + runConfiguration.useTemplate(template.path, it.logicalName, runtime.toString()) + runConfiguration.setGeneratedName() + + runManager.addConfiguration(runConfigurationAndSettings) + + if (runManager.selectedConfiguration == null) { + runManager.selectedConfiguration = runConfigurationAndSettings + } + } + } + + override fun modifySettingsStep(settingsStep: SettingsStep): ModuleWizardStep { + generator.peer.buildUI(settingsStep) + + // need to return an object with validate() implemented for validation + return object : ModuleWizardStep() { + override fun getComponent() = null + + override fun updateDataModel() {} + + @Throws(ConfigurationException::class) + override fun validate(): Boolean { + try { + val info = generator.peer.validate() + if (info != null) throw ConfigurationException(info.message) + } catch (_: ValidationException) { + return false + } + + return true + } + } + } + + private companion object { + val LOG = getLogger() + } +} + +class SamProjectGeneratorIntelliJAdapter : ProjectTemplatesFactory() { + override fun createTemplates(group: String?, context: WizardContext) = arrayOf(SamProjectGenerator()) + + override fun getGroupIcon(group: String?) = AwsIcons.Logos.AWS + + override fun getGroups() = arrayOf("AWS") +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamProjectWizard.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamProjectWizard.kt new file mode 100644 index 0000000000..b6fb6f6ec4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SamProjectWizard.kt @@ -0,0 +1,157 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.wizard + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModifiableRootModel +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile +import software.amazon.awssdk.services.lambda.model.PackageType +import software.aws.toolkits.core.lambda.LambdaArchitecture +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup +import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroupExtensionPointObject +import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon +import software.aws.toolkits.jetbrains.services.lambda.sam.SamTemplateUtils +import java.nio.file.Paths + +/** + * Used to manage SAM project information for different [RuntimeGroup]s + */ +interface SamProjectWizard { + /** + * Return a collection of templates supported by the [RuntimeGroup] + */ + fun listTemplates(): Collection + + /** + * Return an instance of UI section for selecting SDK for the [RuntimeGroup] + */ + fun createSdkSelectionPanel(projectLocation: TextFieldWithBrowseButton?): SdkSelector? + + companion object : RuntimeGroupExtensionPointObject(ExtensionPointName("aws.toolkit.lambda.sam.projectWizard")) +} + +data class SamNewProjectSettings( + val template: SamProjectTemplate, + val runtime: LambdaRuntime, + val architecture: LambdaArchitecture, + val packagingType: PackageType +) + +abstract class SamProjectTemplate { + abstract fun displayName(): String + + open fun description(): String? = null + + override fun toString() = displayName() + + abstract fun supportedZipRuntimes(): Set + + abstract fun supportedImageRuntimes(): Set + + // Gradual opt-in for Schema support on a template by-template basis. + // All SAM templates should support schema selection, but for launch include only EventBridge for most optimal customer experience + open fun supportsDynamicSchemas(): Boolean = false + + abstract fun templateParameters( + projectName: String, + runtime: LambdaRuntime, + architecture: LambdaArchitecture, + packagingType: PackageType + ): TemplateParameters + + open fun postCreationAction( + settings: SamNewProjectSettings, + contentRoot: VirtualFile, + rootModel: ModifiableRootModel, + indicator: ProgressIndicator + ) { + excludeSamDirectory(rootModel, contentRoot) + } + + protected fun addSourceRoots(project: Project, modifiableModel: ModifiableRootModel, projectRoot: VirtualFile) { + val template = SamCommon.getTemplateFromDirectory(projectRoot) ?: return + val templatePath = Paths.get(template.parent.path) + runInEdt { + val functions = SamTemplateUtils.findFunctionsFromTemplate(project, template) + val functionLocations = functions.map { + val codeLocation = SamTemplateUtils.getCodeLocation(template.toNioPath().toAbsolutePath(), it.logicalName) + templatePath.parent.resolve(codeLocation) + } + + val localFileSystem = LocalFileSystem.getInstance() + val function = functionLocations.mapNotNull { localFileSystem.refreshAndFindFileByIoFile(it.toFile()) } + .filter { it.isDirectory } + + modifiableModel.contentEntries.forEach { contentEntry -> + if (contentEntry.file == projectRoot) { + function.forEach { contentEntry.addSourceFolder(it, false) } + } + } + } + } + + private fun excludeSamDirectory(modifiableModel: ModifiableRootModel, projectRoot: VirtualFile) { + modifiableModel.contentEntries.forEach { contentEntry -> + if (contentEntry.file == projectRoot) { + contentEntry.addExcludeFolder( + VfsUtilCore.pathToUrl( + Paths.get(projectRoot.path, SamCommon.SAM_BUILD_DIR).toString() + ) + ) + } + } + } + + // defined so that we can restore the template selection when the runtime selection changes + override fun equals(other: Any?) = if (other is SamProjectTemplate) { + displayName() == other.displayName() + } else { + false + } + + override fun hashCode() = displayName().hashCode() + + companion object { + // Dont cache this since it is not compatible in a dynamic plugin world / waste memory if no longer needed + fun supportedTemplates() = SamProjectWizard.supportedRuntimeGroups().flatMap { + SamProjectWizard.getInstance(it).listTemplates() + } + } +} + +abstract class SamAppTemplateBased : SamProjectTemplate() { + abstract val dependencyManager: String + abstract val appTemplateName: String + open val appTemplateNameImage: String = "hello-world-lambda-image" + + override fun templateParameters( + projectName: String, + runtime: LambdaRuntime, + architecture: LambdaArchitecture, + packagingType: PackageType + ): TemplateParameters = when (packagingType) { + PackageType.IMAGE -> AppBasedImageTemplate( + name = projectName, + baseImage = "amazon/$runtime-base", + architecture = architecture, + dependencyManager = dependencyManager, + appTemplate = appTemplateNameImage + ) + PackageType.ZIP -> AppBasedZipTemplate( + name = projectName, + runtime = runtime, + architecture = architecture, + dependencyManager = dependencyManager, + appTemplate = appTemplateName + ) + else -> throw IllegalStateException("Unknown packaging type: $packagingType") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaCodeGenUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaCodeGenUtils.kt new file mode 100644 index 0000000000..211a3f19d0 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaCodeGenUtils.kt @@ -0,0 +1,88 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.wizard + +// TODO: This is fragile. Very fragile. But it is necessary to get Schemas service launched, and we've evaluated all other trade offs +// This will be done on the server-side as soon as we can, but for now the client needs to do this +class SchemaCodeGenUtils { + companion object { + private const val SCHEMA_PACKAGE_PREFIX = "schema" + private const val AWS = "aws" + private const val PARTNER = "partner" + + // dash suffix because of 3p partner registry name format + private const val AWS_PARTNER_PREFIX = "$AWS.$PARTNER-" + + // . suffix because of 1p event registry schema format + private const val AWS_EVENTS_PREFIX = "$AWS." + + fun buildSchemaPackageName(schemaName: String): String { + val builder = CodeGenPackageBuilder() + builder.append(SCHEMA_PACKAGE_PREFIX) + buildPackageName(builder, schemaName) + return builder.toString() + } + + private fun buildPackageName(builder: CodeGenPackageBuilder, schemaName: String) { + if (isAwsPartnerEvent(schemaName)) { + buildPartnerEventPackageName(builder, schemaName) + } else if (isAwsEvent(schemaName)) { + buildAwsEventPackageName(builder, schemaName) + } else { + buildCustomPackageName(builder, schemaName) + } + } + + private fun isAwsPartnerEvent(schemaName: String): Boolean = schemaName.startsWith(AWS_PARTNER_PREFIX) + + private fun buildPartnerEventPackageName(builder: CodeGenPackageBuilder, schemaName: String) { + val partnerSchemaString = schemaName.substring(AWS_PARTNER_PREFIX.length) + + builder + .append(AWS) + .append(PARTNER) + .append(partnerSchemaString) + } + + private fun isAwsEvent(name: String): Boolean = name.startsWith(AWS_EVENTS_PREFIX) + + private fun buildAwsEventPackageName(builder: CodeGenPackageBuilder, schemaName: String) { + val awsEventSchemaParts = schemaName.split(".") + for (part in awsEventSchemaParts) { + builder.append(part) + } + } + + private fun buildCustomPackageName(builder: CodeGenPackageBuilder, schemaName: String) = builder.append(schemaName) + } + + class CodeGenPackageBuilder { + private val builder: StringBuilder = StringBuilder() + + fun append(segment: String): CodeGenPackageBuilder { + if (builder.isNotEmpty()) { + builder.append(IdentifierFormatter.PACKAGE_SEPARATOR) + } + builder.append(IdentifierFormatter.toValidIdentifier(segment.toLowerCase())) + return this + } + + override fun toString(): String = builder.toString() + } + + object IdentifierFormatter { + private const val POTENTIAL_PACKAGE_SEPARATOR = "@" + + private const val NOT_VALID_IDENTIFIER_CHARACTER = "[^a-zA-Z0-9_$POTENTIAL_PACKAGE_SEPARATOR]" + private val NOT_VALID_IDENTIFIER_REGEX = Regex(NOT_VALID_IDENTIFIER_CHARACTER) + + const val PACKAGE_SEPARATOR = "." + + private const val UNDERSCORE = "_" + + fun toValidIdentifier(name: String): String = name + .replace(NOT_VALID_IDENTIFIER_REGEX, UNDERSCORE) + .replace(POTENTIAL_PACKAGE_SEPARATOR, PACKAGE_SEPARATOR) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaResourceSelector.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaResourceSelector.kt new file mode 100644 index 0000000000..cbb4fcee79 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaResourceSelector.kt @@ -0,0 +1,123 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.wizard + +import com.fasterxml.jackson.databind.JsonNode +import org.jetbrains.annotations.TestOnly +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.services.schemas.SchemaDownloader +import software.aws.toolkits.jetbrains.services.schemas.SchemaSummary +import software.aws.toolkits.jetbrains.services.schemas.SchemaTemplateExtraContext +import software.aws.toolkits.jetbrains.services.schemas.SchemaTemplateParameters +import software.aws.toolkits.jetbrains.services.schemas.resources.SchemasResources +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import javax.swing.JComponent + +class SchemaResourceSelector { + var awsConnection: ConnectionSettings? = null + + internal val schemasSelector = initializeSchemasSelector() + @TestOnly + get() = field + + val component: JComponent = schemasSelector + + fun reload() = schemasSelector.reload() + + private fun initializeSchemasSelector(): ResourceSelector = ResourceSelector.builder() + .resource(SchemasResources.LIST_REGISTRIES_AND_SCHEMAS) + .comboBoxModel(SchemaSelectionComboBoxModel()) + .customRenderer(SchemaSelectionListCellRenderer()) + .disableAutomaticLoading() + .disableAutomaticSorting() + .awsConnection { awsConnection } + .build() + + fun registryName(): String? = when (val selected = schemasSelector.selected()) { + is SchemaSelectionItem.SchemaItem -> selected.registryName + else -> null + } + + fun schemaName(): String? = when (val selected = schemasSelector.selected()) { + is SchemaSelectionItem.SchemaItem -> selected.itemText + else -> null + } + + fun buildSchemaTemplateParameters(): SchemaTemplateParameters? { + val schemaName = schemaName() + val registryName = registryName() + + if (schemaName == null || registryName == null) { + return null + } + + val schemaSummary = SchemaSummary(schemaName, registryName) + + val schemaDownloader = SchemaDownloader() + val describeSchemaResponse = + schemaDownloader.getSchemaContent(registryName, schemaName, connectionSettings = awsConnection!!).toCompletableFuture().get() + val latestSchemaVersion = describeSchemaResponse.schemaVersion() + + val schemaNode = schemaDownloader.getSchemaContentAsJson(describeSchemaResponse) + val awsEventNode = getAwsEventNode(schemaNode) + + // Derive source from custom OpenAPI metadata provided by Schemas service + val source = awsEventNode.path(X_AMAZON_EVENT_SOURCE).textValue() ?: DEFAULT_EVENT_SOURCE + + // Derive detail type from custom OpenAPI metadata provided by Schemas service + val detailType = awsEventNode.path(X_AMAZON_EVENT_DETAIL_TYPE).textValue() ?: DEFAULT_EVENT_DETAIL_TYPE + + // Generate schema root/package from the scheme name + // In the near future, this will be returned as part of a Schemas Service API call + val schemaPackageHierarchy = buildSchemaPackageHierarchy(schemaName) + + // Derive root schema event name from OpenAPI metadata, or if ambiguous, use the last post-character section of a schema name + val rootSchemaEventName = buildRootSchemaEventName(schemaNode, awsEventNode) ?: schemaSummary.title() + + return SchemaTemplateParameters( + schemaSummary, + latestSchemaVersion, + SchemaTemplateExtraContext( + registryName, + rootSchemaEventName, + schemaPackageHierarchy, + source, + detailType + ) + ) + } + + private fun getAwsEventNode(schemaNode: JsonNode): JsonNode = + // Standard OpenAPI specification + schemaNode.path(COMPONENTS).path(SCHEMAS).path(AWS_EVENT) + + private fun buildSchemaPackageHierarchy(schemaName: String): String = SchemaCodeGenUtils.buildSchemaPackageName(schemaName) + private fun buildRootSchemaEventName(schemaNode: JsonNode, awsEvent: JsonNode): String? { + val awsEventDetailRef = awsEvent.path(PROPERTIES).path(DETAIL).path(REF).textValue()?.substringAfter(COMPONENTS_SCHEMAS_PATH) + if (!awsEventDetailRef.isNullOrEmpty()) { + return SchemaCodeGenUtils.IdentifierFormatter.toValidIdentifier(awsEventDetailRef) + } + + val schemaRoots = schemaNode.path(COMPONENTS).path(SCHEMAS).fieldNames().asSequence().toList() + if (schemaRoots.isNotEmpty()) { + return SchemaCodeGenUtils.IdentifierFormatter.toValidIdentifier(schemaRoots[0]) + } + + return null + } + + companion object { + const val X_AMAZON_EVENT_SOURCE = "x-amazon-events-source" + const val X_AMAZON_EVENT_DETAIL_TYPE = "x-amazon-events-detail-type" + const val COMPONENTS = "components" + const val SCHEMAS = "schemas" + const val COMPONENTS_SCHEMAS_PATH = "#/components/schemas/" + const val AWS_EVENT = "AWSEvent" + const val PROPERTIES = "properties" + const val DETAIL = "detail" + const val REF = "${'$'}ref" + const val DEFAULT_EVENT_SOURCE = "INSERT-YOUR-EVENT-SOURCE" + const val DEFAULT_EVENT_DETAIL_TYPE = "INSERT-YOUR-DETAIL-TYPE" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaSelectionComponents.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaSelectionComponents.kt similarity index 95% rename from jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaSelectionComponents.kt rename to jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaSelectionComponents.kt index 76772d0257..298fd3b7ff 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaSelectionComponents.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaSelectionComponents.kt @@ -1,7 +1,7 @@ -// 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.ui.wizard +package software.aws.toolkits.jetbrains.services.lambda.wizard import com.intellij.ui.ColoredListCellRenderer import com.intellij.ui.MutableCollectionComboBoxModel diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaSelectionPanel.kt new file mode 100644 index 0000000000..66c60d10b8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SchemaSelectionPanel.kt @@ -0,0 +1,111 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.wizard + +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.roots.ModifiableRootModel +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.ui.dsl.builder.panel +import software.amazon.awssdk.services.schemas.SchemasClient +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.BuiltInRuntimeGroups +import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup +import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup +import software.aws.toolkits.jetbrains.services.lambda.sam.SamCommon +import software.aws.toolkits.jetbrains.services.lambda.sam.SamSchemaDownloadPostCreationAction +import software.aws.toolkits.jetbrains.services.schemas.SchemaCodeLangs +import software.aws.toolkits.jetbrains.ui.connection.AwsConnectionSettingsSelector +import software.aws.toolkits.resources.message +import javax.swing.JComponent + +/* + * A panel encapsulating AWS credential selection during SAM new project creation wizard + */ +class SchemaSelectionPanel : WizardFragment { + private val schemaSelector by lazy { SchemaResourceSelector() } + private val awsConnectionSelector by lazy { + AwsConnectionSettingsSelector( + project = null, + serviceId = SchemasClient.SERVICE_NAME + ) { + val prev = schemaSelector.awsConnection + schemaSelector.awsConnection = it + if (prev != null) { + schemaSelector.reload() + } + } + } + private val component by lazy { + panel { + row { + cell(awsConnectionSelector.selectorPanel()) + } + row(message("sam.init.schema.label")) { + cell(schemaSelector.component) + } + } + } + + override fun title(): String = message("sam.init.schema.label") + + override fun component(): JComponent = component + + override fun validateFragment(): ValidationInfo? { + if (awsConnectionSelector.selectedCredentialProvider() == null) { + return ValidationInfo(message("sam.init.schema.aws_credentials_select"), awsConnectionSelector.view.credentialProvider) + } + if (awsConnectionSelector.selectedRegion() == null) { + return ValidationInfo(message("sam.init.schema.aws_credentials_select_region"), awsConnectionSelector.view.region) + } + if (schemaSelector.registryName() == null || schemaSelector.schemaName() == null) { + return ValidationInfo(message("sam.init.schema.pleaseSelect"), schemaSelector.component) + } + return null + } + + override fun isApplicable(template: SamProjectTemplate?): Boolean = template?.supportsDynamicSchemas() == true + + override fun updateUi(projectLocation: TextFieldWithBrowseButton?, runtimeGroup: RuntimeGroup?, template: SamProjectTemplate?) { + super.updateUi(projectLocation, runtimeGroup, template) + schemaSelector.reload() + } + + override fun postProjectGeneration(model: ModifiableRootModel, template: SamProjectTemplate, runtime: LambdaRuntime, progressIndicator: ProgressIndicator) { + if (!template.supportsDynamicSchemas()) { + return + } + + schemaSelector.buildSchemaTemplateParameters()?.let { + progressIndicator.text = message("sam.init.generating.schema") + + val moduleRoot = model.contentRoots.firstOrNull() ?: return + val templateFile = SamCommon.getTemplateFromDirectory(moduleRoot) ?: return + + // We take the first since we don't have any way to say generate this schema for this function + val codeUris = SamCommon.getCodeUrisFromTemplate(model.project, templateFile).firstOrNull() ?: return + val connectionSettings = awsConnectionSelector.connectionSettings() ?: return + val runtimeGroup = runtime.toSdkRuntime()?.runtimeGroup ?: return + + SamSchemaDownloadPostCreationAction().downloadCodeIntoWorkspace( + it, + VfsUtil.virtualToIoFile(codeUris).toPath(), + runtimeGroup.toSchemaCodeLang(), + connectionSettings, + progressIndicator + ) + } + } + + fun schemaInfo() = schemaSelector.buildSchemaTemplateParameters() + + private fun RuntimeGroup.toSchemaCodeLang(): SchemaCodeLangs = when (this.id) { + BuiltInRuntimeGroups.Java -> SchemaCodeLangs.JAVA8 + BuiltInRuntimeGroups.Python -> SchemaCodeLangs.PYTHON3_6 + BuiltInRuntimeGroups.NodeJs -> SchemaCodeLangs.TYPESCRIPT + BuiltInRuntimeGroups.Go -> SchemaCodeLangs.GO1 + else -> throw IllegalStateException("Schemas is not supported by $this") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SdkSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SdkSelectionPanel.kt new file mode 100644 index 0000000000..01d3dafc6b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/SdkSelectionPanel.kt @@ -0,0 +1,98 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.wizard + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.roots.ModifiableRootModel +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.ErrorLabel +import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ThrowableRunnable +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup +import software.aws.toolkits.resources.message +import javax.swing.JComponent +import javax.swing.JLabel + +interface SdkSelector { + fun sdkSelectionPanel(): JComponent + + fun sdkSelectionLabel(): JLabel? + + fun applySdkSettings(model: ModifiableRootModel) { + val sdk = getSdk() ?: return + val project = model.project + + val projectRootManager = ProjectRootManager.getInstance(project) + WriteAction.runAndWait( + ThrowableRunnable { + if (projectRootManager.projectSdk == null) { + projectRootManager.projectSdk = sdk + } + + // If requested SDK matches project SDK, inherit it, else only set it for the module + if (sdk == projectRootManager.projectSdk) { + model.inheritSdk() + } else { + model.sdk = sdk + } + } + ) + } + + fun getSdk(): Sdk? = null + + // Validate the SDK selection panel, return a list of violations if any, otherwise null + fun validateSelection(): ValidationInfo? +} + +class SdkSelectionPanel : WizardFragment { + private var sdkSelector: SdkSelector? = null + + private val component = Wrapper() + + override fun title(): String? = null + + override fun component(): JComponent = component + + override fun validateFragment(): ValidationInfo? = sdkSelector?.validateSelection() + + override fun isApplicable(template: SamProjectTemplate?): Boolean = true + + override fun updateUi(projectLocation: TextFieldWithBrowseButton?, runtimeGroup: RuntimeGroup?, template: SamProjectTemplate?) { + if (runtimeGroup == null) { + component.setContent(ErrorLabel(message("sam.init.sdk.runtime.not.selected"))) + return + } + + sdkSelector = SamProjectWizard.getInstance(runtimeGroup).createSdkSelectionPanel(projectLocation).also { + component.setContent( + panel { + it?.let { + row(it.sdkSelectionLabel()) { + cell(it.sdkSelectionPanel()).align(AlignX.FILL) + }.bottomGap(BottomGap.MEDIUM) + } + } + ) + } + } + + override fun postProjectGeneration(model: ModifiableRootModel, template: SamProjectTemplate, runtime: LambdaRuntime, progressIndicator: ProgressIndicator) { + sdkSelector?.let { + progressIndicator.text = "Setting up SDK" + ApplicationManager.getApplication().invokeAndWait { + it.applySdkSettings(model) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/TemplateParameters.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/TemplateParameters.kt new file mode 100644 index 0000000000..ac15a8c0e8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/TemplateParameters.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.jetbrains.services.lambda.wizard + +import software.aws.toolkits.core.lambda.LambdaArchitecture +import software.aws.toolkits.core.lambda.LambdaRuntime + +sealed class TemplateParameters + +data class AppBasedZipTemplate( + val name: String, + val runtime: LambdaRuntime, + val architecture: LambdaArchitecture, + val appTemplate: String, + val dependencyManager: String +) : TemplateParameters() +data class AppBasedImageTemplate( + val name: String, + val baseImage: String, + val architecture: LambdaArchitecture, + val appTemplate: String, + val dependencyManager: String +) : TemplateParameters() +data class LocationBasedTemplate(val location: String) : TemplateParameters() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/WizardFragment.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/WizardFragment.kt new file mode 100644 index 0000000000..c1ce167ec8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/lambda/wizard/WizardFragment.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.lambda.wizard + +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.roots.ModifiableRootModel +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.ui.ValidationInfo +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup +import javax.swing.JComponent + +/** + * Represents a standalone section of the wizard UI + */ +interface WizardFragment { + /** + * If not null, adds a title border to the [component] + */ + fun title(): String? + + /** + * Returns the component that will be added to the main UI + */ + fun component(): JComponent + + /** + * Returns a [ValidationInfo] if the settings are considered invalid to be reported back to the user + */ + fun validateFragment(): ValidationInfo? + + /** + * Return true if this fragment is applicable to the template and should be shown to the user + */ + fun isApplicable(template: SamProjectTemplate?): Boolean + + /** + * Updates the fragment's UI based on changes to the project location (not always available), runtime, or template + */ + fun updateUi(projectLocation: TextFieldWithBrowseButton?, runtimeGroup: RuntimeGroup?, template: SamProjectTemplate?) {} + + /** + * Runs after the initial template is executed to allow for post-generate activities such as code generation + */ + fun postProjectGeneration(model: ModifiableRootModel, template: SamProjectTemplate, runtime: LambdaRuntime, progressIndicator: ProgressIndicator) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.form index b6d64b2f2d..edf22d85ed 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.form @@ -14,7 +14,7 @@ - + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.java deleted file mode 100644 index 7f48028421..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.java +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.s3; - -import javax.swing.JPanel; -import javax.swing.JTextField; - -import org.jetbrains.annotations.NotNull; - -public class CreateBucketPanel { - @NotNull - JTextField bucketName; - @NotNull - JPanel component; -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.kt new file mode 100644 index 0000000000..50f7ac1e3e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateBucketPanel.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.services.s3 + +import javax.swing.JPanel +import javax.swing.JTextField + +class CreateBucketPanel { + lateinit var bucketName: JTextField + private set + lateinit var component: JPanel + private set +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateS3BucketDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateS3BucketDialog.kt index ca5cad70f1..03007749c4 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateS3BucketDialog.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/CreateS3BucketDialog.kt @@ -46,15 +46,18 @@ class CreateS3BucketDialog( override fun doOKAction() { if (okAction.isEnabled) { - setOKButtonText(message("s3.create.bucket.in_progress")) + setOKButtonText(message("general.create_in_progress")) isOKActionEnabled = false ApplicationManager.getApplication().executeOnPooledThread { try { createBucket() - ApplicationManager.getApplication().invokeLater({ - close(OK_EXIT_CODE) - }, ModalityState.stateForComponent(view.component)) + ApplicationManager.getApplication().invokeLater( + { + close(OK_EXIT_CODE) + }, + ModalityState.stateForComponent(view.component) + ) project.refreshAwsTree(S3Resources.LIST_BUCKETS) S3Telemetry.createBucket(project, Result.Succeeded) } catch (e: Exception) { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3ExplorerNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3ExplorerNode.kt index be8768b804..1820049d68 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3ExplorerNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3ExplorerNode.kt @@ -7,17 +7,18 @@ import com.intellij.openapi.project.Project import icons.AwsIcons import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.Bucket -import software.aws.toolkits.jetbrains.core.AwsResourceCache import software.aws.toolkits.jetbrains.core.credentials.activeRegion import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerNode -import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceNode import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerResourceNode -import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceRootNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerServiceNode +import software.aws.toolkits.jetbrains.core.explorer.nodes.CacheBackedAwsExplorerServiceRootNode import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources +import software.aws.toolkits.resources.message -class S3ServiceNode(project: Project, service: AwsExplorerServiceNode) : AwsExplorerServiceRootNode(project, service) { - override fun getChildrenInternal(): List> = - AwsResourceCache.getInstance(nodeProject).getResourceNow(S3Resources.listBucketsByActiveRegion(nodeProject)).map { S3BucketNode(nodeProject, it) } +class S3ServiceNode(project: Project, service: AwsExplorerServiceNode) : + CacheBackedAwsExplorerServiceRootNode(project, service, S3Resources.LIST_BUCKETS) { + override fun displayName(): String = message("explorer.node.s3") + override fun toNode(child: Bucket): AwsExplorerNode<*> = S3BucketNode(nodeProject, child) } class S3BucketNode(project: Project, val bucket: Bucket) : @@ -30,7 +31,7 @@ class S3BucketNode(project: Project, val bucket: Bucket) : override fun isAlwaysShowPlus(): Boolean = false override fun onDoubleClick() { - openEditor(nodeProject, bucket) + openEditor(nodeProject, bucket.name()) } override fun displayName(): String = bucket.name() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3Utils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3Utils.kt index 849848d471..0382c50065 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3Utils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3Utils.kt @@ -6,3 +6,5 @@ package software.aws.toolkits.jetbrains.services.s3 import software.aws.toolkits.core.region.AwsRegion fun bucketArn(bucketName: String, region: AwsRegion) = "arn:${region.partitionId}:s3:::$bucketName" + +const val NOT_VERSIONED_VERSION_ID = "null" diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3ViewerProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3ViewerProvider.kt index b03114229a..ebbd5b35d7 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3ViewerProvider.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/S3ViewerProvider.kt @@ -16,7 +16,6 @@ import com.intellij.openapi.project.PossiblyDumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.util.UserDataHolderBase import com.intellij.openapi.vfs.VirtualFile -import software.amazon.awssdk.services.s3.model.Bucket import software.aws.toolkits.jetbrains.core.awsClient import software.aws.toolkits.jetbrains.services.s3.editor.S3ViewerPanel import software.aws.toolkits.jetbrains.services.s3.editor.S3VirtualBucket @@ -43,9 +42,11 @@ class S3ViewerEditorProvider : FileEditorProvider, PossiblyDumbAware { } } -class S3ViewerEditor(project: Project, bucket: S3VirtualBucket) : UserDataHolderBase(), FileEditor { +class S3ViewerEditor(project: Project, private val bucket: S3VirtualBucket) : UserDataHolderBase(), FileEditor { private val s3Panel: S3ViewerPanel = S3ViewerPanel(this, project, bucket) + override fun getFile(): VirtualFile = bucket + override fun getComponent(): JComponent = s3Panel.component override fun getName(): String = "S3 Bucket Panel" @@ -75,11 +76,11 @@ class S3ViewerEditor(project: Project, bucket: S3VirtualBucket) : UserDataHolder override fun setState(state: FileEditorState) {} } -fun openEditor(project: Project, bucket: Bucket): Editor? = try { +fun openEditor(project: Project, bucketName: String, prefix: String = ""): Editor? = try { FileEditorManager.getInstance(project).openTextEditor( OpenFileDescriptor( project, - S3VirtualBucket(bucket, project.awsClient()) + S3VirtualBucket(bucketName, prefix, project.awsClient(), project) ), true ).also { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/TransferUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/TransferUtils.kt index c022243dd0..3d155c719b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/TransferUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/TransferUtils.kt @@ -7,10 +7,9 @@ import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project -import com.intellij.util.io.inputStream import com.intellij.util.io.outputStream -import com.intellij.util.io.size import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.http.ContentStreamProvider import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.GetObjectRequest import software.amazon.awssdk.services.s3.model.GetObjectResponse @@ -37,12 +36,11 @@ fun S3Client.upload( key: String, message: String = message("s3.upload.object.progress", key), startInBackground: Boolean = true -): CompletionStage = upload(project, source.inputStream(), source.size(), bucket, key, message, startInBackground) +): CompletionStage = upload(project, RequestBody.fromFile(source), bucket, key, message, startInBackground) -fun S3Client.upload( +private fun S3Client.upload( project: Project, - source: InputStream, - length: Long, + source: RequestBody, bucket: String, key: String, message: String = message("s3.upload.object.progress", key), @@ -50,57 +48,92 @@ fun S3Client.upload( ): CompletionStage { val future = CompletableFuture() val request = PutObjectRequest.builder().bucket(bucket).key(key).build() - ProgressManager.getInstance().run(object : Task.Backgroundable(project, message, true, if (startInBackground) ALWAYS_BACKGROUND else null) { - override fun run(indicator: ProgressIndicator) { - indicator.isIndeterminate = false - try { - val result = ProgressMonitorInputStream(indicator, source, length = length).use { - this@upload.putObject(request, RequestBody.fromInputStream(it, length)) + ProgressManager.getInstance().run( + object : Task.Backgroundable(project, message, true, if (startInBackground) ALWAYS_BACKGROUND else null) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = source.optionalContentLength().isEmpty + val requestSource = if (source.optionalContentLength().isPresent) { + val length = source.optionalContentLength().get() + val newProvider = ProgressTrackingContentProvider(indicator, source.contentStreamProvider(), length) + + RequestBody.fromContentProvider(newProvider, length, source.contentType()) + } else { + source + } + + try { + future.complete(this@upload.putObject(request, requestSource)) + } catch (e: Exception) { + future.completeExceptionally(e) } - future.complete(result) - } catch (e: Exception) { - future.completeExceptionally(e) } } - }) + ) return future } +private class ProgressTrackingContentProvider( + private val progressIndicator: ProgressIndicator, + private val underlyingInputStreamProvider: ContentStreamProvider, + private val length: Long +) : ContentStreamProvider { + private var currentStream: InputStream? = null + + override fun newStream(): InputStream { + currentStream?.let { + runCatching { + it.close() + } + } + + return ProgressMonitorInputStream(progressIndicator, underlyingInputStreamProvider.newStream(), length).also { + currentStream = it + } + } +} + fun S3Client.download( project: Project, bucket: String, key: String, + versionId: String?, destination: Path, message: String = message("s3.download.object.progress", key), startInBackground: Boolean = true -): CompletionStage = download(project, bucket, key, destination.outputStream(), message, startInBackground) +): CompletionStage = download(project, bucket, key, versionId, destination.outputStream(), message, startInBackground) fun S3Client.download( project: Project, bucket: String, key: String, + versionId: String?, destination: OutputStream, message: String = message("s3.download.object.progress", key), startInBackground: Boolean = true ): CompletionStage { val future = CompletableFuture() - val request = GetObjectRequest.builder().bucket(bucket).key(key).build() - ProgressManager.getInstance().run(object : Task.Backgroundable(project, message, true, if (startInBackground) ALWAYS_BACKGROUND else null) { - override fun run(indicator: ProgressIndicator) { - try { - this@download.getObject(request) { response, inputStream -> - indicator.isIndeterminate = false - inputStream.use { input -> - ProgressMonitorOutputStream(indicator, destination, response.contentLength()).use { output -> - IoUtils.copy(input, output) + val requestBuilder = GetObjectRequest.builder().bucket(bucket).key(key) + versionId?.let { + requestBuilder.versionId(it) + } + ProgressManager.getInstance().run( + object : Task.Backgroundable(project, message, true, if (startInBackground) ALWAYS_BACKGROUND else null) { + override fun run(indicator: ProgressIndicator) { + try { + this@download.getObject(requestBuilder.build()) { response, inputStream -> + indicator.isIndeterminate = false + inputStream.use { input -> + ProgressMonitorOutputStream(indicator, destination, response.contentLength()).use { output -> + IoUtils.copy(input, output) + } } + future.complete(response) } - future.complete(response) + } catch (e: Exception) { + future.completeExceptionally(e) } - } catch (e: Exception) { - future.completeExceptionally(e) } } - }) + ) return future } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/CreateBucketAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/CreateBucketAction.kt index a0e367ac90..9a96b53920 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/CreateBucketAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/CreateBucketAction.kt @@ -7,14 +7,14 @@ import com.intellij.openapi.actionSystem.LangDataKeys import com.intellij.openapi.project.DumbAwareAction import icons.AwsIcons 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.services.s3.CreateS3BucketDialog import software.aws.toolkits.resources.message class CreateBucketAction : DumbAwareAction(message("s3.create.bucket.title"), null, AwsIcons.Resources.S3_BUCKET) { override fun actionPerformed(e: AnActionEvent) { val project = e.getRequiredData(LangDataKeys.PROJECT) - val client: S3Client = AwsClientManager.getInstance(project).getClient() + val client: S3Client = project.awsClient() val dialog = CreateS3BucketDialog(project, client) dialog.show() } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/DeleteBucketAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/DeleteBucketAction.kt index 5cd45807d6..9041cf3acf 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/DeleteBucketAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/DeleteBucketAction.kt @@ -3,28 +3,27 @@ package software.aws.toolkits.jetbrains.services.s3.bucketActions +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.testFramework.runInEdtAndWait import software.amazon.awssdk.services.s3.S3Client import software.aws.toolkits.core.s3.deleteBucketAndContents -import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.awsClient import software.aws.toolkits.jetbrains.core.explorer.actions.DeleteResourceAction import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree import software.aws.toolkits.jetbrains.services.s3.S3BucketNode import software.aws.toolkits.jetbrains.services.s3.editor.S3VirtualBucket import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources -import software.aws.toolkits.jetbrains.utils.TaggingResourceType import software.aws.toolkits.resources.message -class DeleteBucketAction : DeleteResourceAction(message("s3.delete.bucket.action"), TaggingResourceType.S3_BUCKET) { +class DeleteBucketAction : DeleteResourceAction(message("s3.delete.bucket.action")) { override fun performDelete(selected: S3BucketNode) { - val client: S3Client = AwsClientManager.getInstance(selected.nodeProject).getClient() + val client: S3Client = selected.nodeProject.awsClient() val fileEditorManager = FileEditorManager.getInstance(selected.nodeProject) fileEditorManager.openFiles.forEach { - if (it is S3VirtualBucket && it.s3Bucket.name() == selected.displayName()) { + if (it is S3VirtualBucket && it.name == selected.displayName()) { // Wait so that we know it closes successfully, otherwise this operation is not a success - runInEdtAndWait { + ApplicationManager.getApplication().invokeAndWait { fileEditorManager.closeFile(it) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/OpenBucketViewerAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/OpenBucketViewerAction.kt index 226eb82720..91d9b9c889 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/OpenBucketViewerAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/OpenBucketViewerAction.kt @@ -7,10 +7,9 @@ import com.intellij.openapi.project.DumbAware import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction import software.aws.toolkits.jetbrains.services.s3.S3BucketNode import software.aws.toolkits.jetbrains.services.s3.openEditor -import software.aws.toolkits.resources.message -class OpenBucketViewerAction : SingleResourceNodeAction(message("s3.open.viewer.bucket.action")), DumbAware { +class OpenBucketViewerAction : SingleResourceNodeAction(), DumbAware { override fun actionPerformed(selected: S3BucketNode, e: AnActionEvent) { - openEditor(selected.nodeProject, selected.bucket) + openEditor(selected.nodeProject, selected.bucket.name()) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/OpenPrefixedBucketViewerAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/OpenPrefixedBucketViewerAction.kt new file mode 100644 index 0000000000..7cc8bf244e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/OpenPrefixedBucketViewerAction.kt @@ -0,0 +1,33 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.s3.bucketActions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.ui.Messages +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.s3.S3BucketNode +import software.aws.toolkits.jetbrains.services.s3.openEditor +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.S3Telemetry + +class OpenPrefixedBucketViewerAction : SingleResourceNodeAction(), DumbAware { + override fun actionPerformed(selected: S3BucketNode, e: AnActionEvent) { + val prefix = Messages.showInputDialog( + selected.nodeProject, + message("s3.open.viewer.prefix.message"), + message("s3.open.viewer.prefix.title"), + null + ) + + if (prefix == null) { + // cancelled + S3Telemetry.openEditor(selected.nodeProject, Result.Cancelled) + return + } + + openEditor(selected.nodeProject, selected.bucket.name(), prefix) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/ViewBucketAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/ViewBucketAction.kt new file mode 100644 index 0000000000..d46e259aea --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/bucketActions/ViewBucketAction.kt @@ -0,0 +1,28 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.s3.bucketActions + +import software.amazon.awssdk.services.s3.model.S3Exception +import software.aws.toolkits.jetbrains.core.explorer.actions.ViewResourceAction +import software.aws.toolkits.jetbrains.services.s3.S3ServiceNode +import software.aws.toolkits.jetbrains.services.s3.openEditor +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message + +class ViewBucketAction : ViewResourceAction(message("action.aws.toolkit.s3.open.bucket.viewer.text"), message("s3.bucket.label")) { + + override fun viewResource(resourceToView: String, selected: S3ServiceNode) { + try { + if (resourceToView.startsWith("S3://", ignoreCase = true)) { + openEditor(selected.nodeProject, resourceToView.split("S3://", ignoreCase = true).last().substringBefore("/")) + } else { + openEditor(selected.nodeProject, resourceToView) + } + } catch (e: S3Exception) { + e.notifyError(message("s3.open.viewer.bucket.failed")) + } + } + + override fun checkResourceNameValidity(resourceName: String?): Boolean = resourceName.equals("S3://", ignoreCase = true) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3ColumnInfo.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3ColumnInfo.kt index 37ee4f4346..b459a1f373 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3ColumnInfo.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3ColumnInfo.kt @@ -33,5 +33,5 @@ class S3Column(private val type: S3ColumnType) : ColumnInfo(type.tit enum class S3ColumnType(val title: String) { NAME(message("s3.name")), SIZE(message("s3.size")), - LAST_MODIFIED(message("s3.last_modified")); + LAST_MODIFIED(message("s3.last_modified")) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3EditorDataKeys.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3EditorDataKeys.kt new file mode 100644 index 0000000000..05ca3edfb5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3EditorDataKeys.kt @@ -0,0 +1,18 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.s3.editor + +import com.intellij.openapi.actionSystem.DataKey + +object S3EditorDataKeys { + /** + * Returns all the selected nodes. Note: Error, Continuation, and loading nodes are filtered out + */ + val SELECTED_NODES = DataKey.create>("aws.s3.bucketViewer.selectedNodes") + + /** + * Returns the S3 bucket viewer table + */ + val BUCKET_TABLE = DataKey.create("aws.s3.bucketViewer") +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeCellRenderer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeCellRenderer.kt index 4916e821d8..821598d72b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeCellRenderer.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeCellRenderer.kt @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.aws.toolkits.jetbrains.services.s3.editor -import com.intellij.icons.AllIcons import com.intellij.ui.ColoredTreeCellRenderer import com.intellij.ui.LoadingNode import com.intellij.ui.SimpleTextAttributes @@ -25,21 +24,18 @@ class S3TreeCellRenderer(private val speedSearchTarget: JComponent) : ColoredTre val selectedNode = value as? DefaultMutableTreeNode val node = selectedNode?.userObject as? S3TreeNode ?: return - when { - node.isDirectory -> { - icon = AllIcons.Nodes.Folder - append(node.name.trimEnd('/')) - } - node is S3TreeContinuationNode -> { - icon = AllIcons.Nodes.EmptyNode - append(node.name, SimpleTextAttributes.LINK_ATTRIBUTES) + icon = node.icon + when (node) { + is S3TreeContinuationNode<*> -> { + append(node.displayName(), SimpleTextAttributes.LINK_ATTRIBUTES) } else -> { - icon = node.icon ?: AllIcons.FileTypes.Unknown - append(node.name) + append(node.displayName()) } } SpeedSearchUtil.applySpeedSearchHighlighting(speedSearchTarget, this, true, selected) } + + override fun calcFocusedState(): Boolean = speedSearchTarget.hasFocus() } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeNode.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeNode.kt index c2644c583c..9240d121d3 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeNode.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeNode.kt @@ -3,91 +3,340 @@ package software.aws.toolkits.jetbrains.services.s3.editor +import com.intellij.icons.AllIcons +import com.intellij.ide.projectView.PresentationData import com.intellij.openapi.fileTypes.FileTypeRegistry -import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.openapi.util.io.FileUtilRt +import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.treeStructure.SimpleNode import kotlinx.coroutines.runBlocking +import software.amazon.awssdk.services.s3.model.NoSuchBucketException +import software.amazon.awssdk.services.s3.model.S3Exception +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.jetbrains.services.s3.NOT_VERSIONED_VERSION_ID +import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.resources.message import java.time.Instant -sealed class S3TreeNode(val bucketName: String, val parent: S3TreeDirectoryNode?, val key: String) : SimpleNode() { - open val isDirectory = false +sealed class S3TreeNode(val bucket: S3VirtualBucket, val parent: S3LazyLoadParentNode<*>?, val key: String) : SimpleNode() { override fun getChildren(): Array = arrayOf() - override fun getName(): String = key.substringAfterLast('/') -} -fun S3TreeNode.getDirectoryKey() = if (isDirectory) { - key -} else { - parent?.key ?: throw IllegalStateException("$key claimed it was not a directory but has no parent!") + @Deprecated("Do not use, exists due to SimpleNode. Use more use case specific methods", ReplaceWith("displayName()")) + final override fun getName(): String = displayName() + + /** + * String representation of this node in UIs + */ + open fun displayName(): String = key.trimEnd('/').substringAfterLast('/') + + /** + * Directory path to this node + */ + open fun directoryPath() = parent?.key ?: throw IllegalStateException("$key has no parent!") + + override fun toString(): String = "${this::class.simpleName}(key='$key')" + + override fun getEqualityObjects(): Array = arrayOf(bucket, key) } -class S3TreeDirectoryNode(private val bucket: S3VirtualBucket, parent: S3TreeDirectoryNode?, key: String) : S3TreeNode(bucket.name, parent, key) { - override val isDirectory = true +abstract class S3LazyLoadParentNode(bucket: S3VirtualBucket, parent: S3LazyLoadParentNode<*>?, key: String) : S3TreeNode(bucket, parent, key) { private val childrenLock = Object() - private val loadedPages = mutableSetOf() + private val loadedPages = mutableSetOf() private var cachedList: List = listOf() - override fun getName(): String = key.dropLast(1).substringAfterLast('/') + '/' override fun getChildren(): Array { synchronized(childrenLock) { if (cachedList.isEmpty()) { cachedList = loadObjects() } + return cachedList.toTypedArray() } - return cachedList.toTypedArray() } - @Synchronized - fun loadMore(continuationToken: String) { - // dedupe calls - if (loadedPages.contains(continuationToken)) { - return + fun removeAllChildren() { + synchronized(childrenLock) { + cachedList = listOf() + loadedPages.clear() } - cachedList = children.dropLastWhile { it is S3TreeContinuationNode } + loadObjects(continuationToken) - loadedPages.add(continuationToken) } - private fun loadObjects(continuationToken: String? = null): List { - val response = runBlocking { - bucket.listObjects(key, continuationToken) + fun loadMore(continuationMarker: T) { + synchronized(childrenLock) { + // dedupe calls + if (loadedPages.contains(continuationMarker)) { + return + } + + val more = loadObjects(continuationMarker) + // Only say it has loaded before if it loaded successfully + if (more.none { it is S3TreeErrorNode || it is S3TreeErrorContinuationNode<*> }) { + loadedPages.add(continuationMarker) + } + cachedList = children.dropLastWhile { it is S3TreeContinuationNode<*> || it is S3TreeErrorNode } + more } + } + + protected abstract fun loadObjects(continuationMarker: T? = null): List +} + +class S3TreePrefixedDirectoryNode(bucket: S3VirtualBucket) : S3TreeDirectoryNode(bucket, null, bucket.prefix) { + fun isDelimited() = key.isNotEmpty() && !key.endsWith("/") + override fun displayName() = if (isDelimited()) { + message("s3.prefix.label", key) + } else { + key + } +} + +open class S3TreeDirectoryNode(bucket: S3VirtualBucket, parent: S3TreeDirectoryNode?, key: String) : + S3LazyLoadParentNode(bucket, parent, key) { + init { + icon = AllIcons.Nodes.Folder + } - val continuation = listOfNotNull(response.nextContinuationToken()?.let { - S3TreeContinuationNode(bucketName, this, "${this.key}/${message("s3.load_more")}", it) - }) + override fun directoryPath(): String = key - val folders = response.commonPrefixes()?.map { S3TreeDirectoryNode(bucket, this, it.prefix()) } ?: emptyList() + override fun loadObjects(continuationMarker: String?): List { + try { + val response = runBlocking { + bucket.listObjects(key, continuationMarker) + } + + val continuation = listOfNotNull( + response.nextContinuationToken()?.let { + S3TreeContinuationNode(bucket, this, this.key, it) + } + ) + + val folders = response.commonPrefixes()?.map { S3TreeDirectoryNode(bucket, this, it.prefix()) } ?: emptyList() - val s3Objects = response - .contents() - ?.filterNotNull() - ?.filterNot { it.key() == key } - ?.map { S3TreeObjectNode(bucketName, this, it.key(), it.size(), it.lastModified()) as S3TreeNode } - ?: emptyList() + val s3Objects = response + .contents() + ?.filterNotNull() + // filter out the directory root + // if the root was a non-delimited prefix, it should not be filtered out + ?.filterNot { it.key() == key && (this as? S3TreePrefixedDirectoryNode)?.isDelimited() != true } + ?.map { S3TreeObjectNode(this, it.key(), it.size(), it.lastModified()) } + ?: emptyList() + + val results = (folders + s3Objects).sortedBy { it.key } + continuation + if (results.isEmpty()) { + return listOf(S3TreeEmptyNode(bucket, this)) + } - return (folders + s3Objects).sortedBy { it.key } + continuation + return results + } catch (e: NoSuchBucketException) { + bucket.handleDeletedBucket() + return emptyList() + } catch (e: S3Exception) { + e.notifyError(message("s3.bucket.load.fail.title")) + return buildList { + if (continuationMarker != null) { + add(S3TreeErrorContinuationNode(bucket, this@S3TreeDirectoryNode, this@S3TreeDirectoryNode.key, continuationMarker)) + } else { + add(S3TreeErrorNode(bucket, this@S3TreeDirectoryNode)) + } + } + } catch (e: Exception) { + LOG.error(e) { "Loading objects failed!" } + return buildList { + if (continuationMarker != null) { + add(S3TreeErrorContinuationNode(bucket, this@S3TreeDirectoryNode, this@S3TreeDirectoryNode.key, continuationMarker)) + } else { + add(S3TreeErrorNode(bucket, this@S3TreeDirectoryNode)) + } + } + } } - fun removeChild(node: S3TreeNode) { - cachedList = cachedList.filter { it != node } + companion object { + private val LOG = getLogger() } +} - fun removeAllChildren() { - cachedList = listOf() +interface S3Object { + val bucket: S3VirtualBucket + val key: String + val versionId: String? + + val size: Long + val lastModified: Instant + + fun fileName(): String +} + +data class VersionContinuationToken(val keyMarker: String, val versionId: String) + +class S3TreeObjectNode(parent: S3TreeDirectoryNode, key: String, override val size: Long, override val lastModified: Instant) : + S3LazyLoadParentNode(parent.bucket, parent, key), + S3Object { + var showHistory: Boolean = false + + init { + icon = FileTypeRegistry.getInstance().getFileTypeByFileName(key.substringAfterLast("/")).icon + } + + override val versionId: String? = null + + /** + * The name of this object if saved to a file + */ + override fun fileName() = key.substringAfterLast("/") + + override fun loadObjects(continuationMarker: VersionContinuationToken?): List { + if (!showHistory) { + return emptyList() + } + + try { + val response = runBlocking { + bucket.listObjectVersions(key, continuationMarker?.keyMarker, continuationMarker?.versionId) + } + + return buildList { + response?.versions() + ?.filter { it.key() == key && it.versionId() != NOT_VERSIONED_VERSION_ID } + ?.map { S3TreeObjectVersionNode(this@S3TreeObjectNode, it.versionId(), it.size(), it.lastModified()) } + ?.onEach { add(it) } + + if (response?.isTruncated == true) { + val nextKey = response.nextKeyMarker() + val nextVersion = response.nextVersionIdMarker() + + add( + S3TreeContinuationNode( + bucket, + this@S3TreeObjectNode, + this@S3TreeObjectNode.key, + VersionContinuationToken(nextKey, nextVersion) + ) + ) + } + } + } catch (e: NoSuchBucketException) { + bucket.handleDeletedBucket() + return emptyList() + } catch (e: S3Exception) { + e.notifyError(message("s3.object.load.fail.title")) + return buildList { + if (continuationMarker != null) { + add( + S3TreeErrorContinuationNode( + bucket, + this@S3TreeObjectNode, + this@S3TreeObjectNode.key, + continuationMarker + ) + ) + } else { + add(S3TreeErrorNode(bucket, this@S3TreeObjectNode)) + } + } + } catch (e: Exception) { + LOG.error(e) { "Loading objects failed!" } + return buildList { + if (continuationMarker != null) { + add( + S3TreeErrorContinuationNode( + bucket, + this@S3TreeObjectNode, + this@S3TreeObjectNode.key, + continuationMarker + ) + ) + } else { + add(S3TreeErrorNode(bucket, this@S3TreeObjectNode)) + } + } + } + } + + companion object { + private val LOG = getLogger() + } +} + +class S3TreeObjectVersionNode(parent: S3TreeObjectNode, override val versionId: String, override val size: Long, override val lastModified: Instant) : + S3TreeNode(parent.bucket, parent, parent.key), S3Object { + + init { + icon = parent.icon + } + + override fun directoryPath(): String = (parent as S3TreeObjectNode).directoryPath() + + override fun fileName(): String { + val parentObjectName = (parent as S3TreeObjectNode).fileName() + + val filenamePrefix = FileUtilRt.getNameWithoutExtension(parentObjectName) + "@" + versionId + val extension = FileUtilRt.getExtension(parentObjectName) + return if (extension.isNotEmpty()) { + "$filenamePrefix.$extension" + } else { + filenamePrefix + } } + + override fun displayName(): String = versionId + + override fun getChildren(): Array = emptyArray() + + override fun getEqualityObjects(): Array = arrayOf(bucket, key, versionId) + + override fun toString(): String = "S3TreeObjectVersionNode(key='$key', versionId='$versionId')" } -private val fileTypeRegistry = FileTypeRegistry.getInstance() +open class S3TreeContinuationNode( + bucket: S3VirtualBucket, + private val parentNode: S3LazyLoadParentNode, + key: String, + private val continuationMarker: T +) : S3TreeNode(bucket, parentNode, key) { + init { + icon = AllIcons.Nodes.EmptyNode + } + + override fun displayName(): String = message("s3.load_more") -class S3TreeObjectNode(bucketName: String, parent: S3TreeDirectoryNode?, key: String, val size: Long, val lastModified: Instant) : - S3TreeNode(bucketName, parent, key) { + fun loadMore() { + parentNode.loadMore(continuationMarker) + } + + override fun getEqualityObjects(): Array = arrayOf(bucket, key, continuationMarker) +} + +class S3TreeErrorContinuationNode( + bucket: S3VirtualBucket, + parentNode: S3LazyLoadParentNode, + key: String, + continuationMarker: T +) : S3TreeContinuationNode(bucket, parentNode, key, continuationMarker) { + init { + icon = AllIcons.General.Error + } - private val fileType = fileTypeRegistry.getFileTypeByFileName(name) + override fun displayName(): String = message("s3.load_more_failed") +} +class S3TreeErrorNode( + bucket: S3VirtualBucket, + parentNode: S3LazyLoadParentNode<*> +) : S3TreeNode(bucket, parentNode, "${parentNode.key}error") { init { - fileType.takeIf { it !is UnknownFileType }?.icon.let { icon = it } + icon = AllIcons.General.Error } + + override fun displayName(): String = message("s3.error_loading") } -class S3TreeContinuationNode(bucketName: String, parent: S3TreeDirectoryNode?, key: String, val token: String) : S3TreeNode(bucketName, parent, key) +class S3TreeEmptyNode( + bucket: S3VirtualBucket, + parentNode: S3LazyLoadParentNode<*> +) : S3TreeNode(bucket, parentNode, "${parentNode.key}empty") { + override fun displayName(): String = message("explorer.empty_node") + override fun update(presentation: PresentationData) { + presentation.addText(displayName(), SimpleTextAttributes.GRAYED_ATTRIBUTES) + } +} 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 01661a3770..5c9cd767e0 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 @@ -3,6 +3,7 @@ package software.aws.toolkits.jetbrains.services.s3.editor import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.runInEdt import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileTypes.ex.FileTypeChooser @@ -10,19 +11,18 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.util.io.FileUtilRt.getUserContentLoadLimit import com.intellij.openapi.util.text.StringUtil -import com.intellij.openapi.vfs.LocalFileSystem -import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileWrapper import com.intellij.ui.DoubleClickListener import com.intellij.ui.TreeTableSpeedSearch import com.intellij.ui.treeStructure.treetable.TreeTable import com.intellij.util.containers.Convertor -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import software.amazon.awssdk.services.s3.model.NoSuchBucketException +import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info -import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.services.s3.objectActions.deleteSelectedObjects +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.services.s3.objectActions.uploadObjects import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.Result @@ -45,6 +45,7 @@ class S3TreeTable( val bucket: S3VirtualBucket, private val project: Project ) : TreeTable(treeTableModel) { + private val coroutineScope = projectCoroutineScope(project) private val dropTargetListener = object : DropTargetAdapter() { override fun drop(dropEvent: DropTargetDropEvent) { @@ -54,24 +55,19 @@ class S3TreeTable( val list = dropEvent.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> list.filterIsInstance() } catch (e: UnsupportedFlavorException) { - // When the drag and drop data is not what we expect (like when it is text) this is thrown and can be safey ignored + // When the drag and drop data is not what we expect (like when it is text) this is thrown and can be safely ignored LOG.info(e) { "Unsupported flavor attempted to be dragged and dropped" } return } - val lfs = LocalFileSystem.getInstance() - val virtualFiles = data.mapNotNull { - lfs.findFileByIoFile(it) - } - - uploadAndRefresh(virtualFiles, node) + uploadObjects(project, this@S3TreeTable, data.map { it.toPath() }, node) } } private val openFileListener = object : DoubleClickListener() { override fun onDoubleClick(e: MouseEvent): Boolean { val row = rowAtPoint(e.point).takeIf { it >= 0 } ?: return false - return handleOpeningFile(row) + return handleOpeningFile(row, isDoubleClick = true) } } @@ -87,54 +83,67 @@ class S3TreeTable( } private fun doProcessKeyEvent(e: KeyEvent) { - if (!e.isConsumed && (e.keyCode == KeyEvent.VK_DELETE || e.keyCode == KeyEvent.VK_BACK_SPACE)) { - e.consume() - deleteSelectedObjects(project, this@S3TreeTable) - } if (e.keyCode == KeyEvent.VK_ENTER && selectedRowCount == 1) { - handleOpeningFile(selectedRow) + handleOpeningFile(selectedRow, isDoubleClick = false) handleLoadingMore(selectedRow) } } - private fun handleOpeningFile(row: Int): Boolean { - val objectNode = (tree.getPathForRow(row).lastPathComponent as? DefaultMutableTreeNode)?.userObject as? S3TreeObjectNode ?: return false + private fun handleOpeningFile(row: Int, isDoubleClick: Boolean): Boolean { + val objectNode = (tree.getPathForRow(row).lastPathComponent as? DefaultMutableTreeNode)?.userObject as? S3Object ?: return false + // Don't process double click if it has children (i.e. versions) since it will trigger expansion as well + if (isDoubleClick && objectNode is S3LazyLoadParentNode<*> && objectNode.childCount > 0) { + return false + } + val maxFileSize = getUserContentLoadLimit() if (objectNode.size > maxFileSize) { notifyError(content = message("s3.open.file_too_big", StringUtil.formatFileSize(maxFileSize.toLong()))) + S3Telemetry.downloadObject(project, false) return true } - val fileWrapper = VirtualFileWrapper(File("${FileUtil.getTempDirectory()}${File.separator}${objectNode.key.replace('/', '_')}")) + val fileWrapper = VirtualFileWrapper(File("${FileUtil.getTempDirectory()}${File.separator}${objectNode.fileName()}")) // set the file to not be read only so that the S3Client can write to the file ApplicationManager.getApplication().runWriteAction { fileWrapper.virtualFile?.isWritable = true } - GlobalScope.launch { - bucket.download(project, objectNode.key, fileWrapper.file.outputStream()) - runInEdt { - // If the file type is not associated, prompt user to associate. Returns null on cancel - fileWrapper.virtualFile?.let { - ApplicationManager.getApplication().runWriteAction { - it.isWritable = false - } - FileTypeChooser.getKnownFileTypeOrAssociate(it, project) ?: return@runInEdt - // set virtual file to read only - FileEditorManager.getInstance(project).openFile(it, true, true).ifEmpty { - notifyError(project = project, content = message("s3.open.viewer.failed")) + val modality = ModalityState.stateForComponent(this) + + coroutineScope.launch { + try { + bucket.download(project, objectNode.key, objectNode.versionId, fileWrapper.file.outputStream()) + runInEdt(modality) { + // If the file type is not associated, prompt user to associate. Returns null on cancel + fileWrapper.virtualFile?.let { + ApplicationManager.getApplication().runWriteAction { + it.isWritable = false + } + FileTypeChooser.getKnownFileTypeOrAssociate(it, project) ?: return@runInEdt + // set virtual file to read only + FileEditorManager.getInstance(project).openFile(it, true, true).ifEmpty { + notifyError(project = project, content = message("s3.open.viewer.failed.unsupported")) + } } } + S3Telemetry.downloadObject(project, true) + } catch (e: NoSuchBucketException) { + bucket.handleDeletedBucket() + S3Telemetry.downloadObject(project, Result.Failed) + } catch (e: Exception) { + S3Telemetry.downloadObject(project, false) + LOG.error(e) { "Attempting to open file threw" } + notifyError(project = project, content = message("s3.open.viewer.failed")) } } return true } private fun handleLoadingMore(row: Int): Boolean { - val continuationNode = (tree.getPathForRow(row).lastPathComponent as? DefaultMutableTreeNode)?.userObject as? S3TreeContinuationNode ?: return false - val parent = continuationNode.parent ?: return false + val continuationNode = (tree.getPathForRow(row).lastPathComponent as? DefaultMutableTreeNode)?.userObject as? S3TreeContinuationNode<*> ?: return false - GlobalScope.launch { - parent.loadMore(continuationNode.token) + coroutineScope.launch { + continuationNode.loadMore() refresh() } @@ -142,55 +151,28 @@ class S3TreeTable( } init { - // Associate the drop target listener with this instance which will allow uploading by drag and drop - DropTarget(this, dropTargetListener) - TreeTableSpeedSearch(this, Convertor { obj -> - val node = obj.lastPathComponent as DefaultMutableTreeNode - val userObject = node.userObject as? S3TreeNode ?: return@Convertor null - return@Convertor if (userObject !is S3TreeContinuationNode) { - userObject.name - } else { - null + // Do not set up Drag and Drop when in test mode since AWT is not enabled + if (!ApplicationManager.getApplication().isUnitTestMode) { + // Associate the drop target listener with this instance which will allow uploading by drag and drop + DropTarget(this, dropTargetListener) + } + TreeTableSpeedSearch( + this, + Convertor { obj -> + val node = obj.lastPathComponent as DefaultMutableTreeNode + val userObject = node.userObject as? S3TreeNode ?: return@Convertor null + return@Convertor if (userObject !is S3TreeContinuationNode<*>) { + userObject.displayName() + } else { + null + } } - }) + ) loadMoreListener.installOn(this) openFileListener.installOn(this) super.addKeyListener(keyListener) } - fun uploadAndRefresh(selectedFiles: List, node: S3TreeNode) { - if (selectedFiles.isEmpty()) { - LOG.warn { "Zero files passed into s3 uploadAndRefresh, not attempting upload or refresh" } - return - } - GlobalScope.launch { - try { - selectedFiles.forEach { - if (it.isDirectory) { - notifyError( - title = message("s3.upload.object.failed", it.name), - content = message("s3.upload.directory.impossible", it.name), - project = project - ) - return@forEach - } - - try { - bucket.upload(project, it.inputStream, it.length, node.getDirectoryKey() + it.name) - invalidateLevel(node) - refresh() - } catch (e: Exception) { - e.notifyError(message("s3.upload.object.failed", it.path), project) - throw e - } - } - S3Telemetry.uploadObjects(project, Result.Succeeded, selectedFiles.size.toDouble()) - } catch (e: Exception) { - S3Telemetry.uploadObjects(project, Result.Failed, selectedFiles.size.toDouble()) - } - } - } - fun refresh() { runInEdt { clearSelection() @@ -198,7 +180,7 @@ class S3TreeTable( } } - fun getNodeForRow(row: Int): S3TreeNode? { + private fun getNodeForRow(row: Int): S3TreeNode? { val path = tree.getPathForRow(convertRowIndexToModel(row)) return (path.lastPathComponent as DefaultMutableTreeNode).userObject as? S3TreeNode } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeTableModel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeTableModel.kt index 5338c455a0..6c856f98f6 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeTableModel.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3TreeTableModel.kt @@ -3,13 +3,13 @@ // Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. package software.aws.toolkits.jetbrains.services.s3.editor +import com.intellij.ui.tree.AsyncTreeModel +import com.intellij.ui.tree.StructureTreeModel import com.intellij.ui.tree.TreeVisitor import com.intellij.ui.treeStructure.SimpleTreeStructure import com.intellij.ui.treeStructure.treetable.TreeTableModel import com.intellij.util.ui.ColumnInfo import org.jetbrains.concurrency.Promise -import software.aws.toolkits.jetbrains.ui.tree.AsyncTreeModel -import software.aws.toolkits.jetbrains.ui.tree.StructureTreeModel import javax.swing.JTree import javax.swing.tree.TreeModel import javax.swing.tree.TreePath diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3ViewerPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3ViewerPanel.kt index 8f84ee3e89..9c56d661e3 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3ViewerPanel.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3ViewerPanel.kt @@ -3,44 +3,105 @@ package software.aws.toolkits.jetbrains.services.s3.editor +import com.intellij.ide.DataManager import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.actionSystem.ActionPlaces -import com.intellij.openapi.actionSystem.CommonShortcuts -import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.actionSystem.ActionToolbar import com.intellij.openapi.project.Project +import com.intellij.ui.IdeBorderFactory import com.intellij.ui.PopupHandler -import com.intellij.ui.ToolbarDecorator +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.SearchTextField +import com.intellij.ui.SideBorder +import com.intellij.ui.tree.AsyncTreeModel +import com.intellij.ui.tree.StructureTreeModel import com.intellij.ui.treeStructure.SimpleTreeStructure -import software.aws.toolkits.jetbrains.services.s3.objectActions.CopyPathAction -import software.aws.toolkits.jetbrains.services.s3.objectActions.DeleteObjectAction -import software.aws.toolkits.jetbrains.services.s3.objectActions.DownloadObjectAction -import software.aws.toolkits.jetbrains.services.s3.objectActions.NewFolderAction -import software.aws.toolkits.jetbrains.services.s3.objectActions.RefreshSubTreeAction -import software.aws.toolkits.jetbrains.services.s3.objectActions.RefreshTreeAction -import software.aws.toolkits.jetbrains.services.s3.objectActions.RenameObjectAction -import software.aws.toolkits.jetbrains.services.s3.objectActions.UploadObjectAction -import software.aws.toolkits.jetbrains.ui.tree.AsyncTreeModel -import software.aws.toolkits.jetbrains.ui.tree.StructureTreeModel +import com.intellij.util.concurrency.Invoker +import com.intellij.util.text.nullize +import software.aws.toolkits.jetbrains.utils.ui.onEmpty +import software.aws.toolkits.jetbrains.utils.ui.onEnter +import software.aws.toolkits.resources.message +import java.awt.BorderLayout import javax.swing.JComponent +import javax.swing.JPanel import javax.swing.SwingConstants import javax.swing.table.DefaultTableCellRenderer -class S3ViewerPanel(disposable: Disposable, private val project: Project, private val virtualBucket: S3VirtualBucket) { +class S3ViewerPanel(private val disposable: Disposable, private val project: Project, virtualBucket: S3VirtualBucket) { val component: JComponent - val treeTable: S3TreeTable - private val rootNode: S3TreeDirectoryNode = S3TreeDirectoryNode(virtualBucket, null, "") + lateinit var treeTable: S3TreeTable + private set + private val filterComponent: JComponent + private lateinit var toolbarComponent: JComponent init { - val structureTreeModel: StructureTreeModel = StructureTreeModel(SimpleTreeStructure.Impl(rootNode), disposable) + component = JPanel(BorderLayout()) + + filterComponent = SearchTextField() + filterComponent.textEditor.text = virtualBucket.prefix + filterComponent.textEditor.emptyText.text = message("s3.prefix.filter") + val handler = { + component.removeAll() + virtualBucket.prefix = filterComponent.text.nullize(nullizeSpaces = true) ?: "" + setupTreeTable(virtualBucket) + } + + filterComponent.onEnter(handler) + filterComponent.onEmpty { + // only refresh if view is currently filtered + if (virtualBucket.prefix.isNotBlank()) { + handler() + } + } + + DataManager.registerDataProvider(component) { + when { + S3EditorDataKeys.SELECTED_NODES.`is`(it) -> treeTable.getSelectedNodes() + S3EditorDataKeys.BUCKET_TABLE.`is`(it) -> treeTable + else -> null + } + } + + setupTreeTable(virtualBucket) + } + + private fun setupTreeTable(virtualBucket: S3VirtualBucket) { + treeTable = createTreeTable(disposable, virtualBucket) + toolbarComponent = createToolbar(treeTable).component + toolbarComponent.border = IdeBorderFactory.createBorder(SideBorder.TOP) + + setupContextMenu(treeTable) + + val panel = JPanel(BorderLayout()) + panel.add(toolbarComponent, BorderLayout.CENTER) + panel.add(filterComponent, BorderLayout.EAST) + + component.add(panel, BorderLayout.NORTH) + component.add(ScrollPaneFactory.createScrollPane(treeTable), BorderLayout.CENTER) + } + + private fun createTreeTable(disposable: Disposable, virtualBucket: S3VirtualBucket): S3TreeTable { + val rootNode = if (virtualBucket.prefix.isNotBlank()) { + S3TreePrefixedDirectoryNode(virtualBucket) + } else { + S3TreeDirectoryNode(virtualBucket, null, "") + } + + val structureTreeModel: StructureTreeModel = StructureTreeModel( + SimpleTreeStructure.Impl(rootNode), + null, + // TODO this has a concurrency of 1, do we want to adjust this? + Invoker.forBackgroundThreadWithoutReadAction(disposable), + disposable + ) val model = S3TreeTableModel( AsyncTreeModel(structureTreeModel, true, disposable), arrayOf(S3Column(S3ColumnType.NAME), S3Column(S3ColumnType.SIZE), S3Column(S3ColumnType.LAST_MODIFIED)), structureTreeModel ) - treeTable = S3TreeTable(model, rootNode, virtualBucket, project).also { - it.setRootVisible(false) + val treeTable = S3TreeTable(model, rootNode, virtualBucket, project).also { + it.setRootVisible(rootNode is S3TreePrefixedDirectoryNode) it.cellSelectionEnabled = false it.rowSelectionAllowed = true it.rowSorter = S3RowSorter(it.model) @@ -49,39 +110,35 @@ class S3ViewerPanel(disposable: Disposable, private val project: Project, privat it.tableHeader.reorderingAllowed = false it.columnModel.getColumn(1).maxWidth = 120 } - component = addToolbar().createPanel() + val treeRenderer = S3TreeCellRenderer(treeTable) treeTable.setTreeCellRenderer(treeRenderer) val tableRenderer = DefaultTableCellRenderer().also { it.horizontalAlignment = SwingConstants.LEFT } treeTable.setDefaultRenderer(Any::class.java, tableRenderer) + + return treeTable + } + + private fun createToolbar(s3TreeTable: S3TreeTable): ActionToolbar { + val actionManager = ActionManager.getInstance() + val group = actionManager.getAction("aws.toolkit.s3viewer.toolbar") as ActionGroup + val toolbar = actionManager.createActionToolbar(ACTION_PLACE, group, true) + toolbar.setTargetComponent(s3TreeTable) + return toolbar + } + + private fun setupContextMenu(treeTable: S3TreeTable) { + val actionManager = ActionManager.getInstance() + val group = actionManager.getAction("aws.toolkit.s3viewer.contextMenu") as ActionGroup + PopupHandler.installPopupHandler( treeTable, - createCommonActionGroup(treeTable).also { - it.addAction(RefreshSubTreeAction(treeTable)) - }, - ActionPlaces.EDITOR_POPUP, - ActionManager.getInstance() + group, + ACTION_PLACE, ) } - private fun addToolbar(): ToolbarDecorator { - val group = createCommonActionGroup(treeTable).also { - it.addAction(RefreshTreeAction(treeTable, rootNode)) - } - return ToolbarDecorator.createDecorator(treeTable).setActionGroup(group) - } - - private fun createCommonActionGroup(table: S3TreeTable): DefaultActionGroup = DefaultActionGroup().also { - it.add(DownloadObjectAction(project, table)) - it.add(UploadObjectAction(project, table)) - it.add(Separator()) - it.add(NewFolderAction(project, table)) - it.add(RenameObjectAction(project, table).apply { - registerCustomShortcutSet(CommonShortcuts.getRename(), table) - }) - it.add(CopyPathAction(project, table)) - it.add(Separator()) - it.add(DeleteObjectAction(project, table)) - it.add(Separator()) + private companion object { + const val ACTION_PLACE = "S3ViewerPanel" } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3VirtualBucket.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3VirtualBucket.kt index b88c2044f1..7392734c64 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3VirtualBucket.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/editor/S3VirtualBucket.kt @@ -3,78 +3,130 @@ package software.aws.toolkits.jetbrains.services.s3.editor +import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.testFramework.LightVirtualFile -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import software.amazon.awssdk.core.sync.RequestBody import software.amazon.awssdk.services.s3.S3Client -import software.amazon.awssdk.services.s3.model.Bucket +import software.amazon.awssdk.services.s3.model.ListObjectVersionsResponse import software.amazon.awssdk.services.s3.model.ListObjectsV2Response import software.amazon.awssdk.services.s3.model.ObjectIdentifier +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree import software.aws.toolkits.jetbrains.services.s3.download +import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources import software.aws.toolkits.jetbrains.services.s3.upload -import java.io.InputStream +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message import java.io.OutputStream +import java.net.URL +import java.nio.file.Path -class S3VirtualBucket(val s3Bucket: Bucket, val client: S3Client) : LightVirtualFile(s3Bucket.name()) { - override fun isWritable(): Boolean = false - override fun getPath(): String = s3Bucket.name() +class S3VirtualBucket(val s3Bucket: String, prefix: String, val client: S3Client, val project: Project) : + LightVirtualFile(vfsName(s3Bucket, prefix)) { + + var prefix = prefix + set(value) { + val oldName = name + field = value + VirtualFileManager.getInstance().notifyPropertyChanged(this, PROP_NAME, oldName, name) + } + + override fun isDirectory(): Boolean = false /* Unit tests refuse to open this in an editor if this is true */ override fun isValid(): Boolean = true + override fun isWritable(): Boolean = false + override fun getName(): String = vfsName(s3Bucket, prefix) override fun getParent(): VirtualFile? = null - override fun toString(): String = s3Bucket.name() - override fun isDirectory(): Boolean = false /* Unit tests refuse to open this in an editor if this is true */ + override fun getPath(): String = super.getName() + override fun toString(): String = super.getName() override fun equals(other: Any?): Boolean { if (other !is S3VirtualBucket) { return false } - return s3Bucket.name() == (other as? S3VirtualBucket)?.s3Bucket?.name() + return s3Bucket == (other as? S3VirtualBucket)?.s3Bucket && prefix == (other as? S3VirtualBucket)?.prefix } - override fun hashCode(): Int = s3Bucket.name().hashCode() + override fun hashCode(): Int = s3Bucket.hashCode() + prefix.hashCode() suspend fun newFolder(name: String) { - withContext(Dispatchers.IO) { - client.putObject({ it.bucket(s3Bucket.name()).key(name.trimEnd('/') + "/") }, RequestBody.empty()) + withContext(getCoroutineBgContext()) { + client.putObject({ it.bucket(s3Bucket).key(name.trimEnd('/') + "/") }, RequestBody.empty()) } } - suspend fun listObjects(prefix: String, continuationToken: String?): ListObjectsV2Response = withContext(Dispatchers.IO) { - client.listObjectsV2 { - it.bucket(s3Bucket.name()).delimiter("/").prefix(prefix).maxKeys(MAX_ITEMS_TO_LOAD).continuationToken(continuationToken) + suspend fun listObjects(prefix: String, continuationToken: String?): ListObjectsV2Response = + withContext(getCoroutineBgContext()) { + client.listObjectsV2 { + it.bucket(s3Bucket).delimiter("/").prefix(prefix).maxKeys(MAX_ITEMS_TO_LOAD).continuationToken(continuationToken) + } + } + + suspend fun listObjectVersions(key: String, keyMarker: String?, versionIdMarker: String?): ListObjectVersionsResponse? = + withContext(getCoroutineBgContext()) { + client.listObjectVersions { + it.bucket(s3Bucket).prefix(key).delimiter("/").maxKeys(MAX_ITEMS_TO_LOAD).keyMarker(keyMarker).versionIdMarker(versionIdMarker) + } } - } suspend fun deleteObjects(keys: List) { - withContext(Dispatchers.IO) { + withContext(getCoroutineBgContext()) { val keysToDelete = keys.map { ObjectIdentifier.builder().key(it).build() } - client.deleteObjects { it.bucket(s3Bucket.name()).delete { del -> del.objects(keysToDelete) } } + client.deleteObjects { it.bucket(s3Bucket).delete { del -> del.objects(keysToDelete) } } } } suspend fun renameObject(fromKey: String, toKey: String) { - withContext(Dispatchers.IO) { - client.copyObject { it.copySource("${s3Bucket.name()}/$fromKey").destinationBucket(s3Bucket.name()).destinationKey(toKey) } - client.deleteObject { it.bucket(s3Bucket.name()).key(fromKey) } + withContext(getCoroutineBgContext()) { + client.copyObject { it.sourceBucket(s3Bucket).sourceKey(fromKey).destinationBucket(s3Bucket).destinationKey(toKey) } + client.deleteObject { it.bucket(s3Bucket).key(fromKey) } + } + } + + suspend fun upload(project: Project, source: Path, key: String) { + withContext(getCoroutineBgContext()) { + client.upload(project, source, s3Bucket, key).await() } } - suspend fun upload(project: Project, source: InputStream, length: Long, key: String) { - withContext(Dispatchers.IO) { - client.upload(project, source, length, s3Bucket.name(), key).await() + suspend fun download(project: Project, key: String, versionId: String? = null, output: OutputStream) { + withContext(getCoroutineBgContext()) { + client.download(project, s3Bucket, key, versionId, output).await() } } - suspend fun download(project: Project, key: String, output: OutputStream) { - withContext(Dispatchers.IO) { - client.download(project, s3Bucket.name(), key, output).await() + fun generateUrl(key: String, versionId: String?): URL = client.utilities().getUrl { + it.bucket(s3Bucket) + it.key(key) + it.versionId(versionId) + } + + fun handleDeletedBucket() { + notifyError(project = project, content = message("s3.open.viewer.bucket_does_not_exist", s3Bucket)) + val fileEditorManager = FileEditorManager.getInstance(project) + fileEditorManager.openFiles.forEach { + if (it is S3VirtualBucket && it.name == s3Bucket) { + runBlocking(getCoroutineUiContext()) { + fileEditorManager.closeFile(it) + } + } } + project.refreshAwsTree(S3Resources.LIST_BUCKETS) } private companion object { const val MAX_ITEMS_TO_LOAD = 300 + + fun vfsName(s3BucketName: String, subroot: String): String = if (subroot.isBlank()) { + s3BucketName + } else { + "$s3BucketName/$subroot" + } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyPathAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyPathAction.kt index c854940c45..8ab65547c2 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyPathAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyPathAction.kt @@ -3,21 +3,21 @@ package software.aws.toolkits.jetbrains.services.s3.objectActions -import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.ide.CopyPasteManager -import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.utils.getRequiredData import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode -import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectVersionNode import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.S3Telemetry import java.awt.datatransfer.StringSelection -class CopyPathAction(private val project: Project, treeTable: S3TreeTable) : SingleS3ObjectAction(treeTable, message("s3.copy.path"), AllIcons.Actions.Copy) { - // Only enable it if we have some selection. We hide the root node so it means we have no selection if that is the node passed in - override fun enabled(node: S3TreeNode): Boolean = node != treeTable.rootNode - - override fun performAction(node: S3TreeNode) { +class CopyPathAction : SingleS3ObjectAction(message("s3.copy.path")) { + override fun performAction(dataContext: DataContext, node: S3TreeNode) { CopyPasteManager.getInstance().setContents(StringSelection(node.key)) - S3Telemetry.copyPath(project) + S3Telemetry.copyPath(dataContext.getRequiredData(CommonDataKeys.PROJECT)) } + + override fun enabled(node: S3TreeNode): Boolean = node !is S3TreeObjectVersionNode } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyUriAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyUriAction.kt new file mode 100644 index 0000000000..9c705e7f29 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyUriAction.kt @@ -0,0 +1,23 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.s3.objectActions + +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.ide.CopyPasteManager +import software.aws.toolkits.jetbrains.core.utils.getRequiredData +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectVersionNode +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.S3Telemetry +import java.awt.datatransfer.StringSelection + +class CopyUriAction : SingleS3ObjectAction(message("s3.copy.uri")) { + override fun performAction(dataContext: DataContext, node: S3TreeNode) { + CopyPasteManager.getInstance().setContents(StringSelection("s3://${node.bucket.name}/${node.key}")) + S3Telemetry.copyUri(dataContext.getRequiredData(CommonDataKeys.PROJECT), success = true) + } + + override fun enabled(node: S3TreeNode): Boolean = node !is S3TreeObjectVersionNode +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyUrlAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyUrlAction.kt new file mode 100644 index 0000000000..63d555f13e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/CopyUrlAction.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.s3.objectActions + +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.ide.CopyPasteManager +import software.aws.toolkits.jetbrains.core.utils.getRequiredData +import software.aws.toolkits.jetbrains.services.s3.NOT_VERSIONED_VERSION_ID +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectVersionNode +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.S3Telemetry +import java.awt.datatransfer.StringSelection + +class CopyUrlAction : SingleS3ObjectAction(message("s3.copy.url")) { + override fun performAction(dataContext: DataContext, node: S3TreeNode) { + val project = dataContext.getRequiredData(CommonDataKeys.PROJECT) + try { + val versionId = (node as? S3TreeObjectVersionNode)?.versionId?.takeIf { it != NOT_VERSIONED_VERSION_ID } + val url = node.bucket.generateUrl(node.key, versionId).toString() + CopyPasteManager.getInstance().setContents(StringSelection(url)) + + S3Telemetry.copyUrl(project, presigned = false, success = true) + } catch (e: Exception) { + e.notifyError(project = project, title = message("s3.copy.url.failed")) + S3Telemetry.copyUrl(project, presigned = false, success = false) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/DeleteObjectAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/DeleteObjectAction.kt index c3c1e82fff..b1beb3427c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/DeleteObjectAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/DeleteObjectAction.kt @@ -4,10 +4,15 @@ package software.aws.toolkits.jetbrains.services.s3.objectActions import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import software.amazon.awssdk.services.s3.model.NoSuchBucketException +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.utils.getRequiredData +import software.aws.toolkits.jetbrains.services.s3.editor.S3EditorDataKeys import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectNode import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable @@ -16,42 +21,43 @@ import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.Result import software.aws.toolkits.telemetry.S3Telemetry -class DeleteObjectAction(private val project: Project, treeTable: S3TreeTable) : - S3ObjectAction(treeTable, message("s3.delete.object.action"), AllIcons.Actions.Cancel) { - - override fun performAction(nodes: List) { +class DeleteObjectAction : S3ObjectAction(message("s3.delete.object.action"), AllIcons.Actions.Cancel) { + override fun performAction(dataContext: DataContext, nodes: List) { + val project = dataContext.getRequiredData(CommonDataKeys.PROJECT) + val treeTable = dataContext.getRequiredData(S3EditorDataKeys.BUCKET_TABLE) deleteNodes(project, treeTable, nodes.filterIsInstance()) } - override fun enabled(nodes: List): Boolean = nodes.all { it is S3TreeObjectNode } -} - -fun deleteSelectedObjects(project: Project, treeTable: S3TreeTable) { - val nodes = treeTable.getSelectedNodes().filterIsInstance() - deleteNodes(project, treeTable, nodes) -} + // TODO enable for versioned objects. + override fun enabled(nodes: List): Boolean = nodes.isNotEmpty() && nodes.all { it::class == S3TreeObjectNode::class } -private fun deleteNodes(project: Project, treeTable: S3TreeTable, nodes: List) { - val response = Messages.showOkCancelDialog( - project, - message("s3.delete.object.description", nodes.size), - message("s3.delete.object.action"), - message("s3.delete.object.delete"), - message("s3.delete.object.cancel"), Messages.getWarningIcon() - ) + private fun deleteNodes(project: Project, treeTable: S3TreeTable, nodes: List) { + val response = Messages.showOkCancelDialog( + project, + message("s3.delete.object.description", nodes.size), + message("s3.delete.object.action"), + message("general.delete"), + message("s3.delete.object.cancel"), + Messages.getWarningIcon() + ) - if (response != Messages.OK) { - S3Telemetry.deleteObject(project, Result.Cancelled) - } else { - GlobalScope.launch { - try { - treeTable.bucket.deleteObjects(nodes.map { it.key }) - nodes.forEach { treeTable.invalidateLevel(it) } - treeTable.refresh() - S3Telemetry.deleteObject(project, Result.Succeeded) - } catch (e: Exception) { - e.notifyError(message("s3.delete.object.failed")) - S3Telemetry.deleteObject(project, Result.Failed) + if (response != Messages.OK) { + S3Telemetry.deleteObject(project, Result.Cancelled) + } else { + val scope = projectCoroutineScope(project) + scope.launch { + try { + treeTable.bucket.deleteObjects(nodes.map { it.key }) + nodes.forEach { treeTable.invalidateLevel(it) } + treeTable.refresh() + S3Telemetry.deleteObject(project, Result.Succeeded) + } catch (e: NoSuchBucketException) { + treeTable.bucket.handleDeletedBucket() + S3Telemetry.deleteObject(project, Result.Failed) + } catch (e: Exception) { + e.notifyError(project = project, title = message("s3.delete.object.failed")) + S3Telemetry.deleteObject(project, Result.Failed) + } } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/DownloadObjectAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/DownloadObjectAction.kt index b6a5b32ea1..550787c2db 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/DownloadObjectAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/DownloadObjectAction.kt @@ -3,22 +3,29 @@ package software.aws.toolkits.jetbrains.services.s3.objectActions import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.fileChooser.FileChooser import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages import com.intellij.openapi.vfs.VfsUtil -import com.intellij.util.io.exists import com.intellij.util.io.isDirectory -import com.intellij.util.io.outputStream -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import software.amazon.awssdk.services.s3.model.NoSuchBucketException import software.aws.toolkits.core.utils.deleteIfExists +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.core.utils.outputStream +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.utils.getRequiredData +import software.aws.toolkits.jetbrains.services.s3.editor.S3EditorDataKeys +import software.aws.toolkits.jetbrains.services.s3.editor.S3Object import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectNode -import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectVersionNode +import software.aws.toolkits.jetbrains.services.s3.editor.S3VirtualBucket import software.aws.toolkits.jetbrains.services.s3.objectActions.DownloadObjectAction.ConflictResolution.OVERWRITE import software.aws.toolkits.jetbrains.services.s3.objectActions.DownloadObjectAction.ConflictResolution.OVERWRITE_ALL import software.aws.toolkits.jetbrains.services.s3.objectActions.DownloadObjectAction.ConflictResolution.SKIP @@ -29,13 +36,17 @@ import software.aws.toolkits.telemetry.S3Telemetry import java.nio.file.Path import java.nio.file.Paths -// TODO: Cant replace the file chooser service until newer IDE version, switch to ServiceContainerUtil to use a fake file chooser instead of -// fileDownloadBackDoor example: -// https://github.com/JetBrains/intellij-community/blob/54e4a2ad3b73973b3123c87d48749cc0ff36c4cd/platform/external-system-impl/testSrc/com/intellij/openapi/externalSystem/importing/ExternalSystemSetupProjectTestCase.kt#L102 FIX_WHEN_MIN_IS_193 -class DownloadObjectAction @JvmOverloads constructor(private val project: Project, treeTable: S3TreeTable, private val fileDownloadBackDoor: Path? = null) : - S3ObjectAction(treeTable, message("s3.download.object.action"), AllIcons.Actions.Download) { +class DownloadObjectAction : + S3ObjectAction(message("s3.download.object.action"), AllIcons.Actions.Download) { + private data class DownloadInfo(val sourceBucket: S3VirtualBucket, val s3Object: String, val versionId: String?, val diskLocation: Path) { + constructor(sourceBucket: S3VirtualBucket, s3Object: S3Object, diskLocation: Path) : this( + sourceBucket, + s3Object.key, + s3Object.versionId, + diskLocation + ) + } - private data class DownloadInfo(val s3Object: String, val diskLocation: Path) enum class ConflictResolution(val message: String) { SKIP(message("s3.download.object.conflict.skip")), OVERWRITE(message("s3.download.object.conflict.overwrite")), @@ -53,31 +64,31 @@ class DownloadObjectAction @JvmOverloads constructor(private val project: Projec } } - private val bucket = treeTable.bucket - - override fun enabled(nodes: List): Boolean = nodes.all { it is S3TreeObjectNode } + override fun enabled(nodes: List): Boolean = nodes.isNotEmpty() && nodes.all { it is S3TreeObjectNode || it is S3TreeObjectVersionNode } - override fun performAction(nodes: List) { - val files = nodes.filterIsInstance() + override fun performAction(dataContext: DataContext, nodes: List) { + val files = nodes.filterIsInstance() + val project = dataContext.getRequiredData(CommonDataKeys.PROJECT) + val sourceBucket = dataContext.getRequiredData(S3EditorDataKeys.BUCKET_TABLE).bucket when (files.size) { - 1 -> downloadSingle(project, files.first()) - else -> downloadMultiple(project, files) + 1 -> downloadSingle(project, sourceBucket, files.first()) + else -> downloadMultiple(project, sourceBucket, files) } } - private fun downloadSingle(project: Project, file: S3TreeObjectNode) { - val selectedLocation = getDownloadLocation(foldersOnly = false) ?: return + private fun downloadSingle(project: Project, sourceBucket: S3VirtualBucket, file: S3Object) { + val selectedLocation = getDownloadLocation(project = project, foldersOnly = false) ?: return val destinationFile = if (selectedLocation.isDirectory()) { - selectedLocation.resolve(file.name) + selectedLocation.resolve(file.fileName()) } else { selectedLocation } - val downloads = listOf(DownloadInfo(file.key, destinationFile)) + val downloads = listOf(DownloadInfo(sourceBucket, file, destinationFile)) val finalDownloads = if (selectedLocation.isDirectory()) { - checkForConflicts(destinationFile, downloads) + checkForConflicts(project, destinationFile, downloads) } else { // If user has requested a single file as their destination, presume they want to overwrite it downloads @@ -86,20 +97,16 @@ class DownloadObjectAction @JvmOverloads constructor(private val project: Projec downloadAll(project, finalDownloads) } - private fun downloadMultiple(project: Project, files: List) { - val selectedLocation = getDownloadLocation(foldersOnly = true) ?: return + private fun downloadMultiple(project: Project, sourceBucket: S3VirtualBucket, files: List) { + val selectedLocation = getDownloadLocation(project, foldersOnly = true) ?: return - val downloads = files.map { DownloadInfo(it.key, selectedLocation.resolve(it.name)) } - val finalDownloads = checkForConflicts(selectedLocation, downloads) + val downloads = files.map { DownloadInfo(sourceBucket, it, selectedLocation.resolve(it.fileName())) } + val finalDownloads = checkForConflicts(project, selectedLocation, downloads) downloadAll(project, finalDownloads) } - private fun getDownloadLocation(foldersOnly: Boolean): Path? { - fileDownloadBackDoor?.let { - return it - } - + private fun getDownloadLocation(project: Project, foldersOnly: Boolean): Path? { val baseDir = VfsUtil.getUserHomeDir() val descriptor = if (foldersOnly) { @@ -122,7 +129,7 @@ class DownloadObjectAction @JvmOverloads constructor(private val project: Projec } } - private fun checkForConflicts(targetDirectory: Path, downloads: List): List { + private fun checkForConflicts(project: Project, targetDirectory: Path, downloads: List): List { val finalDownloads = mutableListOf() var skipAll = false @@ -136,7 +143,7 @@ class DownloadObjectAction @JvmOverloads constructor(private val project: Projec continue } - val resolution = promptForConflictResolution(targetDirectory, download, downloads) + val resolution = promptForConflictResolution(project, targetDirectory, download, downloads) if (resolution == SKIP) { LOG.info { "User requested skipping $download" } } else if (resolution == OVERWRITE) { @@ -154,6 +161,7 @@ class DownloadObjectAction @JvmOverloads constructor(private val project: Projec } private fun promptForConflictResolution( + project: Project, targetDirectory: Path, download: DownloadInfo, files: List @@ -187,18 +195,20 @@ class DownloadObjectAction @JvmOverloads constructor(private val project: Projec } private fun downloadAll(project: Project, files: List) { - // TODO: Get off global scope - GlobalScope.launch { + val scope = projectCoroutineScope(project) + scope.launch { try { - files.forEach { (key, output) -> + files.forEach { try { // TODO: Create 1 progress indicator for all files and pass it in - output.outputStream().use { - bucket.download(project, key, it) + it.diskLocation.outputStream().use { os -> + it.sourceBucket.download(project, it.s3Object, it.versionId, os) } + } catch (e: NoSuchBucketException) { + it.sourceBucket.handleDeletedBucket() } catch (e: Exception) { - e.notifyError(message("s3.download.object.failed", key)) - output.deleteIfExists() + e.notifyError(project = project, title = message("s3.download.object.failed", it.s3Object)) + it.diskLocation.deleteIfExists() throw e } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/NewFolderAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/NewFolderAction.kt index ba10ffc682..1c59fa83f8 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/NewFolderAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/NewFolderAction.kt @@ -4,31 +4,48 @@ package software.aws.toolkits.jetbrains.services.s3.objectActions import com.intellij.icons.AllIcons -import com.intellij.openapi.project.Project +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.ui.Messages -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import software.amazon.awssdk.services.s3.model.NoSuchBucketException +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.utils.getRequiredData +import software.aws.toolkits.jetbrains.services.s3.editor.S3EditorDataKeys +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeDirectoryNode import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode -import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable -import software.aws.toolkits.jetbrains.services.s3.editor.getDirectoryKey +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectNode import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.S3Telemetry + +class NewFolderAction : S3ObjectAction(message("s3.new.folder"), AllIcons.Actions.NewFolder) { + override fun performAction(dataContext: DataContext, nodes: List) { + val project = dataContext.getRequiredData(CommonDataKeys.PROJECT) + val treeTable = dataContext.getRequiredData(S3EditorDataKeys.BUCKET_TABLE) + val node = nodes.firstOrNull() ?: treeTable.rootNode + val scope = projectCoroutineScope(project) -class NewFolderAction( - private val project: Project, - treeTable: S3TreeTable -) : SingleS3ObjectAction(treeTable, message("s3.new.folder"), AllIcons.Actions.NewFolder) { - override fun performAction(node: S3TreeNode) { Messages.showInputDialog(project, message("s3.new.folder.name"), message("s3.new.folder"), null)?.let { key -> - GlobalScope.launch { + scope.launch { + var result = Result.Failed try { - treeTable.bucket.newFolder(node.getDirectoryKey() + key) + node.bucket.newFolder(node.directoryPath() + key) treeTable.invalidateLevel(node) treeTable.refresh() + result = Result.Succeeded + } catch (e: NoSuchBucketException) { + node.bucket.handleDeletedBucket() } catch (e: Exception) { - e.notifyError() + e.notifyError(project = project) + } finally { + S3Telemetry.createFolder(project, result) } } - } + } ?: S3Telemetry.createFolder(project, Result.Cancelled) } + + override fun enabled(nodes: List): Boolean = nodes.isEmpty() || + (nodes.size == 1 && nodes.first().let { it is S3TreeObjectNode || it is S3TreeDirectoryNode }) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RefreshSubTreeAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RefreshSubTreeAction.kt deleted file mode 100644 index 3a3e71e3a5..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RefreshSubTreeAction.kt +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.s3.objectActions - -import com.intellij.icons.AllIcons -import com.intellij.ide.util.treeView.TreeState -import com.intellij.openapi.project.DumbAware -import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode -import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable -import software.aws.toolkits.resources.message - -class RefreshSubTreeAction( - treeTable: S3TreeTable -) : SingleS3ObjectAction(treeTable, message("general.refresh"), AllIcons.Actions.Refresh), DumbAware { - override fun performAction(node: S3TreeNode) { - val state = TreeState.createOn(treeTable.tree) - treeTable.invalidateLevel(node) - treeTable.refresh() - state.applyTo(treeTable.tree) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RefreshTreeAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RefreshTreeAction.kt index 0cb4f1458a..1b2f866846 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RefreshTreeAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RefreshTreeAction.kt @@ -5,21 +5,25 @@ package software.aws.toolkits.jetbrains.services.s3.objectActions import com.intellij.icons.AllIcons import com.intellij.ide.util.treeView.TreeState -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.actionSystem.DataContext +import software.aws.toolkits.jetbrains.core.utils.getRequiredData +import software.aws.toolkits.jetbrains.services.s3.editor.S3EditorDataKeys import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeDirectoryNode -import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectNode import software.aws.toolkits.resources.message -class RefreshTreeAction( - private val treeTable: S3TreeTable, - private val rootNode: S3TreeDirectoryNode -) : AnAction(message("general.refresh"), null, AllIcons.Actions.Refresh), DumbAware { - override fun actionPerformed(e: AnActionEvent) { +class RefreshTreeAction : S3ObjectAction(message("general.refresh"), AllIcons.Actions.Refresh) { + override fun performAction(dataContext: DataContext, nodes: List) { + val treeTable = dataContext.getRequiredData(S3EditorDataKeys.BUCKET_TABLE) + val node = nodes.firstOrNull() ?: treeTable.rootNode + val state = TreeState.createOn(treeTable.tree) - rootNode.removeAllChildren() + treeTable.invalidateLevel(node) treeTable.refresh() state.applyTo(treeTable.tree) } + + override fun enabled(nodes: List) = nodes.isEmpty() || + (nodes.size == 1 && nodes.first().let { it is S3TreeObjectNode || it is S3TreeDirectoryNode }) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RenameObjectAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RenameObjectAction.kt index bb91cb3cd6..1c1990d4b6 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RenameObjectAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/RenameObjectAction.kt @@ -3,54 +3,56 @@ package software.aws.toolkits.jetbrains.services.s3.objectActions import com.intellij.icons.AllIcons -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.InputValidator +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.ui.Messages -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import software.amazon.awssdk.services.s3.model.NoSuchBucketException +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.utils.getRequiredData +import software.aws.toolkits.jetbrains.services.s3.editor.S3EditorDataKeys import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectNode -import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.Result import software.aws.toolkits.telemetry.S3Telemetry -class RenameObjectAction( - private val project: Project, - treeTable: S3TreeTable -) : SingleS3ObjectAction(treeTable, message("s3.rename.object.action"), AllIcons.Actions.RefactoringBulb) { +class RenameObjectAction : + SingleS3ObjectAction(message("s3.rename.object.action"), AllIcons.Actions.RefactoringBulb) { - override fun enabled(node: S3TreeNode): Boolean = node is S3TreeObjectNode - - override fun performAction(node: S3TreeNode) { + override fun performAction(dataContext: DataContext, node: S3TreeNode) { + val project = dataContext.getRequiredData(CommonDataKeys.PROJECT) + val treeTable = dataContext.getRequiredData(S3EditorDataKeys.BUCKET_TABLE) val newName = Messages.showInputDialog( project, - message("s3.rename.object.title", node.name), + message("s3.rename.object.title", node.displayName()), message("s3.rename.object.action"), null, - node.name, - object : InputValidator { - override fun checkInput(inputString: String?): Boolean = true - - override fun canClose(inputString: String?): Boolean = checkInput(inputString) - } + node.displayName(), + null ) if (newName == null) { S3Telemetry.renameObject(project, Result.Cancelled) } else { - GlobalScope.launch { + val scope = projectCoroutineScope(project) + scope.launch { try { treeTable.bucket.renameObject(node.key, "${node.parent?.key}$newName") treeTable.invalidateLevel(node) treeTable.refresh() S3Telemetry.renameObject(project, Result.Succeeded) + } catch (e: NoSuchBucketException) { + treeTable.bucket.handleDeletedBucket() + S3Telemetry.renameObject(project, Result.Failed) } catch (e: Exception) { - e.notifyError(message("s3.rename.object.failed")) + e.notifyError(project = project, title = message("s3.rename.object.failed")) S3Telemetry.renameObject(project, Result.Failed) } } } } + + override fun enabled(node: S3TreeNode): Boolean = node is S3TreeObjectNode } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/S3ObjectAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/S3ObjectAction.kt index fe3194086a..0f70b8f7ed 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/S3ObjectAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/S3ObjectAction.kt @@ -4,40 +4,32 @@ package software.aws.toolkits.jetbrains.services.s3.objectActions import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.jetbrains.services.s3.editor.S3EditorDataKeys import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeContinuationNode +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeErrorNode import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode -import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable import javax.swing.Icon -// TODO: The treeTable should be removed, and migrated to DataKey to decouple this from the treeTable -abstract class S3ObjectAction(protected val treeTable: S3TreeTable, title: String, icon: Icon? = null) : DumbAwareAction(title, null, icon) { - protected abstract fun performAction(nodes: List) +abstract class S3ObjectAction(title: String, icon: Icon? = null) : DumbAwareAction(title, null, icon) { + final override fun actionPerformed(e: AnActionEvent) = performAction(e.dataContext, selected(e.dataContext).filter { it !is S3TreeContinuationNode<*> }) - protected open fun enabled(nodes: List): Boolean = nodes.isNotEmpty() - - override fun update(e: AnActionEvent) { - val selected = selected() - e.presentation.isEnabled = selected.none { it is S3TreeContinuationNode } && enabled(selected) - } - - override fun actionPerformed(e: AnActionEvent) = performAction(selected().filter { it !is S3TreeContinuationNode }) - - private fun selected(): List = treeTable.getSelectedNodes().takeIf { it.isNotEmpty() } ?: listOf(treeTable.rootNode) -} - -abstract class SingleS3ObjectAction(treeTable: S3TreeTable, title: String, icon: Icon? = null) : S3ObjectAction(treeTable, title, icon) { + protected abstract fun performAction(dataContext: DataContext, nodes: List) - final override fun performAction(nodes: List) { - if (nodes.size != 1) { - throw IllegalStateException("SingleActionNode should only have a single node, got $nodes") + final override fun update(e: AnActionEvent) { + val bucketViewer = e.dataContext.getData(S3EditorDataKeys.BUCKET_TABLE) + // Disable the action if the bucket viewer is not in our UI hierarchy + if (bucketViewer == null) { + e.presentation.isEnabledAndVisible = false + return } - performAction(nodes.first()) - } - final override fun enabled(nodes: List): Boolean = nodes.size == 1 && enabled(nodes.first()) + val selected = selected(e.dataContext) + e.presentation.isEnabled = selected.none { it is S3TreeContinuationNode<*> || it is S3TreeErrorNode } && enabled(selected) + } - protected abstract fun performAction(node: S3TreeNode) + private fun selected(dataContext: DataContext): List = dataContext.getData(S3EditorDataKeys.SELECTED_NODES) ?: emptyList() - protected open fun enabled(node: S3TreeNode): Boolean = true + protected open fun enabled(nodes: List): Boolean = nodes.isNotEmpty() } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/SingleS3ObjectAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/SingleS3ObjectAction.kt new file mode 100644 index 0000000000..a269b48a14 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/SingleS3ObjectAction.kt @@ -0,0 +1,23 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.s3.objectActions + +import com.intellij.openapi.actionSystem.DataContext +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode +import javax.swing.Icon + +abstract class SingleS3ObjectAction(title: String, icon: Icon? = null) : S3ObjectAction(title, icon) { + final override fun performAction(dataContext: DataContext, nodes: List) { + if (nodes.size != 1) { + throw IllegalStateException("SingleActionNode should only have a single node, got $nodes") + } + performAction(dataContext, nodes.first()) + } + + final override fun enabled(nodes: List): Boolean = nodes.size == 1 && enabled(nodes.first()) + + protected abstract fun performAction(dataContext: DataContext, node: S3TreeNode) + + protected open fun enabled(node: S3TreeNode): Boolean = true +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/UploadObjectAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/UploadObjectAction.kt index f37cf6dd4c..c91d332212 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/UploadObjectAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/UploadObjectAction.kt @@ -3,32 +3,83 @@ package software.aws.toolkits.jetbrains.services.s3.objectActions import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.fileChooser.FileChooserFactory import com.intellij.openapi.project.Project +import com.intellij.util.io.isDirectory +import kotlinx.coroutines.launch +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.utils.getRequiredData +import software.aws.toolkits.jetbrains.services.s3.editor.S3EditorDataKeys import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeDirectoryNode import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeTable +import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.Result import software.aws.toolkits.telemetry.S3Telemetry +import java.nio.file.Path -class UploadObjectAction(private val project: Project, treeTable: S3TreeTable) : - SingleS3ObjectAction(treeTable, message("s3.upload.object.action"), AllIcons.Actions.Upload) { - override fun performAction(node: S3TreeNode) { - val descriptor = - FileChooserDescriptorFactory.createAllButJarContentsDescriptor().withDescription(message("s3.upload.object.action", treeTable.bucket.name)) +class UploadObjectAction : S3ObjectAction(message("s3.upload.object.action"), AllIcons.Actions.Upload) { + override fun performAction(dataContext: DataContext, nodes: List) { + val project = dataContext.getRequiredData(CommonDataKeys.PROJECT) + val treeTable = dataContext.getRequiredData(S3EditorDataKeys.BUCKET_TABLE) + val node = nodes.firstOrNull() ?: treeTable.rootNode + + val descriptor = FileChooserDescriptorFactory.createAllButJarContentsDescriptor() + .withDescription(message("s3.upload.object.action", treeTable.bucket.name)) val chooserDialog = FileChooserFactory.getInstance().createFileChooser(descriptor, project, null) val filesChosen = chooserDialog.choose(project).toList() // If there are no files chosen, the user has cancelled upload if (filesChosen.isEmpty()) { - S3Telemetry.uploadObjects(project, Result.Cancelled) + S3Telemetry.uploadObject(project, Result.Cancelled) return } - treeTable.uploadAndRefresh(filesChosen, node) + uploadObjects(project, treeTable, filesChosen.map { it.toNioPath() }, node) } - override fun enabled(node: S3TreeNode): Boolean = node is S3TreeDirectoryNode + override fun enabled(nodes: List): Boolean = nodes.isEmpty() || + (nodes.size == 1 && nodes.first().let { it is S3TreeDirectoryNode }) +} + +fun uploadObjects(project: Project, treeTable: S3TreeTable, files: List, parentNode: S3TreeNode) { + if (files.isEmpty()) { + return + } + val scope = projectCoroutineScope(project, "UploadObjectAction") + scope.launch { + var changeMade = false + try { + files.forEach { + if (it.isDirectory()) { + notifyError( + title = message("s3.upload.object.failed", it.fileName), + content = message("s3.upload.directory.impossible", it.fileName), + project = project + ) + } else { + try { + treeTable.bucket.upload(project, it, parentNode.directoryPath() + it.fileName) + changeMade = true + } catch (e: Exception) { + e.notifyError(message("s3.upload.object.failed", it.fileName), project) + throw e + } + } + } + + S3Telemetry.uploadObject(project = project, result = Result.Succeeded, value = files.size.toDouble()) + } catch (e: Exception) { + S3Telemetry.uploadObject(project = project, result = Result.Failed, value = files.size.toDouble()) + } finally { + if (changeMade) { + treeTable.invalidateLevel(parentNode) + treeTable.refresh() + } + } + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/ViewObjectVersionAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/ViewObjectVersionAction.kt new file mode 100644 index 0000000000..1ad437edfc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/s3/objectActions/ViewObjectVersionAction.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.jetbrains.services.s3.objectActions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.DataContext +import software.aws.toolkits.jetbrains.core.utils.getRequiredData +import software.aws.toolkits.jetbrains.services.s3.editor.S3EditorDataKeys +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeNode +import software.aws.toolkits.jetbrains.services.s3.editor.S3TreeObjectNode +import software.aws.toolkits.resources.message + +class ViewObjectVersionAction : SingleS3ObjectAction(message("s3.version.history.view"), AllIcons.Actions.ShowAsTree) { + override fun performAction(dataContext: DataContext, node: S3TreeNode) { + if (node is S3TreeObjectNode) { + node.showHistory = true + + // TODO: Can we expand the node too + dataContext.getRequiredData(S3EditorDataKeys.BUCKET_TABLE).refresh() + } + } + + override fun enabled(node: S3TreeNode): Boolean = node::class == S3TreeObjectNode::class +} 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 8966256256..42932314e0 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 @@ -1,8 +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.s3.resources -import com.intellij.openapi.project.Project +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.TestOnly import org.slf4j.event.Level import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.Bucket @@ -12,9 +17,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.AwsConnectionManager -import software.aws.toolkits.jetbrains.core.filter -import software.aws.toolkits.jetbrains.core.map +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider import java.time.Instant import java.time.LocalDateTime @@ -25,30 +28,30 @@ object S3Resources { private val LOG = getLogger() private val regions by lazy { AwsRegionProvider.getInstance().allRegions() } + @TestOnly val LIST_REGIONALIZED_BUCKETS = ClientBackedCachedResource(S3Client::class, "s3.list_buckets") { - listBuckets().buckets().mapNotNull { bucket -> - LOG.tryOrNull("Cannot determine region for ${bucket.name()}", level = Level.WARN) { - regionForBucket(bucket.name()) - }?.let { regions[it] }?.let { - RegionalizedBucket(bucket, it) + val buckets = listBuckets().buckets() + // TODO when the resource cache is coroutine based, remove the runBlocking and withContext + runBlocking { + // withContext is needed to put this on a thread pool + withContext(getCoroutineBgContext()) { + buckets.map { bucket -> + async { + LOG.tryOrNull("Cannot determine region for ${bucket.name()}", level = Level.WARN) { + regionForBucket(bucket.name()) + }?.let { regions[it] }?.let { + RegionalizedBucket(bucket, it) + } + } + }.awaitAll().filterNotNull() } } } - val LIST_BUCKETS: Resource> = LIST_REGIONALIZED_BUCKETS.map { it.bucket } - - fun bucketRegion(bucketName: String): Resource = Resource.View(LIST_REGIONALIZED_BUCKETS) { - find { it.bucket.name() == bucketName }?.region - } - - fun listBucketsByActiveRegion(project: Project): Resource> { - val activeRegion = AwsConnectionManager.getInstance(project).activeRegion - return LIST_REGIONALIZED_BUCKETS.filter { it.region == activeRegion }.map { it.bucket } + val LIST_BUCKETS: Resource> = Resource.View(LIST_REGIONALIZED_BUCKETS) { bucketList, region -> + bucketList.filter { it.region == region }.map { it.bucket } } - @JvmStatic - fun listBucketNamesByActiveRegion(project: Project): Resource> = listBucketsByActiveRegion(project).map { it.name() } - @JvmStatic fun formatDate(date: Instant): String { val datetime = LocalDateTime.ofInstant(date, ZoneId.systemDefault()) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/SchemaViewer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/SchemaViewer.kt index 2e91afd9ab..97a80aff33 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/SchemaViewer.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/SchemaViewer.kt @@ -4,29 +4,25 @@ package software.aws.toolkits.jetbrains.services.schemas import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.intellij.ide.scratch.ScratchFileService import com.intellij.ide.scratch.ScratchRootType import com.intellij.json.JsonLanguage import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.runInEdt import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.util.io.FileUtil import com.intellij.util.ExceptionUtil import software.amazon.awssdk.services.schemas.model.DescribeSchemaResponse +import software.aws.toolkits.core.ConnectionSettings 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.services.schemas.resources.SchemasResources import software.aws.toolkits.jetbrains.utils.notifyError import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.SchemasTelemetry import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage -import javax.swing.JComponent import kotlin.math.min class SchemaViewer( @@ -35,8 +31,8 @@ class SchemaViewer( private val schemaFormatter: SchemaFormatter = SchemaFormatter(), private val schemaPreviewer: SchemaPreviewer = SchemaPreviewer() ) { - fun downloadAndViewSchema(schemaName: String, registryName: String): CompletionStage = - schemaDownloader.getSchemaContent(registryName, schemaName, project = project) + fun downloadAndViewSchema(schemaName: String, registryName: String, connectionSettings: ConnectionSettings): CompletionStage = + schemaDownloader.getSchemaContent(registryName, schemaName, connectionSettings = connectionSettings) .thenCompose { schemaContent -> SchemasTelemetry.download(project, success = true) schemaFormatter.prettySchemaContent(schemaContent.content()) @@ -46,7 +42,8 @@ class SchemaViewer( schemaName, prettySchemaContent, schemaContent.schemaVersion(), - project + project, + connectionSettings ) } } @@ -60,10 +57,10 @@ class SchemaViewer( schemaName: String, registryName: String, version: String?, - component: JComponent - ): CompletionStage = schemaDownloader.getSchemaContent(registryName, schemaName, version, project) + connectionSettings: ConnectionSettings + ): CompletionStage = schemaDownloader.getSchemaContent(registryName, schemaName, version, connectionSettings) .thenCompose { schemaContent -> - schemaFormatter.prettySchemaContent(schemaContent.content(), component) + schemaFormatter.prettySchemaContent(schemaContent.content()) } .exceptionally { error -> notifyError(message("schemas.schema.could_not_open", schemaName), ExceptionUtil.getThrowableText(error), project) @@ -71,50 +68,47 @@ class SchemaViewer( } } -class SchemaDownloader() { - fun getSchemaContent(registryName: String, schemaName: String, version: String? = null, project: Project): CompletionStage { +class SchemaDownloader { + fun getSchemaContent( + registryName: String, + schemaName: String, + version: String? = null, + connectionSettings: ConnectionSettings + ): CompletionStage { val resource = SchemasResources.getSchema(registryName, schemaName, version) - return AwsResourceCache.getInstance(project).getResource(resource) + return AwsResourceCache.getInstance().getResource(resource, connectionSettings) } - fun getSchemaContentAsJson(schemaContent: DescribeSchemaResponse): JsonNode = mapper.readTree(schemaContent.content()) - - companion object { - val mapper = ObjectMapper() - } + fun getSchemaContentAsJson(schemaContent: DescribeSchemaResponse): JsonNode = jacksonObjectMapper().readTree(schemaContent.content()) } -class SchemaFormatter() { - fun prettySchemaContent(rawSchemaContent: String, component: JComponent? = null): CompletionStage { +class SchemaFormatter { + fun prettySchemaContent(rawSchemaContent: String): CompletionStage { val future = CompletableFuture() - runInEdt(if (component == null) ModalityState.any() else ModalityState.stateForComponent(component)) { - try { - val json = mapper.readValue(rawSchemaContent, Any::class.java) - val formatted = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(json) - - future.complete(formatted) - } catch (e: Exception) { - future.completeExceptionally(e) - } + val mapper = jacksonObjectMapper() + try { + val json = mapper.readValue(rawSchemaContent, Any::class.java) + val formatted = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(json) + + future.complete(formatted) + } catch (e: Exception) { + future.completeExceptionally(e) } return future } - - companion object { - val mapper = ObjectMapper() - } } -class SchemaPreviewer() { +class SchemaPreviewer { fun openFileInEditor( registryName: String, schemaName: String, schemaContent: String, version: String, - project: Project + project: Project, + connectionSettings: ConnectionSettings ): CompletionStage { - val credentialIdentifier = project.activeCredentialProvider().displayName - val region = project.activeRegion().id + val credentialIdentifier = connectionSettings.credentials.id + val region = connectionSettings.region.id val fileName = "${credentialIdentifier}_${region}_${registryName}_${schemaName}_$version" val sanitizedFileName = FileUtil.sanitizeFileName(fileName, false) @@ -143,6 +137,7 @@ class SchemaPreviewer() { companion object { const val SCHEMA_EXTENSION = ".json" + const val MAX_FILE_LENGTH = 255 - SCHEMA_EXTENSION.length // min(MAX_FILE_NAME_LENGTH_WINDOWS, MAX_FILE_NAME_LENGTH_MAC) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/Schemas.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/Schemas.kt index f2ef12f82d..2a025403ad 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/Schemas.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/Schemas.kt @@ -5,18 +5,19 @@ package software.aws.toolkits.jetbrains.services.schemas import com.fasterxml.jackson.annotation.JsonProperty import software.amazon.awssdk.services.schemas.model.SchemaSummary -import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup +import software.aws.toolkits.jetbrains.services.lambda.BuiltInRuntimeGroups import software.aws.toolkits.resources.message enum class SchemaCodeLangs( val apiValue: String, val text: String, val extension: String, - val runtimeGroup: RuntimeGroup + val runtimeGroupId: String ) { - JAVA8("Java8", message("schemas.schema.SchemaCodeLangs.JAVA8"), "java", RuntimeGroup.JAVA), - PYTHON3_6("Python36", message("schemas.schema.SchemaCodeLangs.PYTHON3_6"), "py", RuntimeGroup.PYTHON), - TYPESCRIPT("TypeScript3", message("schemas.schema.SchemaCodeLangs.TYPESCRIPT"), "ts", RuntimeGroup.NODEJS); + JAVA8("Java8", message("schemas.schema.language.java8"), "java", BuiltInRuntimeGroups.Java), + PYTHON3_6("Python36", message("schemas.schema.language.python3_6"), "py", BuiltInRuntimeGroups.Python), + TYPESCRIPT("TypeScript3", message("schemas.schema.language.typescript"), "ts", BuiltInRuntimeGroups.NodeJs), + GO1("Go1", message("schemas.schema.language.go1"), "go", BuiltInRuntimeGroups.Go); override fun toString() = text } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/SchemasExplorerNodes.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/SchemasExplorerNodes.kt index 51adebcdb5..b89a02d1b1 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/SchemasExplorerNodes.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/SchemasExplorerNodes.kt @@ -7,18 +7,19 @@ import com.intellij.openapi.project.Project import icons.AwsIcons import software.amazon.awssdk.services.schemas.SchemasClient import software.amazon.awssdk.services.schemas.model.RegistrySummary -import software.aws.toolkits.jetbrains.core.AwsResourceCache import software.aws.toolkits.jetbrains.core.explorer.nodes.AwsExplorerEmptyNode 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 import software.aws.toolkits.jetbrains.core.explorer.nodes.ResourceParentNode +import software.aws.toolkits.jetbrains.core.getResourceNow import software.aws.toolkits.jetbrains.services.schemas.resources.SchemasResources import software.aws.toolkits.resources.message class SchemasServiceNode(project: Project, service: AwsExplorerServiceNode) : CacheBackedAwsExplorerServiceRootNode(project, service, SchemasResources.LIST_REGISTRIES) { + override fun displayName(): String = message("explorer.node.schemas") override fun toNode(child: RegistrySummary): AwsExplorerNode<*> = SchemaRegistryNode(nodeProject, child) } @@ -30,7 +31,8 @@ open class SchemaRegistryNode( SchemasClient.SERVICE_NAME, registry, AwsIcons.Resources.SCHEMA_REGISTRY -), ResourceParentNode { +), + ResourceParentNode { override fun resourceType() = "registry" override fun resourceArn(): String = value.registryArn() ?: value.registryName() @@ -46,9 +48,8 @@ open class SchemaRegistryNode( override fun getChildren(): List> = super.getChildren() override fun getChildrenInternal(): List> { - val resourceCache = AwsResourceCache.getInstance(nodeProject) val registryName = value.registryName() - return resourceCache + return nodeProject .getResourceNow(SchemasResources.listSchemas(registryName)) .map { schema -> SchemaNode(nodeProject, schema.toDataClass(registryName)) } .toList() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/ViewSchemaAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/ViewSchemaAction.kt index 1680727f6b..5c799e3e27 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/ViewSchemaAction.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/ViewSchemaAction.kt @@ -6,11 +6,18 @@ package software.aws.toolkits.jetbrains.services.schemas import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.project.DumbAware import icons.AwsIcons +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.core.credentials.activeCredentialProvider +import software.aws.toolkits.jetbrains.core.credentials.activeRegion import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction import software.aws.toolkits.resources.message -class ViewSchemaAction() : SingleResourceNodeAction(message("schemas.schema.view.action"), null, AwsIcons.Actions.SCHEMA_VIEW), DumbAware { +class ViewSchemaAction : SingleResourceNodeAction(message("schemas.schema.view.action"), null, AwsIcons.Actions.SCHEMA_VIEW), DumbAware { override fun actionPerformed(selected: SchemaNode, e: AnActionEvent) { - SchemaViewer(selected.nodeProject).downloadAndViewSchema(selected.value.name, selected.value.registryName) + SchemaViewer(selected.nodeProject).downloadAndViewSchema( + selected.value.name, + selected.value.registryName, + ConnectionSettings(selected.nodeProject.activeCredentialProvider(), selected.nodeProject.activeRegion()) + ) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaDialog.kt index 3ff4a54e14..1115bcd3dc 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaDialog.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaDialog.kt @@ -16,10 +16,9 @@ import com.intellij.openapi.ui.DialogWrapper import com.intellij.openapi.ui.ValidationInfo import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VfsUtil -import org.apache.commons.lang.exception.ExceptionUtils +import org.apache.commons.lang3.exception.ExceptionUtils import software.amazon.awssdk.services.schemas.model.SchemaVersionSummary -import software.aws.toolkits.jetbrains.core.AwsClientManager -import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.getResourceNow import software.aws.toolkits.jetbrains.core.help.HelpIds import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup import software.aws.toolkits.jetbrains.services.schemas.Schema @@ -34,7 +33,7 @@ import software.aws.toolkits.telemetry.SchemaLanguage import software.aws.toolkits.telemetry.SchemasTelemetry import java.awt.event.ActionEvent import java.io.File -import java.util.ArrayList +import java.nio.file.Path import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage import javax.swing.Action @@ -46,8 +45,8 @@ class DownloadCodeForSchemaDialog( private val project: Project, private val schemaName: String = "", private val registryName: String = "", - private val version: String? = null, - private val language: SchemaCodeLangs? = null, + version: String? = null, + language: SchemaCodeLangs? = null, private val onClose: (() -> Unit)? = null ) : DialogWrapper(project) { @@ -61,8 +60,8 @@ class DownloadCodeForSchemaDialog( val schemaVersions: List val latestVersion: String - val view = DownloadCodeForSchemaPanel(project, this) - val validator = DownloadCodeForSchemaValidator() + val view = DownloadCodeForSchemaPanel(project) + private val validator = DownloadCodeForSchemaValidator() private val action: OkAction = DownloadCodeForSchemaOkAction() @@ -90,29 +89,28 @@ class DownloadCodeForSchemaDialog( private fun getContentRootOfCurrentFile(): String? { // Get the currently open files (plural in case they are split) val selectedFiles = FileEditorManager.getInstance(project).getSelectedFiles() - if (!selectedFiles.isEmpty()) { + if (selectedFiles.isNotEmpty()) { // return the content root of the first selected file return ProjectFileIndex.getInstance(project).getContentRootForFile(selectedFiles.first())?.path } // Otherwise, find the first content root of the project, and return that val contentRoots = ProjectRootManager.getInstance(project).contentRoots - if (!contentRoots.isEmpty()) { - return contentRoots.first()?.path + if (contentRoots.isNotEmpty()) { + return contentRoots.first().path } return null } - private fun loadSchemaVersions(): List = - AwsResourceCache.getInstance(project) - .getResourceNow(SchemasResources.getSchemaVersions(registryName, schemaName)) - .map(SchemaVersionSummary::schemaVersion) - .sortedByDescending { s -> s.toIntOrNull() } + private fun loadSchemaVersions(): List = project + .getResourceNow(SchemasResources.getSchemaVersions(registryName, schemaName)) + .map(SchemaVersionSummary::schemaVersion) + .sortedByDescending { s -> s.toIntOrNull() } private fun getLanguageForCurrentRuntime(): SchemaCodeLangs? { val currentRuntimeGroup = RuntimeGroup.determineRuntimeGroup(project) ?: return null - return SchemaCodeLangs.values().firstOrNull { it.runtimeGroup.equals(currentRuntimeGroup) } + return SchemaCodeLangs.values().firstOrNull { it.runtimeGroupId == currentRuntimeGroup.id } } override fun createCenterPanel(): JComponent? = view.content @@ -136,31 +134,37 @@ class DownloadCodeForSchemaDialog( val schemaCodeDownloadDetails = viewToSchemaCodeDownloadDetails() // Telemetry for download code language - SchemasTelemetry.download(project, success = true, schemalanguage = SchemaLanguage.from(schemaCodeDownloadDetails.language.apiValue)) + SchemasTelemetry.download( + project = project, + success = true, + schemaLanguage = SchemaLanguage.from(schemaCodeDownloadDetails.language.apiValue) + ) val schemaName = schemaCodeDownloadDetails.schema.name - ProgressManager.getInstance().run(object : Task.Backgroundable(project, message("schemas.schema.download_code_bindings.title", schemaName), false) { - override fun run(indicator: ProgressIndicator) { - notifyInfo( - title = NOTIFICATION_TITLE, - content = message("schemas.schema.download_code_bindings.notification.start", schemaName), - project = project - ) - - schemaCodeDownloader.downloadCode(schemaCodeDownloadDetails, indicator) - .thenCompose { schemaCoreCodeFile -> - refreshDownloadCodeDirectory(schemaCodeDownloadDetails) - openSchemaCoreCodeFileInEditor(schemaCoreCodeFile, project) - } - .thenApply { - showDownloadCompletionNotification(schemaName, project) - } - .exceptionally { error -> - showDownloadCompletionErrorNotification(error, project) - } - .toCompletableFuture().get() + ProgressManager.getInstance().run( + object : Task.Backgroundable(project, message("schemas.schema.download_code_bindings.title", schemaName), false) { + override fun run(indicator: ProgressIndicator) { + notifyInfo( + title = NOTIFICATION_TITLE, + content = message("schemas.schema.download_code_bindings.notification.start", schemaName), + project = project + ) + + schemaCodeDownloader.downloadCode(schemaCodeDownloadDetails, indicator) + .thenCompose { schemaCoreCodeFile -> + refreshDownloadCodeDirectory(schemaCodeDownloadDetails) + openSchemaCoreCodeFileInEditor(schemaCoreCodeFile, project) + } + .thenApply { + showDownloadCompletionNotification(schemaName, project) + } + .exceptionally { error -> + showDownloadCompletionErrorNotification(error, project) + } + .toCompletableFuture().get() + } } - }) + ) onClose?.let { it() } @@ -168,11 +172,9 @@ class DownloadCodeForSchemaDialog( } private fun refreshDownloadCodeDirectory(schemaCodeDownloadDetails: SchemaCodeDownloadRequestDetails) { - val file = File(schemaCodeDownloadDetails.destinationDirectory) - - // Don't replace this with LocalFileSystem.getInstance().refreshIoFiles(listOf(file)) - it doesn't work. - val vFile = LocalFileSystem.getInstance().findFileByIoFile(file) - VfsUtil.markDirtyAndRefresh(false, true, true, vFile) + LocalFileSystem.getInstance().findFileByIoFile(schemaCodeDownloadDetails.destinationDirectory)?.let { + VfsUtil.markDirtyAndRefresh(false, true, true, it) + } } private fun showDownloadCompletionNotification( @@ -188,8 +190,7 @@ class DownloadCodeForSchemaDialog( error: Throwable?, project: Project ) { - val rootError = ExceptionUtils.getRootCause(error) - when (rootError) { + when (val rootError = ExceptionUtils.getRootCause(error)) { is SchemaCodeDownloadFileCollisionException -> notifyError(title = NOTIFICATION_TITLE, content = rootError.message ?: "", project = project) is Exception -> rootError.notifyError(title = NOTIFICATION_TITLE, project = project) } @@ -197,14 +198,14 @@ class DownloadCodeForSchemaDialog( } private fun openSchemaCoreCodeFileInEditor( - schemaCoreCodeFile: File?, + schemaCoreCodeFile: Path?, project: Project ): CompletionStage { val future = CompletableFuture() ApplicationManager.getApplication().invokeLater { try { schemaCoreCodeFile?.let { - val vSchemaCoreCodeFileName = LocalFileSystem.getInstance().findFileByIoFile(schemaCoreCodeFile) + val vSchemaCoreCodeFileName = LocalFileSystem.getInstance().findFileByNioFile(schemaCoreCodeFile) vSchemaCoreCodeFileName?.let { val fileEditorManager = FileEditorManager.getInstance(project) fileEditorManager.openTextEditor(OpenFileDescriptor(project, it), true) @@ -224,7 +225,7 @@ class DownloadCodeForSchemaDialog( schema = SchemaSummary(this.schemaName, this.registryName), version = getSelectedVersion(), language = view.language.selected()!!, - destinationDirectory = view.location.text + destinationDirectory = File(view.location.text) ) private fun getSelectedVersion(): String { @@ -244,7 +245,7 @@ class DownloadCodeForSchemaDialog( super.doAction(e) if (doValidateAll().isNotEmpty()) return - downloadSchemaCode(SchemaCodeDownloader.create(AwsClientManager.getInstance(project))) + downloadSchemaCode(SchemaCodeDownloader.create(project)) } } @@ -263,12 +264,13 @@ class DownloadCodeForSchemaValidator { return ValidationInfo(message("schemas.schema.download_code_bindings.validation.language_required"), view.language) } - val locationText = view.location.getText() - if (locationText.isNullOrEmpty()) { + val locationText = view.location.text + if (locationText.isEmpty()) { return ValidationInfo(message("schemas.schema.download_code_bindings.validation.fileLocation_required"), view.location) } + val file = File(locationText) - if (!file.exists() || !file.isDirectory()) { + if (!file.exists() || !file.isDirectory) { return ValidationInfo(message("schemas.schema.download_code_bindings.validation.fileLocation_invalid"), view.location) } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.form index 03b13f5065..f8681fd081 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.form @@ -1,6 +1,6 @@
- + @@ -12,11 +12,11 @@ - + - - + + @@ -24,18 +24,18 @@ - - + + - + - + @@ -43,8 +43,8 @@ - - + + @@ -52,18 +52,18 @@ - - + + - + - + @@ -73,10 +73,10 @@ - + - + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.java deleted file mode 100644 index 6344230548..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.java +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.schemas.code; - -import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.ComboBox; -import com.intellij.openapi.ui.TextComponentAccessor; -import com.intellij.openapi.ui.TextFieldWithBrowseButton; -import com.intellij.ui.SortedComboBoxModel; - -import java.util.Collection; -import java.util.Comparator; -import javax.swing.DefaultComboBoxModel; -import javax.swing.JComboBox; -import javax.swing.JLabel; -import javax.swing.JPanel; - -import org.jetbrains.annotations.NotNull; -import software.aws.toolkits.jetbrains.services.schemas.SchemaCodeLangs; -import software.aws.toolkits.jetbrains.ui.ProjectFileBrowseListener; - -@SuppressWarnings("NullableProblems") -public class DownloadCodeForSchemaPanel { - @NotNull JLabel heading; - @NotNull JComboBox version; - @NotNull JComboBox language; - @NotNull JPanel content; - - @NotNull TextFieldWithBrowseButton location; - - private DefaultComboBoxModel versionModel; - private SortedComboBoxModel languageModel; - private final Project project; - private final DownloadCodeForSchemaDialog dialog; - - DownloadCodeForSchemaPanel(Project project, DownloadCodeForSchemaDialog dialog) { - this.project = project; - this.dialog = dialog; - - location.addActionListener(new ProjectFileBrowseListener<>( - project, - location, - FileChooserDescriptorFactory.createSingleFolderDescriptor(), - TextComponentAccessor.TEXT_FIELD_WHOLE_TEXT - )); - } - - private void createUIComponents() { - versionModel = new DefaultComboBoxModel<>(); - version = new ComboBox<>(versionModel); - - languageModel = new SortedComboBoxModel<>(Comparator.comparing(SchemaCodeLangs::toString, Comparator.naturalOrder())); - language = new ComboBox<>(languageModel); - } - - public void setLanguages(Collection languages) { - languageModel.setAll(languages); - } - - public void setVersions(Collection versions) { - versionModel.removeAllElements(); - versions.forEach(version -> versionModel.addElement(version)); - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.kt new file mode 100644 index 0000000000..a88d30e469 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/DownloadCodeForSchemaPanel.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.services.schemas.code + +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.SortedComboBoxModel +import software.aws.toolkits.jetbrains.services.schemas.SchemaCodeLangs +import software.aws.toolkits.jetbrains.ui.installTextFieldProjectFileBrowseListener +import java.util.Comparator +import javax.swing.DefaultComboBoxModel +import javax.swing.JComboBox +import javax.swing.JLabel +import javax.swing.JPanel + +class DownloadCodeForSchemaPanel(project: Project) { + lateinit var content: JPanel + private set + lateinit var heading: JLabel + private set + lateinit var version: JComboBox + private set + lateinit var language: JComboBox + private set + lateinit var location: TextFieldWithBrowseButton + private set + private lateinit var versionModel: DefaultComboBoxModel + private lateinit var languageModel: SortedComboBoxModel + + private fun createUIComponents() { + versionModel = DefaultComboBoxModel() + version = ComboBox(versionModel) + languageModel = SortedComboBoxModel(compareBy(Comparator.naturalOrder()) { it.toString() }) + language = ComboBox(languageModel) + } + + init { + installTextFieldProjectFileBrowseListener( + project, + location, + FileChooserDescriptorFactory.createSingleFolderDescriptor() + ) + } + + fun setLanguages(languages: List) { + languageModel.setAll(languages) + } + + fun setVersions(versions: List) { + versionModel.removeAllElements() + versionModel.addAll(versions) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/SchemaCodeDownload.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/SchemaCodeDownload.kt index f5b49f55a3..b59e663995 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/SchemaCodeDownload.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/SchemaCodeDownload.kt @@ -5,13 +5,14 @@ package software.aws.toolkits.jetbrains.services.schemas.code import software.aws.toolkits.jetbrains.services.schemas.SchemaCodeLangs import software.aws.toolkits.jetbrains.services.schemas.SchemaSummary +import java.io.File import java.nio.ByteBuffer data class SchemaCodeDownloadRequestDetails( val schema: SchemaSummary, val version: String, val language: SchemaCodeLangs, - val destinationDirectory: String + val destinationDirectory: File ) { // TODO: This is far from reliable, and won't work if the schema has special characters, // and should either be generated using SchemaCodeGenUtils or provided from the server via metadata in DescribeCodeBindings diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/SchemaCodeDownloader.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/SchemaCodeDownloader.kt index cf16cfc893..bb5e9cd960 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/SchemaCodeDownloader.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/code/SchemaCodeDownloader.kt @@ -5,6 +5,7 @@ package software.aws.toolkits.jetbrains.services.schemas.code import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.Project import com.intellij.util.io.Decompressor import software.amazon.awssdk.services.schemas.SchemasClient import software.amazon.awssdk.services.schemas.model.CodeGenerationStatus @@ -13,13 +14,14 @@ import software.amazon.awssdk.services.schemas.model.DescribeCodeBindingRequest import software.amazon.awssdk.services.schemas.model.GetCodeBindingSourceRequest import software.amazon.awssdk.services.schemas.model.NotFoundException import software.amazon.awssdk.services.schemas.model.PutCodeBindingRequest -import software.aws.toolkits.core.ToolkitClientManager -import software.aws.toolkits.core.utils.wait +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager import software.aws.toolkits.resources.message import java.io.File import java.io.FileOutputStream +import java.nio.file.Path import java.nio.file.Paths -import java.time.Duration import java.util.Collections import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage @@ -29,16 +31,14 @@ class SchemaCodeDownloader( private val generator: CodeGenerator, private val poller: CodeGenerationStatusPoller, private val downloader: CodeDownloader, - private val extractor: CodeExtractor, - private val progressUpdater: ProgressUpdater + private val extractor: CodeExtractor ) { fun downloadCode( schemaDownloadRequest: SchemaCodeDownloadRequestDetails, indicator: ProgressIndicator - ): CompletionStage { - + ): CompletionStage { val schemaName = schemaDownloadRequest.schema.name - progressUpdater.updateProgress(indicator, message("schemas.schema.download_code_bindings.notification.start", schemaName)) + indicator.updateProgress(message("schemas.schema.download_code_bindings.notification.start", schemaName)) return downloader.download(schemaDownloadRequest) // Try to get code directly .handle { downloadedSchemaCode, exception -> @@ -47,38 +47,49 @@ class SchemaCodeDownloader( !is NotFoundException -> throw exception // Unexpected exception, throw } - progressUpdater.updateProgress(indicator, message("schemas.schema.download_code_bindings.notification.generating", schemaName)) + indicator.updateProgress(message("schemas.schema.download_code_bindings.notification.generating", schemaName)) generator.generate(schemaDownloadRequest) // If the code generation status wasn't previously requested, trigger it now .thenCompose { initialCodeGenerationStatus -> poller.pollForCompletion(schemaDownloadRequest, initialCodeGenerationStatus) // Then, poll for completion } .thenCompose { - progressUpdater.updateProgress(indicator, message("schemas.schema.download_code_bindings.notification.downloading", schemaName)) + indicator.updateProgress(message("schemas.schema.download_code_bindings.notification.downloading", schemaName)) downloader.download(schemaDownloadRequest) // Download the code zip file } .toCompletableFuture().get() } .thenCompose { code -> - progressUpdater.updateProgress(indicator, message("schemas.schema.download_code_bindings.notification.extracting", schemaName)) + indicator.updateProgress(message("schemas.schema.download_code_bindings.notification.extracting", schemaName)) extractor.extractAndPlace(schemaDownloadRequest, code) // Extract and place in workspace } } + private fun ProgressIndicator.updateProgress(newStatus: String) { + text = newStatus + isIndeterminate = true + } + companion object { - fun create(clientManager: ToolkitClientManager): SchemaCodeDownloader = SchemaCodeDownloader( - CodeGenerator(clientManager.getClient()), - CodeGenerationStatusPoller(clientManager.getClient()), - CodeDownloader(clientManager.getClient()), - CodeExtractor(), - ProgressUpdater() - ) + fun create(project: Project): SchemaCodeDownloader { + val connectionSettings = AwsConnectionManager.getInstance(project).connectionSettings() + ?: throw IllegalStateException("Attempting to use SchemaCodeDownload without valid AWS connection") + return create(connectionSettings) + } + + fun create(connectionSettings: ConnectionSettings): SchemaCodeDownloader { + val clientManager = AwsClientManager.getInstance() + return SchemaCodeDownloader( + CodeGenerator(clientManager.getClient(connectionSettings.credentials, connectionSettings.region)), + CodeGenerationStatusPoller(clientManager.getClient(connectionSettings.credentials, connectionSettings.region)), + CodeDownloader(clientManager.getClient(connectionSettings.credentials, connectionSettings.region)), + CodeExtractor() + ) + } } } class CodeGenerator(private val schemasClient: SchemasClient) { - fun generate( - schemaDownload: SchemaCodeDownloadRequestDetails - ): CompletionStage { + fun generate(schemaDownload: SchemaCodeDownloadRequestDetails): CompletionStage { val future = CompletableFuture() ApplicationManager.getApplication().executeOnPooledThread { try { @@ -101,11 +112,7 @@ class CodeGenerator(private val schemasClient: SchemasClient) { } } -class CodeGenerationStatusPoller( - private val schemasClient: SchemasClient, - private val pollingSettings: PollingSettings = PollingSettings(DEFAULT_POLLING_DELAY, DEFAULT_POLLING_MAX_ATTEMPTS) -) { - +class CodeGenerationStatusPoller(private val schemasClient: SchemasClient) { fun pollForCompletion( schemaDownload: SchemaCodeDownloadRequestDetails, initialCodeGenerationStatus: CodeGenerationStatus = CodeGenerationStatus.CREATE_IN_PROGRESS @@ -114,22 +121,12 @@ class CodeGenerationStatusPoller( ApplicationManager.getApplication().executeOnPooledThread { try { if (initialCodeGenerationStatus != CodeGenerationStatus.CREATE_COMPLETE) { - wait( - call = { getCurrentStatus(schemaDownload).toCompletableFuture().get() }, - success = { codeGenerationStatus -> codeGenerationStatus == CodeGenerationStatus.CREATE_COMPLETE }, - fail = { codeGenerationStatus -> - if (codeGenerationStatus != CodeGenerationStatus.CREATE_IN_PROGRESS && - codeGenerationStatus != CodeGenerationStatus.CREATE_COMPLETE - ) { - message("schemas.schema.download_code_bindings.invalid_code_generation_status", codeGenerationStatus) - } else { - null - } - }, - timeoutErrorMessage = message("schemas.schema.download_code_bindings.timeout", schemaDownload.schema.name), - attempts = pollingSettings.maxAttempts, - delay = pollingSettings.delay - ) + schemasClient.waiter().waitUntilCodeBindingExists { + it.registryName(schemaDownload.schema.registryName) + it.schemaName(schemaDownload.schema.name) + it.schemaVersion(schemaDownload.version) + it.language(schemaDownload.language.apiValue) + } } future.complete(schemaDownload.schema.name) @@ -164,16 +161,6 @@ class CodeGenerationStatusPoller( } return future } - - companion object { - private val DEFAULT_POLLING_DELAY: Duration = Duration.ofSeconds(2) - private val DEFAULT_POLLING_MAX_ATTEMPTS: Int = 30 // p90 of code generation for most use cases [O(schemaSize)] is ~45 seconds. Retry for 60 seconds - } - - data class PollingSettings( - val delay: Duration, - val maxAttempts: Int - ) } class CodeDownloader(private val schemasClient: SchemasClient) { @@ -208,15 +195,14 @@ class CodeExtractor { fun extractAndPlace( request: SchemaCodeDownloadRequestDetails, downloadedSchemaCode: DownloadedSchemaCode - ): CompletionStage { - + ): CompletionStage { val zipContents = downloadedSchemaCode.zipContents val zipFileName = "${request.schema.registryName}.${request.schema.name}.${request.version}.${request.language.apiValue}.zip" val schemaCoreCodeFileName = request.schemaCoreCodeFileName() - var schemaCoreCodeFile: File? = null + var schemaCoreCodeFile: Path? = null - val future = CompletableFuture() + val future = CompletableFuture() try { val codeZipDir = createTempDir() @@ -226,17 +212,16 @@ class CodeExtractor { fileChannel.write(zipContents) } - val destinationDirectory = File(request.destinationDirectory) - validateNoFileCollisions(codeZipFile, destinationDirectory) + validateNoFileCollisions(codeZipFile, request.destinationDirectory) val decompressor = Decompressor.Zip(codeZipFile) .overwrite(false) - .postprocessor { file -> - if (schemaCoreCodeFile == null && file.name.equals(schemaCoreCodeFileName)) { - schemaCoreCodeFile = file + .postProcessor { path -> + if (schemaCoreCodeFile == null && path.fileName.toString() == schemaCoreCodeFileName) { + schemaCoreCodeFile = path } } - decompressor.extract(destinationDirectory) + decompressor.extract(request.destinationDirectory) future.complete(schemaCoreCodeFile) } catch (e: Exception) { @@ -247,36 +232,19 @@ class CodeExtractor { // Ensure that the downloaded code hierarchy has no collisions with the destination directory private fun validateNoFileCollisions(codeZipFile: File, destinationDirectory: File) { - ZipFile(codeZipFile).use({ zipFile -> + ZipFile(codeZipFile).use { zipFile -> val zipEntries = zipFile.entries() Collections.list(zipEntries).forEach { zipEntry -> - if (zipEntry.isDirectory()) { - // Ignore directories because those can/will be merged - } else { + // Ignore directories because those can/will be merged + if (!zipEntry.isDirectory) { val intendedDestinationPath = Paths.get(destinationDirectory.path, zipEntry.name) val intendedDestinationFile = intendedDestinationPath.toFile() if (intendedDestinationFile.exists() && !intendedDestinationFile.isDirectory) { - val name = intendedDestinationFile.name - throw SchemaCodeDownloadFileCollisionException(name) + throw SchemaCodeDownloadFileCollisionException(intendedDestinationFile.name) } } } - }) - } -} - -class ProgressUpdater { - fun updateProgress( - indicator: ProgressIndicator, - newStatus: String - ): CompletionStage { - - indicator.text = newStatus - indicator.setIndeterminate(true) - - val future = CompletableFuture() - future.complete(null) - return future + } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/resources/SchemasResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/resources/SchemasResources.kt index 1224dea3f1..75cf39cb02 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/resources/SchemasResources.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/resources/SchemasResources.kt @@ -10,15 +10,12 @@ import software.amazon.awssdk.services.schemas.model.SchemaSummary import software.amazon.awssdk.services.schemas.model.SchemaVersionSummary import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource import software.aws.toolkits.jetbrains.core.Resource -import software.aws.toolkits.jetbrains.ui.wizard.SchemaSelectionItem +import software.aws.toolkits.jetbrains.services.lambda.wizard.SchemaSelectionItem import java.time.Duration -import kotlin.streams.toList object SchemasResources { - @JvmField - val AWS_EVENTS_REGISTRY = "aws.events" + const val AWS_EVENTS_REGISTRY = "aws.events" - @JvmField val LIST_REGISTRIES: Resource.Cached> = ClientBackedCachedResource(SchemasClient::class, "schemas.list_registries") { listRegistriesPaginator { it.build() } @@ -27,7 +24,6 @@ object SchemasResources { .toList() } - @JvmField val LIST_REGISTRIES_AND_SCHEMAS: Resource.Cached> = ClientBackedCachedResource(SchemasClient::class, "schemas.list_registries_and_schemas") { listRegistriesPaginator { it.build() } @@ -42,7 +38,7 @@ object SchemasResources { val schemaSelectionItems = ArrayList() schemaSelectionItems.add(SchemaSelectionItem.RegistryItem(registryName)) - schemas.forEach() { schemaSelectionItems.add(SchemaSelectionItem.SchemaItem(it.schemaName(), registryName)) } + schemas.forEach { schemaSelectionItems.add(SchemaSelectionItem.SchemaItem(it.schemaName(), registryName)) } schemaSelectionItems } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchDialog.kt index 5e89a67a51..ebbc4f5596 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchDialog.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchDialog.kt @@ -49,11 +49,11 @@ class SchemaSearchAllRegistriesDialog( schemaViewer: SchemaViewer = SchemaViewer(project), onCancelCallback: (SchemaSearchDialogState) -> Unit ) : SchemasSearchDialogBase( - project, - schemaViewer, - message("schemas.search.header.text.allRegistries"), - onCancelCallback - ) { + project, + schemaViewer, + message("schemas.search.header.text.allRegistries"), + onCancelCallback +) { override fun createResultRenderer(): (SchemaSearchResultWithRegistry) -> JComponent = { JBLabel("${it.registry}/${it.name}") diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchExecutor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchExecutor.kt index 2a1dc9d433..eec4dc864b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchExecutor.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchExecutor.kt @@ -10,13 +10,13 @@ import software.amazon.awssdk.services.schemas.SchemasClient import software.amazon.awssdk.services.schemas.model.SearchSchemasRequest import software.amazon.awssdk.services.schemas.model.SearchSchemasResponse import software.aws.toolkits.core.utils.warn -import software.aws.toolkits.jetbrains.core.AwsClientManager -import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.getResource import software.aws.toolkits.jetbrains.services.schemas.resources.SchemasResources class SchemaSearchExecutor( private val project: Project, - private val schemasClient: SchemasClient = AwsClientManager.getInstance(project).getClient() + private val schemasClient: SchemasClient = project.awsClient() ) { fun searchSchemasInRegistry( registryName: String, @@ -40,7 +40,7 @@ class SchemaSearchExecutor( incrementalResultsCallback: OnSearchResultReturned, registrySearchErrorCallback: OnSearchResultError ) { - AwsResourceCache.getInstance(project).getResource(SchemasResources.LIST_REGISTRIES) + project.getResource(SchemasResources.LIST_REGISTRIES) .thenApply { it.forEach { registry -> val registryName = registry.registryName() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchResults.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchResults.kt index dcd0e7c040..df199d33f3 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchResults.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/schemas/search/SchemaSearchResults.kt @@ -13,6 +13,4 @@ data class SchemaSearchResultWithRegistry( val registry: String ) -data class SchemaSearchResultVersion(val version: String) - data class SchemaSearchError(val registryName: String, val errorMessage: String) 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 3f51531b28..4adae29d52 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 @@ -16,6 +16,9 @@ import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.Alarm +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.jetbrains.core.credentials.activeCredentialProvider +import software.aws.toolkits.jetbrains.core.credentials.activeRegion import software.aws.toolkits.jetbrains.core.help.HelpIds import software.aws.toolkits.jetbrains.services.schemas.SchemaViewer import software.aws.toolkits.jetbrains.services.schemas.code.DownloadCodeForSchemaDialog @@ -51,20 +54,15 @@ abstract class SchemasSearchDialogBase( private val headerText: String, private val onCancelCallback: (SchemaSearchDialogState) -> Unit ) : SchemaSearchDialog, DialogWrapper(project) { - - private val DEFAULT_PADDING = 10 - private val SEARCH_DELAY_MS = 300L - private val HIGHLIGHT_COLOR = Color.YELLOW - val searchTextField = JTextField() private val searchTextAlarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this.disposable) val resultsModel = DefaultListModel() - val resultsList = JBList(resultsModel) + val resultsList = JBList(resultsModel) private val resultsLock = ReentrantLock() val versionsModel: DefaultComboBoxModel = DefaultComboBoxModel() - val versionsCombo = ComboBox(versionsModel) + val versionsCombo = ComboBox(versionsModel) val previewText = JTextArea() @@ -118,39 +116,44 @@ abstract class SchemasSearchDialogBase( } } - searchTextField.document.addDocumentListener(object : DocumentListener { - override fun changedUpdate(e: DocumentEvent?) = search() - override fun insertUpdate(e: DocumentEvent?) = search() - override fun removeUpdate(e: DocumentEvent?) = search() + searchTextField.document.addDocumentListener( + object : DocumentListener { + override fun changedUpdate(e: DocumentEvent?) = search() + override fun insertUpdate(e: DocumentEvent?) = search() + override fun removeUpdate(e: DocumentEvent?) = search() - private fun search() { - if (searchTextAlarm.isDisposed) return + private fun search() { + if (searchTextAlarm.isDisposed) return - searchTextAlarm.cancelAllRequests() + searchTextAlarm.cancelAllRequests() - searchTextAlarm.addRequest({ - val searchText = searchTextField.text + searchTextAlarm.addRequest( + { + val searchText = searchTextField.text - if (searchText.isNullOrEmpty()) { - clearState() - return@addRequest - } + if (searchText.isNullOrEmpty()) { + clearState() + return@addRequest + } - clearState() - resultsList.setEmptyText(message("schemas.search.searching")) - searchSchemas(searchText, { onSearchResultsReturned(it) }, { onErrorSearchingRegistry(it) }) - }, SEARCH_DELAY_MS) - } + clearState() + resultsList.setEmptyText(message("schemas.search.searching")) + searchSchemas(searchText, { onSearchResultsReturned(it) }, { onErrorSearchingRegistry(it) }) + }, + SEARCH_DELAY_MS + ) + } - private fun clearState() { - previewText.text = "" - resultsList.setEmptyText(message("schemas.search.no_results")) - getDownloadButton()?.isEnabled = false - resultsModel.removeAllElements() - versionsModel.removeAllElements() - currentSearchErrors.clear() + private fun clearState() { + previewText.text = "" + resultsList.setEmptyText(message("schemas.search.no_results")) + getDownloadButton()?.isEnabled = false + resultsModel.removeAllElements() + versionsModel.removeAllElements() + currentSearchErrors.clear() + } } - }) + ) } private fun onSearchResultsReturned(searchResults: List) { @@ -312,7 +315,7 @@ abstract class SchemasSearchDialogBase( abstract fun createResultRenderer(): (SchemaSearchResultWithRegistry) -> JComponent private fun downloadSchemaContent(schema: SchemaSearchResultWithRegistry, version: String): CompletionStage = - schemaViewer.downloadPrettySchema(schema.name, schema.registry, version, contentPanel) + schemaViewer.downloadPrettySchema(schema.name, schema.registry, version, ConnectionSettings(project.activeCredentialProvider(), project.activeRegion())) abstract fun searchSchemas( searchText: String, @@ -367,4 +370,10 @@ abstract class SchemasSearchDialogBase( doCancelAction() } } + + private companion object { + private const val DEFAULT_PADDING = 10 + private const val SEARCH_DELAY_MS = 300L + private val HIGHLIGHT_COLOR = Color.YELLOW + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sns/resources/SnsResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sns/resources/SnsResources.kt new file mode 100644 index 0000000000..80b5afae09 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sns/resources/SnsResources.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.sns.resources + +import software.amazon.awssdk.services.sns.SnsClient +import software.amazon.awssdk.services.sns.model.Topic +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource +import software.aws.toolkits.jetbrains.core.Resource + +object SnsResources { + val LIST_TOPICS: Resource.Cached> = ClientBackedCachedResource(SnsClient::class, "sns.list_topics") { + listTopicsPaginator().topics().toList() + } +} + +// SNS topic ARNs are in the format: arn::sns::: and cannot contain ':' +fun Topic.getName(): String = topicArn().substringAfterLast(':') diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfigureLambdaDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfigureLambdaDialog.kt new file mode 100644 index 0000000000..34bb72d481 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfigureLambdaDialog.kt @@ -0,0 +1,158 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs + +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.iam.IamClient +import software.amazon.awssdk.services.lambda.LambdaClient +import software.amazon.awssdk.services.lambda.model.InvalidParameterValueException +import software.amazon.awssdk.services.lambda.model.LambdaException +import software.amazon.awssdk.services.lambda.model.ResourceConflictException +import software.amazon.awssdk.services.lambda.model.ResourceNotFoundException +import software.amazon.awssdk.services.lambda.model.ServiceException +import software.aws.toolkits.core.utils.WaiterTimeoutException +import software.aws.toolkits.core.utils.Waiters.waitUntil +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SqsTelemetry +import java.time.Duration +import javax.swing.JComponent + +class ConfigureLambdaDialog( + private val project: Project, + private val queue: Queue +) : DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + private val lambdaClient: LambdaClient = project.awsClient() + private val iamClient: IamClient = project.awsClient() + val view = ConfigureLambdaPanel(project) + + init { + title = message("sqs.configure.lambda") + setOKButtonText(message("general.configure_button")) + setOKButtonTooltip(message("sqs.configure.lambda.configure.tooltip")) + + init() + } + + override fun createCenterPanel(): JComponent? = view.component + + override fun getPreferredFocusedComponent(): JComponent? = view.lambdaFunction + + override fun doValidate(): ValidationInfo? { + if (functionSelected().isEmpty()) { + return ValidationInfo(message("sqs.configure.lambda.validation.function"), view.lambdaFunction) + } + return null + } + + override fun doCancelAction() { + SqsTelemetry.configureLambdaTrigger(project, Result.Cancelled, queue.telemetryType()) + super.doCancelAction() + } + + override fun doOKAction() { + if (!isOKActionEnabled) { + return + } + + isOKActionEnabled = false + setOKButtonText(message("sqs.configure.lambda.in_progress")) + + coroutineScope.launch { + try { + configureLambda(functionSelected()) + runInEdt(ModalityState.any()) { + close(OK_EXIT_CODE) + } + notifyInfo(message("sqs.service_name"), message("sqs.configure.lambda.success", functionSelected()), project) + SqsTelemetry.configureLambdaTrigger(project, Result.Succeeded, queue.telemetryType()) + } catch (e: InvalidParameterValueException) { // Exception thrown for invalid permission + // DO NOT change to withCoroutineUiContext, it breaks the panel with the wrong state + runInEdt(ModalityState.any()) { + if (ConfirmIamPolicyDialog(project, iamClient, lambdaClient, functionSelected(), queue, view.component).showAndGet()) { + retryConfiguration(functionSelected()) + } else { + setOKButtonText(message("general.configure_button")) + isOKActionEnabled = true + } + } + } catch (e: Exception) { + LOG.warn(e) { message("sqs.configure.lambda.error", functionSelected()) } + setErrorText(e.message) + setOKButtonText(message("general.configure_button")) + isOKActionEnabled = true + SqsTelemetry.configureLambdaTrigger(project, Result.Failed, queue.telemetryType()) + } + } + } + + private fun functionSelected(): String = view.lambdaFunction.selected()?.functionName() ?: "" + + internal fun configureLambda(functionName: String) { + lambdaClient.createEventSourceMapping { + it.functionName(functionName) + it.eventSourceArn(queue.arn) + } + } + + // It takes a few seconds for the role policy to update, so this function will attempt configuration for a duration of time until it succeeds. + internal suspend fun waitUntilConfigured(functionName: String): String? { + var identifier: String? = null + try { + waitUntil( + succeedOn = { + it.eventSourceArn().isNotEmpty() + }, + exceptionsToIgnore = setOf(InvalidParameterValueException::class), + exceptionsToStopOn = setOf(LambdaException::class, ResourceConflictException::class, ResourceNotFoundException::class, ServiceException::class), + maxDuration = Duration.ofSeconds(CONFIGURATION_WAIT_TIME), + call = { + lambdaClient.createEventSourceMapping { + it.functionName(functionName) + it.eventSourceArn(queue.arn) + }.apply { + identifier = this.uuid() + } + } + ) + } catch (e: WaiterTimeoutException) { + identifier = null + } + return identifier + } + + private fun retryConfiguration(functionName: String) { + coroutineScope.launch { + val identifier = waitUntilConfigured(functionName) + if (!identifier.isNullOrEmpty()) { + runInEdt(ModalityState.any()) { + close(OK_EXIT_CODE) + } + notifyInfo(message("sqs.service_name"), message("sqs.configure.lambda.success", functionName), project) + SqsTelemetry.configureLambdaTrigger(project, Result.Succeeded, queue.telemetryType()) + } else { + setErrorText(message("sqs.configure.lambda.error", functionName)) + setOKButtonText(message("general.configure_button")) + isOKActionEnabled = true + SqsTelemetry.configureLambdaTrigger(project, Result.Failed, queue.telemetryType()) + } + } + } + + private companion object { + val LOG = getLogger() + const val CONFIGURATION_WAIT_TIME: Long = 30 + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfigureLambdaPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfigureLambdaPanel.form new file mode 100644 index 0000000000..da143041ab --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfigureLambdaPanel.form @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfigureLambdaPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfigureLambdaPanel.kt new file mode 100644 index 0000000000..a1eb4fd454 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfigureLambdaPanel.kt @@ -0,0 +1,39 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.sqs + +import com.intellij.icons.AllIcons +import com.intellij.ide.HelpTooltip +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleListCellRenderer +import software.amazon.awssdk.services.lambda.model.FunctionConfiguration +import software.aws.toolkits.jetbrains.services.lambda.resources.LambdaResources +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.resources.message +import javax.swing.JLabel +import javax.swing.JPanel + +class ConfigureLambdaPanel(private val project: Project) { + lateinit var component: JPanel + private set + lateinit var lambdaFunction: ResourceSelector + private set + lateinit var functionContextHelp: JLabel + private set + + init { + functionContextHelp.icon = AllIcons.General.ContextHelp + HelpTooltip().apply { + setDescription(message("sqs.configure.lambda.tooltip")) + installOn(functionContextHelp) + } + } + + private fun createUIComponents() { + lambdaFunction = ResourceSelector.builder() + .resource(LambdaResources.LIST_FUNCTIONS) + .customRenderer(SimpleListCellRenderer.create("") { it.functionName() }) + .awsConnection(project) + .build() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfirmIamPolicyDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfirmIamPolicyDialog.kt new file mode 100644 index 0000000000..2be1d2cd51 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfirmIamPolicyDialog.kt @@ -0,0 +1,90 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs + +import com.intellij.json.JsonLanguage +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.iam.IamClient +import software.amazon.awssdk.services.lambda.LambdaClient +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.services.lambda.upload.createSqsPollerPolicy +import software.aws.toolkits.jetbrains.ui.ConfirmPolicyPanel +import software.aws.toolkits.jetbrains.utils.ui.formatAndSet +import software.aws.toolkits.resources.message +import java.awt.Component +import javax.swing.JComponent + +class ConfirmIamPolicyDialog( + project: Project, + private val iamClient: IamClient, + private val lambdaClient: LambdaClient, + private val functionName: String, + private val queue: Queue, + parent: Component? = null +) : DialogWrapper(project, parent, false, IdeModalityType.PROJECT) { + private val coroutineScope = projectCoroutineScope(project) + private val rolePolicy: String by lazy { createSqsPollerPolicy(queue.arn) } + private val policyName: String by lazy { "AWSLambdaSQSPollerExecutionRole-$functionName-${queue.queueName}-${queue.region.id}" } + val view = ConfirmPolicyPanel(project, message("sqs.confirm.iam.warning.text")) + + init { + title = message("sqs.confirm.iam") + setOKButtonText(message("sqs.confirm.iam.create")) + view.policyDocument.formatAndSet(rolePolicy, JsonLanguage.INSTANCE) + init() + } + + override fun createCenterPanel(): JComponent? = view.component + + override fun doOKAction() { + if (!isOKActionEnabled) { + return + } + + setOKButtonText(message("general.create_in_progress")) + isOKActionEnabled = false + coroutineScope.launch { + try { + val policyArn = createPolicy() + attachPolicy(policyArn) + runInEdt(ModalityState.any()) { + close(OK_EXIT_CODE) + } + } catch (e: Exception) { + LOG.warn(e) { message("sqs.confirm.iam.failed") } + setErrorText(e.message) + setOKButtonText(message("sqs.confirm.iam.create")) + isOKActionEnabled = true + } + } + } + + private fun createPolicy(): String { + val policy = iamClient.createPolicy { + it.policyName(policyName) + it.policyDocument(rolePolicy) + }.policy() + return policy.arn() + } + + private fun attachPolicy(policyArn: String) { + // getFunctionConfiguration().role() returns the ARN of the role like this: arn:aws:iam::123456789012:role/service-role/ROLE-NAME. + // We must use substringAfterLast to extract only the role name. + val role = lambdaClient.getFunctionConfiguration { it.functionName(functionName) }.role().substringAfterLast('/') + iamClient.attachRolePolicy { + it.policyArn(policyArn) + it.roleName(role) + } + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfirmQueuePolicyDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfirmQueuePolicyDialog.kt new file mode 100644 index 0000000000..7b048033bc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/ConfirmQueuePolicyDialog.kt @@ -0,0 +1,88 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs + +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.json.JsonLanguage +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.QueueAttributeName +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.ui.ConfirmPolicyPanel +import software.aws.toolkits.jetbrains.utils.ui.formatAndSet +import software.aws.toolkits.resources.message +import java.awt.Component +import javax.swing.JComponent + +class ConfirmQueuePolicyDialog( + project: Project, + private val sqsClient: SqsClient, + private val queue: Queue, + topicArn: String, + private val existingPolicy: String?, + parent: Component? = null +) : DialogWrapper(project, parent, false, IdeModalityType.PROJECT) { + private val coroutineScope = projectCoroutineScope(project) + private val policyStatement = createSqsSnsSubscribePolicyStatement(queue.arn, topicArn) + + val view = ConfirmPolicyPanel(project, message("sqs.confirm.iam.warning.sqs_queue_permissions")) + + init { + title = message("sqs.confirm.iam.create") + setOKButtonText(message("sqs.confirm.iam.create")) + view.policyDocument.formatAndSet(policyStatement, JsonLanguage.INSTANCE) + init() + } + + override fun createCenterPanel(): JComponent? = view.component + + override fun doOKAction() { + if (!isOKActionEnabled) { + return + } + + setOKButtonText(message("sqs.confirm.iam.in_progress")) + isOKActionEnabled = false + coroutineScope.launch { + try { + addPolicy() + runInEdt(ModalityState.any()) { + close(OK_EXIT_CODE) + } + } catch (e: Exception) { + LOG.warn(e) { message("sqs.confirm.iam.failed") } + setErrorText(e.message) + setOKButtonText(message("sqs.confirm.iam.create")) + isOKActionEnabled = true + } + } + } + + private fun addPolicy() { + val document = mapper.readTree(existingPolicy ?: createSqsPolicy(queue.arn)) as ObjectNode + val policyArray = document[sqsPolicyStatementArray] as? ArrayNode ?: document.putArray(sqsPolicyStatementArray) + policyArray.add(mapper.readTree(policyStatement)) + sqsClient.setQueueAttributes { + it.queueUrl(queue.queueUrl) + it.attributes( + mutableMapOf( + QueueAttributeName.POLICY to document.toPrettyString() + ) + ) + } + } + + private companion object { + val mapper = jacksonObjectMapper() + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/CreateQueueDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/CreateQueueDialog.kt new file mode 100644 index 0000000000..e30c9acadb --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/CreateQueueDialog.kt @@ -0,0 +1,115 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.QueueAttributeName +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.services.sqs.resources.SqsResources +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SqsQueueType +import software.aws.toolkits.telemetry.SqsTelemetry +import javax.swing.JComponent + +class CreateQueueDialog( + private val project: Project, + private val client: SqsClient +) : DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + val view = CreateQueuePanel() + + init { + title = message("sqs.create.queue.title") + setOKButtonText(message("sqs.create.queue.create")) + setOKButtonTooltip(message("sqs.create.queue.tooltip")) + + init() + } + + override fun createCenterPanel(): JComponent? = view.component + + override fun getPreferredFocusedComponent(): JComponent? = view.queueName + + override fun doValidate(): ValidationInfo? = validateFields() + + override fun doCancelAction() { + SqsTelemetry.createQueue(project, Result.Cancelled) + super.doCancelAction() + } + + override fun doOKAction() { + if (!isOKActionEnabled) { + return + } + setOKButtonText(message("general.create_in_progress")) + isOKActionEnabled = false + + coroutineScope.launch { + try { + createQueue() + withContext(getCoroutineUiContext()) { + close(OK_EXIT_CODE) + } + project.refreshAwsTree(SqsResources.LIST_QUEUE_URLS) + SqsTelemetry.createQueue(project, Result.Succeeded, if (view.fifoType.isSelected) SqsQueueType.Fifo else SqsQueueType.Standard) + } catch (e: Exception) { + // API only throws QueueNameExistsException if the request includes attributes whose values differ from those of the existing queue. + LOG.warn(e) { message("sqs.create.queue.failed", queueName()) } + setErrorText(e.message) + setOKButtonText(message("sqs.create.queue.create")) + isOKActionEnabled = true + SqsTelemetry.createQueue(project, Result.Failed, if (view.fifoType.isSelected) SqsQueueType.Fifo else SqsQueueType.Standard) + } + } + } + + private fun validateFields(): ValidationInfo? { + if (view.queueName.text.isEmpty()) { + return ValidationInfo(message("sqs.create.validation.empty.queue.name"), view.queueName) + } + if (queueName().length > MAX_LENGTH_OF_QUEUE_NAME) { + return ValidationInfo(message("sqs.create.validation.long.queue.name", MAX_LENGTH_OF_QUEUE_NAME), view.queueName) + } + if (!validateCharacters(view.queueName.text)) { + return ValidationInfo(message("sqs.create.validation.queue.name.invalid"), view.queueName) + } + + return null + } + + private fun validateCharacters(queueName: String): Boolean = queueName.matches("^[a-zA-Z0-9-_]*$".toRegex()) + + private fun queueName(): String { + val name = view.queueName.text.trim() + return if (view.fifoType.isSelected) { + name + FIFO_SUFFIX + } else { + name + } + } + + fun createQueue() { + client.createQueue { + it.queueName(queueName()) + if (view.fifoType.isSelected) { + it.attributes(mutableMapOf(Pair(QueueAttributeName.FIFO_QUEUE, true.toString()))) + } + } + } + + private companion object { + val LOG = getLogger() + const val FIFO_SUFFIX = ".fifo" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/CreateQueuePanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/CreateQueuePanel.form new file mode 100644 index 0000000000..d83c4e0068 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/CreateQueuePanel.form @@ -0,0 +1,109 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/CreateQueuePanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/CreateQueuePanel.kt new file mode 100644 index 0000000000..ac1c4a5480 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/CreateQueuePanel.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.sqs + +import com.intellij.icons.AllIcons +import com.intellij.ide.HelpTooltip +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.JBColor +import com.intellij.ui.SideBorder +import software.aws.toolkits.resources.message +import java.awt.BorderLayout +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JRadioButton +import javax.swing.JTextField + +class CreateQueuePanel { + lateinit var component: JPanel + private set + lateinit var queueName: JTextField + private set + lateinit var standardType: JRadioButton + private set + lateinit var fifoType: JRadioButton + private set + lateinit var queueNameContextHelp: JLabel + private set + lateinit var fifoSuffix: JPanel + private set + lateinit var textPanel: JPanel + private set + + init { + setRadioButton() + setTooltip() + setFields() + } + + private fun setRadioButton() { + fifoSuffixLabel.isVisible = false + fifoType.addActionListener { + fifoSuffixLabel.isVisible = true + } + standardType.addActionListener { + fifoSuffixLabel.isVisible = false + } + } + + private fun setTooltip() { + queueNameContextHelp.icon = AllIcons.General.ContextHelp + HelpTooltip().apply { + setDescription(message("sqs.queue.name.tooltip")) + installOn(queueNameContextHelp) + } + } + + private fun setFields() { + queueName.apply { + border = IdeBorderFactory.createBorder(SideBorder.TOP or SideBorder.BOTTOM or SideBorder.LEFT) + } + fifoSuffix.apply { + background = queueName.background + layout = BorderLayout() + border = IdeBorderFactory.createBorder(SideBorder.TOP or SideBorder.BOTTOM or SideBorder.RIGHT) + add(fifoSuffixLabel, BorderLayout.WEST) + } + } + + companion object { + val fifoSuffixLabel = JLabel(".fifo").apply { + foreground = JBColor.GRAY + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/EditAttributesDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/EditAttributesDialog.kt new file mode 100644 index 0000000000..5f8af959b1 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/EditAttributesDialog.kt @@ -0,0 +1,114 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs + +import com.intellij.openapi.options.ConfigurationException +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.QueueAttributeName +import software.amazon.awssdk.services.sqs.model.SqsException +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SqsTelemetry +import javax.swing.JComponent + +class EditAttributesDialog( + private val project: Project, + private val client: SqsClient, + private val queue: Queue, + private val attributes: Map +) : DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + val view = EditAttributesPanel() + + init { + title = message("sqs.edit.attributes") + setOKButtonText(message("sqs.edit.attributes.save")) + populateFields() + init() + } + + override fun createCenterPanel(): JComponent? = view.component + + override fun doValidate(): ValidationInfo? { + val sliderIssue = view.visibilityTimeout.validate() ?: view.deliveryDelay.validate() ?: view.waitTime.validate() + if (sliderIssue != null) { + return sliderIssue + } + return try { + view.retentionPeriod.validateContent() + view.messageSize.validateContent() + null + } catch (e: ConfigurationException) { + ValidationInfo(e.title) + } + } + + override fun doCancelAction() { + SqsTelemetry.editQueueParameters(project, Result.Cancelled, queue.telemetryType()) + super.doCancelAction() + } + + override fun doOKAction() { + if (!isOKActionEnabled) { + return + } + isOKActionEnabled = false + coroutineScope.launch { + try { + updateAttributes() + notifyInfo( + project = project, + title = message("sqs.service_name"), + content = message("sqs.edit.attributes.updated", queue.queueName) + ) + SqsTelemetry.editQueueParameters(project, Result.Succeeded, queue.telemetryType()) + withContext(getCoroutineUiContext()) { + close(OK_EXIT_CODE) + } + } catch (e: SqsException) { + LOG.error(e) { "Updating queue parameters failed" } + setErrorText(e.message) + isOKActionEnabled = true + SqsTelemetry.editQueueParameters(project, Result.Failed, queue.telemetryType()) + } + } + } + + private fun populateFields() { + view.visibilityTimeout.value = attributes[QueueAttributeName.VISIBILITY_TIMEOUT]?.toIntOrNull() ?: MIN_VISIBILITY_TIMEOUT + view.messageSize.text = attributes[QueueAttributeName.MAXIMUM_MESSAGE_SIZE] + view.retentionPeriod.text = attributes[QueueAttributeName.MESSAGE_RETENTION_PERIOD] + view.deliveryDelay.value = attributes[QueueAttributeName.DELAY_SECONDS]?.toIntOrNull() ?: MIN_DELIVERY_DELAY + view.waitTime.value = attributes[QueueAttributeName.RECEIVE_MESSAGE_WAIT_TIME_SECONDS]?.toIntOrNull() ?: MIN_WAIT_TIME + } + + internal fun updateAttributes() { + client.setQueueAttributes { + it.queueUrl(queue.queueUrl) + it.attributes( + mutableMapOf( + QueueAttributeName.VISIBILITY_TIMEOUT to view.visibilityTimeout.value.toString(), + QueueAttributeName.MAXIMUM_MESSAGE_SIZE to view.messageSize.text, + QueueAttributeName.MESSAGE_RETENTION_PERIOD to view.retentionPeriod.text, + QueueAttributeName.DELAY_SECONDS to view.deliveryDelay.value.toString(), + QueueAttributeName.RECEIVE_MESSAGE_WAIT_TIME_SECONDS to view.waitTime.value.toString() + ) + ) + } + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/EditAttributesPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/EditAttributesPanel.form new file mode 100644 index 0000000000..baa1a8aba3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/EditAttributesPanel.form @@ -0,0 +1,90 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/EditAttributesPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/EditAttributesPanel.kt new file mode 100644 index 0000000000..febc2aa885 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/EditAttributesPanel.kt @@ -0,0 +1,75 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.sqs + +import com.intellij.ide.HelpTooltip +import com.intellij.ui.components.fields.IntegerField +import software.aws.toolkits.jetbrains.ui.SliderPanel +import software.aws.toolkits.resources.message +import javax.swing.JPanel + +class EditAttributesPanel { + lateinit var component: JPanel + private set + lateinit var visibilityTimeout: SliderPanel + private set + lateinit var messageSize: IntegerField + private set + lateinit var retentionPeriod: IntegerField + private set + lateinit var deliveryDelay: SliderPanel + private set + lateinit var waitTime: SliderPanel + private set + + private fun createUIComponents() { + visibilityTimeout = SliderPanel( + MIN_VISIBILITY_TIMEOUT, + MAX_VISIBILITY_TIMEOUT, + MIN_VISIBILITY_TIMEOUT, + MIN_VISIBILITY_TIMEOUT, + MAX_VISIBILITY_TIMEOUT, + VISIBILITY_TIMEOUT_TICK, + VISIBILITY_TIMEOUT_TICK * 5, + false + ) + IntegerField("", MIN_VISIBILITY_TIMEOUT, MAX_VISIBILITY_TIMEOUT) + HelpTooltip().apply { + setDescription(message("sqs.edit.attributes.visibility_timeout.tooltip")) + installOn(visibilityTimeout.slider) + installOn(visibilityTimeout.textField) + } + messageSize = IntegerField("", MIN_MESSAGE_SIZE_LIMIT, MAX_MESSAGE_SIZE_LIMIT) + HelpTooltip().apply { + setDescription(message("sqs.edit.attributes.message_size.tooltip", MIN_MESSAGE_SIZE_LIMIT, MAX_MESSAGE_SIZE_LIMIT)) + installOn(messageSize) + } + retentionPeriod = IntegerField("", MIN_RETENTION_PERIOD, MAX_RETENTION_PERIOD) + HelpTooltip().apply { + setDescription(message("sqs.edit.attributes.retention_period.tooltip", MIN_RETENTION_PERIOD, MAX_RETENTION_PERIOD)) + installOn(retentionPeriod) + } + deliveryDelay = SliderPanel( + MIN_DELIVERY_DELAY, + MAX_DELIVERY_DELAY, + MIN_DELIVERY_DELAY, + MIN_DELIVERY_DELAY, + MAX_DELIVERY_DELAY, + DELIVERY_DELAY_TICK, + DELIVERY_DELAY_TICK * 5, + false + ) + IntegerField("", MIN_DELIVERY_DELAY, MAX_DELIVERY_DELAY) + HelpTooltip().apply { + setDescription(message("sqs.edit.attributes.delivery_delay.tooltip")) + installOn(deliveryDelay.textField) + installOn(deliveryDelay.slider) + } + waitTime = SliderPanel(MIN_WAIT_TIME, MAX_WAIT_TIME, MIN_WAIT_TIME, MIN_WAIT_TIME, MAX_WAIT_TIME, WAIT_TIME_TICK, WAIT_TIME_TICK * 5, true) + HelpTooltip().apply { + setDescription(message("sqs.edit.attributes.wait_time.tooltip")) + installOn(waitTime.textField) + installOn(waitTime.slider) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/Queue.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/Queue.kt new file mode 100644 index 0000000000..95adbb0571 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/Queue.kt @@ -0,0 +1,36 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs + +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.resources.message + +// TODO This does not support FIPS + +/** + * @param queueUrl The format for queueUrl is https://sqs..amazonaws.com// + * queueName cannot contain '/', so it is safe enough to do string manipulation on it + */ +class Queue(val queueUrl: String, val region: AwsRegion) { + val accountId: String by lazy { + val id = queueUrl.substringBeforeLast("/").substringAfterLast("/") + if ((id == queueUrl) || (id.length != 12) || id.isBlank()) { + throw IllegalArgumentException(message("sqs.url.parse_error")) + } else { + id + } + } + + val queueName: String by lazy { + val name = queueUrl.substringAfterLast("/") + if (name == queueUrl || name.isBlank()) { + throw IllegalArgumentException(message("sqs.url.parse_error")) + } else { + name + } + } + + val arn = "arn:${region.partitionId}:sqs:${region.id}:$accountId:$queueName" + val isFifo: Boolean by lazy { queueName.endsWith(".fifo") } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SqsExplorerNodes.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SqsExplorerNodes.kt new file mode 100644 index 0000000000..73f8d2db50 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SqsExplorerNodes.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.jetbrains.services.sqs + +import com.intellij.openapi.project.Project +import icons.AwsIcons +import software.amazon.awssdk.services.sqs.SqsClient +import software.aws.toolkits.jetbrains.core.credentials.activeRegion +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 +import software.aws.toolkits.jetbrains.services.sqs.resources.SqsResources +import software.aws.toolkits.jetbrains.services.sqs.toolwindow.SqsWindow +import software.aws.toolkits.resources.message + +class SqsServiceNode(project: Project, service: AwsExplorerServiceNode) : + CacheBackedAwsExplorerServiceRootNode(project, service, SqsResources.LIST_QUEUE_URLS) { + override fun displayName(): String = message("explorer.node.sqs") + override fun toNode(child: String): AwsExplorerNode<*> = SqsQueueNode(nodeProject, child) +} + +class SqsQueueNode( + project: Project, + val queueUrl: String +) : AwsExplorerResourceNode( + project, + SqsClient.SERVICE_NAME, + queueUrl, + AwsIcons.Resources.Sqs.SQS_QUEUE +) { + val queue = Queue(queueUrl, nodeProject.activeRegion()) + + override fun resourceType() = "queue" + + override fun resourceArn(): String = queue.arn + + override fun displayName(): String = queue.queueName + + override fun onDoubleClick() { + SqsWindow.getInstance(nodeProject).pollMessage(queue) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SqsUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SqsUtils.kt new file mode 100644 index 0000000000..a0c5475afa --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SqsUtils.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.sqs + +import org.intellij.lang.annotations.Language +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.QueueAttributeName +import software.amazon.awssdk.services.sqs.model.SqsException +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.telemetry.SqsQueueType + +const val MAX_NUMBER_OF_POLLED_MESSAGES = 10 +const val MAX_LENGTH_OF_POLLED_MESSAGES = 1024 +const val MAX_LENGTH_OF_FIFO_ID = 128 +const val MAX_LENGTH_OF_QUEUE_NAME = 80 + +// Maximum length of queue name is 80, but the maximum will be 75 for FIFO queues due to '.fifo' suffix +const val MAX_LENGTH_OF_FIFO_QUEUE_NAME = 75 + +// Queue attribute limits +const val MIN_DELIVERY_DELAY = 0 +const val MAX_DELIVERY_DELAY = 900 +const val DELIVERY_DELAY_TICK = (MAX_DELIVERY_DELAY - MIN_DELIVERY_DELAY) / 30 +const val MIN_MESSAGE_SIZE_LIMIT = 1024 +const val MAX_MESSAGE_SIZE_LIMIT = 262144 +const val MIN_RETENTION_PERIOD = 60 +const val MAX_RETENTION_PERIOD = 1209600 +const val MIN_VISIBILITY_TIMEOUT = 0 +const val MAX_VISIBILITY_TIMEOUT = 43200 +const val VISIBILITY_TIMEOUT_TICK = (MAX_VISIBILITY_TIMEOUT - MIN_VISIBILITY_TIMEOUT) / 30 +const val MIN_WAIT_TIME = 0 +const val MAX_WAIT_TIME = 20 +const val WAIT_TIME_TICK = 1 + +const val sqsPolicyStatementArray = "Statement" + +// Extension function to get telemetry type from Queue +fun Queue.telemetryType() = if (isFifo) SqsQueueType.Fifo else SqsQueueType.Standard + +/* + * Get the approximate number of messages from a queue. Returns null when there is a service exception + * thrown, or the value returned is not an int. + * @param queueUrl The queue url to retrieve the approximate number of messages from + */ +fun SqsClient.approximateNumberOfMessages(queueUrl: String): Int? = try { + getQueueAttributes { + it.queueUrl(queueUrl) + it.attributeNames(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES) + }.attributes().getValue(QueueAttributeName.APPROXIMATE_NUMBER_OF_MESSAGES).toIntOrNull() +} catch (e: SqsException) { + getLogger().error(e) { "SqsClient threw an exception getting approximate number of messages" } + null +} + +/** + * Create a policy statement that allows sending SNS messages to an SQS queue. The Sid + * matches how the console does sid (so it won't duplicate it), and the overall policy + * matches how the console does it. + */ +@Language("JSON") +fun createSqsSnsSubscribePolicyStatement(sqsArn: String, snsArn: String): String = + """ + { + "Sid": "topic-subscription-$snsArn", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": "SQS:SendMessage", + "Resource": "$sqsArn", + "Condition": { + "ArnLike": { + "aws:SourceArn": "$snsArn" + } + } + } + """ + +/** + * When a queue is created with the default parameters, the policy is null when returned with `getQueueAttributes` + * (even though in the console it shows up properly) so we have to create our own whole policy document if that happens + */ +@Language("JSON") +fun createSqsPolicy(arn: String): String = + """ + { + "Version": "2012-10-17", + "Id": "$arn/SQSDefaultPolicy" + } + """ diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SubscribeSnsDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SubscribeSnsDialog.kt new file mode 100644 index 0000000000..406b94daf9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SubscribeSnsDialog.kt @@ -0,0 +1,137 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs + +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.ValidationInfo +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.iam.IamClient +import software.amazon.awssdk.services.iam.model.ContextEntry +import software.amazon.awssdk.services.iam.model.ContextKeyTypeEnum +import software.amazon.awssdk.services.iam.model.PolicyEvaluationDecisionType +import software.amazon.awssdk.services.sns.SnsClient +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.QueueAttributeName +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SqsTelemetry +import javax.swing.JComponent + +class SubscribeSnsDialog( + private val project: Project, + private val queue: Queue +) : DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + private val snsClient: SnsClient = project.awsClient() + private val sqsClient: SqsClient = project.awsClient() + private val iamClient: IamClient = project.awsClient() + + val view = SubscribeSnsPanel(project) + + init { + title = message("sqs.subscribe.sns") + setOKButtonText(message("sqs.subscribe.sns.subscribe")) + + init() + } + + override fun createCenterPanel(): JComponent? = view.component + + override fun getPreferredFocusedComponent(): JComponent? = view.topicSelector + + override fun doValidate(): ValidationInfo? { + if (topicSelected().isEmpty()) { + return ValidationInfo(message("sqs.subscribe.sns.validation.empty_topic"), view.topicSelector) + } + return null + } + + override fun doCancelAction() { + SqsTelemetry.subscribeSns(project, Result.Cancelled, queue.telemetryType()) + super.doCancelAction() + } + + override fun doOKAction() { + if (!isOKActionEnabled) { + return + } + val topicArn = topicSelected() + setOKButtonText(message("sqs.subscribe.sns.in_progress")) + isOKActionEnabled = false + + coroutineScope.launch { + try { + val policy = sqsClient.getQueueAttributes { + it.queueUrl(queue.queueUrl) + it.attributeNames(QueueAttributeName.POLICY) + }.attributes()[QueueAttributeName.POLICY] + + if (needToEditPolicy(policy)) { + val continueAdding = withContext(getCoroutineUiContext()) { + ConfirmQueuePolicyDialog(project, sqsClient, queue, topicArn, policy, view.component).showAndGet() + } + if (!continueAdding) { + setOKButtonText(message("sqs.subscribe.sns.subscribe")) + isOKActionEnabled = true + return@launch + } + } + subscribe(topicArn) + withContext(getCoroutineUiContext()) { + close(OK_EXIT_CODE) + } + notifyInfo(message("sqs.service_name"), message("sqs.subscribe.sns.success", topicSelected()), project) + SqsTelemetry.subscribeSns(project, Result.Succeeded, queue.telemetryType()) + } catch (e: Exception) { + LOG.warn(e) { message("sqs.subscribe.sns.failed", queue.queueName, topicArn) } + setErrorText(e.message) + setOKButtonText(message("sqs.subscribe.sns.subscribe")) + isOKActionEnabled = true + SqsTelemetry.subscribeSns(project, Result.Failed, queue.telemetryType()) + } + } + } + + internal fun subscribe(arn: String) { + snsClient.subscribe { + it.topicArn(arn) + it.protocol(PROTOCOL) + it.endpoint(queue.arn) + } + } + + private fun topicSelected(): String = view.topicSelector.selected()?.topicArn() ?: "" + + private fun needToEditPolicy(existingPolicy: String?): Boolean { + existingPolicy ?: return true + + val allowed = iamClient.simulateCustomPolicy { + it.contextEntries( + ContextEntry.builder() + .contextKeyType(ContextKeyTypeEnum.STRING) + .contextKeyName("aws:SourceArn") + .contextKeyValues(topicSelected()) + .build() + ) + it.actionNames("sqs:SendMessage") + it.resourceArns(queue.arn) + it.policyInputList(existingPolicy) + }.evaluationResults().first() + + return allowed.evalDecision() != PolicyEvaluationDecisionType.ALLOWED + } + + private companion object { + val LOG = getLogger() + const val PROTOCOL = "sqs" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SubscribeSnsPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SubscribeSnsPanel.form new file mode 100644 index 0000000000..937d2e4dee --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SubscribeSnsPanel.form @@ -0,0 +1,41 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SubscribeSnsPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SubscribeSnsPanel.kt new file mode 100644 index 0000000000..4843cab25a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/SubscribeSnsPanel.kt @@ -0,0 +1,40 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.sqs + +import com.intellij.icons.AllIcons +import com.intellij.ide.HelpTooltip +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleListCellRenderer +import software.amazon.awssdk.services.sns.model.Topic +import software.aws.toolkits.jetbrains.services.sns.resources.SnsResources +import software.aws.toolkits.jetbrains.services.sns.resources.getName +import software.aws.toolkits.jetbrains.ui.ResourceSelector +import software.aws.toolkits.resources.message +import javax.swing.JLabel +import javax.swing.JPanel + +class SubscribeSnsPanel(private val project: Project) { + lateinit var component: JPanel + private set + lateinit var topicSelector: ResourceSelector + private set + lateinit var selectContextHelp: JLabel + private set + + init { + selectContextHelp.icon = AllIcons.General.ContextHelp + HelpTooltip().apply { + setDescription(message("sqs.subscribe.sns.select.tooltip")) + installOn(selectContextHelp) + } + } + + private fun createUIComponents() { + topicSelector = ResourceSelector.builder() + .resource(SnsResources.LIST_TOPICS) + .customRenderer(SimpleListCellRenderer.create("") { it.getName() }) + .awsConnection(project) + .build() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/ConfigureLambdaAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/ConfigureLambdaAction.kt new file mode 100644 index 0000000000..8fdd7ca26f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/ConfigureLambdaAction.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.jetbrains.services.sqs.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.sqs.ConfigureLambdaDialog +import software.aws.toolkits.jetbrains.services.sqs.SqsQueueNode +import software.aws.toolkits.resources.message + +class ConfigureLambdaAction : SingleResourceNodeAction(message("sqs.configure.lambda")), DumbAware { + override fun actionPerformed(selected: SqsQueueNode, e: AnActionEvent) { + ConfigureLambdaDialog(selected.nodeProject, selected.queue).show() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/CopyMessageAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/CopyMessageAction.kt new file mode 100644 index 0000000000..fd01925ea5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/CopyMessageAction.kt @@ -0,0 +1,29 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.ui.table.TableView +import software.amazon.awssdk.services.sqs.model.Message +import software.aws.toolkits.resources.message +import java.awt.datatransfer.StringSelection + +class CopyMessageAction(private val table: TableView) : DumbAwareAction(message("sqs.copy.message", 1), null, AllIcons.Actions.Copy) { + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = table.selectedObjects.size > 0 + e.presentation.text = message("sqs.copy.message", table.selectedObjects.size) + } + + override fun actionPerformed(e: AnActionEvent) { + // get an immutable view of the selected items + val messages = table.selectedObjects.toList() + if (messages.isEmpty()) { + return + } + CopyPasteManager.getInstance().setContents(StringSelection(messages.joinToString("\n") { it.body() })) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/CreateQueueAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/CreateQueueAction.kt new file mode 100644 index 0000000000..3096fc81db --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/CreateQueueAction.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.jetbrains.services.sqs.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.PlatformDataKeys +import com.intellij.openapi.project.DumbAwareAction +import icons.AwsIcons +import software.amazon.awssdk.services.sqs.SqsClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.services.sqs.CreateQueueDialog +import software.aws.toolkits.resources.message + +class CreateQueueAction : DumbAwareAction( + message("sqs.create.queue"), + null, + AwsIcons.Resources.Sqs.SQS_QUEUE +) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.getRequiredData(PlatformDataKeys.PROJECT) + val client: SqsClient = project.awsClient() + CreateQueueDialog(project, client).show() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/DeleteQueueAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/DeleteQueueAction.kt new file mode 100644 index 0000000000..0097173bff --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/DeleteQueueAction.kt @@ -0,0 +1,28 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs.actions + +import com.intellij.openapi.application.ApplicationManager +import software.amazon.awssdk.services.sqs.SqsClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.explorer.actions.DeleteResourceAction +import software.aws.toolkits.jetbrains.core.explorer.refreshAwsTree +import software.aws.toolkits.jetbrains.services.sqs.SqsQueueNode +import software.aws.toolkits.jetbrains.services.sqs.resources.SqsResources +import software.aws.toolkits.jetbrains.services.sqs.toolwindow.SqsWindow +import software.aws.toolkits.resources.message + +class DeleteQueueAction : DeleteResourceAction(message("sqs.delete.queue.action")) { + override fun performDelete(selected: SqsQueueNode) { + val project = selected.nodeProject + val client = project.awsClient() + ApplicationManager.getApplication().invokeAndWait { + SqsWindow.getInstance(project).closeQueue(selected.queueUrl) + } + client.deleteQueue { it.queueUrl(selected.queueUrl) } + project.refreshAwsTree(SqsResources.LIST_QUEUE_URLS) + } + + override val comment: String = message("resource.delete.warning_text", "queue") +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/EditAttributesAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/EditAttributesAction.kt new file mode 100644 index 0000000000..91c3caaba5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/EditAttributesAction.kt @@ -0,0 +1,66 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs.actions + +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.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAware +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.QueueAttributeName +import software.amazon.awssdk.services.sqs.model.SqsException +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.sqs.EditAttributesDialog +import software.aws.toolkits.jetbrains.services.sqs.SqsQueueNode +import software.aws.toolkits.jetbrains.services.sqs.telemetryType +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SqsTelemetry + +class EditAttributesAction : SingleResourceNodeAction(message("sqs.edit.attributes.action")), DumbAware { + override fun actionPerformed(selected: SqsQueueNode, e: AnActionEvent) { + ProgressManager.getInstance().run( + object : Task.Backgroundable( + selected.project, + message("sqs.edit.attributes.retrieving_from_service"), + false, + PerformInBackgroundOption.ALWAYS_BACKGROUND + ) { + override fun run(indicator: ProgressIndicator) { + val client = selected.nodeProject.awsClient() + val queue = selected.queue + val attributes = try { + client.getQueueAttributes { + it.queueUrl(queue.queueUrl) + it.attributeNames(QueueAttributeName.ALL) + }.attributes() + } catch (e: SqsException) { + LOG.error(e) { "Getting queue parameters failed" } + notifyError( + project = project, + title = message("sqs.service_name"), + content = message("sqs.edit.attributes.failed", queue.queueName) + ) + SqsTelemetry.editQueueParameters(project, Result.Failed, queue.telemetryType()) + return + } + runInEdt { + EditAttributesDialog(selected.nodeProject, client, queue, attributes).show() + } + } + } + ) + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/PollMessageAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/PollMessageAction.kt new file mode 100644 index 0000000000..083ad6d785 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/PollMessageAction.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.jetbrains.services.sqs.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.sqs.SqsQueueNode +import software.aws.toolkits.jetbrains.services.sqs.toolwindow.SqsWindow +import software.aws.toolkits.resources.message + +class PollMessageAction : SingleResourceNodeAction(message("sqs.poll.message")), DumbAware { + override fun actionPerformed(selected: SqsQueueNode, e: AnActionEvent) { + SqsWindow.getInstance(selected.nodeProject).pollMessage(selected.queue) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/PurgeQueueAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/PurgeQueueAction.kt new file mode 100644 index 0000000000..e5213902c8 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/PurgeQueueAction.kt @@ -0,0 +1,93 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import kotlinx.coroutines.runBlocking +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.PurgeQueueInProgressException +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.services.sqs.Queue +import software.aws.toolkits.jetbrains.services.sqs.approximateNumberOfMessages +import software.aws.toolkits.jetbrains.services.sqs.telemetryType +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SqsTelemetry + +class PurgeQueueAction( + private val project: Project, + private val client: SqsClient, + private val queue: Queue +) : DumbAwareAction(message("sqs.purge_queue.action")) { + private val edtContext = getCoroutineUiContext() + + override fun actionPerformed(e: AnActionEvent) { + ProgressManager.getInstance().run( + object : Task.Backgroundable( + project, + message("sqs.purge_queue"), + false, + ALWAYS_BACKGROUND + ) { + override fun run(indicator: ProgressIndicator) { + val numMessages = client.approximateNumberOfMessages(queue.queueUrl) ?: 0 + val response = runBlocking(edtContext) { + Messages.showOkCancelDialog( + project, + message("sqs.purge_queue.confirm", queue.queueName, numMessages), + message("sqs.purge_queue.confirm.title"), + Messages.getYesButton(), + Messages.getNoButton(), + Messages.getWarningIcon() + ) + } + if (response != Messages.YES) { + SqsTelemetry.purgeQueue(project, Result.Cancelled, queue.telemetryType()) + return + } + try { + client.purgeQueue { it.queueUrl(queue.queueUrl) } + LOG.info { "Started purging ${queue.queueUrl}" } + notifyInfo( + project = project, + title = message("aws.notification.title"), + content = message("sqs.purge_queue.succeeded", queue.queueUrl) + ) + SqsTelemetry.purgeQueue(project, Result.Succeeded, queue.telemetryType()) + } catch (e: PurgeQueueInProgressException) { + LOG.warn { "${queue.queueUrl} already has a query purge in progress" } + notifyError( + project = project, + content = message("sqs.purge_queue.failed.60_seconds", queue.queueUrl) + ) + SqsTelemetry.purgeQueue(project, Result.Succeeded, queue.telemetryType()) + } catch (e: Exception) { + LOG.error(e) { "Exception thrown while trying to purge query ${queue.queueUrl}" } + notifyError( + project = project, + content = message("sqs.purge_queue.failed", queue.queueUrl) + ) + SqsTelemetry.purgeQueue(project, Result.Failed, queue.telemetryType()) + } + } + } + ) + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/PurgeQueueNodeAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/PurgeQueueNodeAction.kt new file mode 100644 index 0000000000..3a0635382d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/PurgeQueueNodeAction.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.services.sqs.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.amazon.awssdk.services.sqs.SqsClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.sqs.SqsQueueNode +import software.aws.toolkits.resources.message + +class PurgeQueueNodeAction : SingleResourceNodeAction(message("sqs.purge_queue.action")), DumbAware { + override fun actionPerformed(selected: SqsQueueNode, e: AnActionEvent) { + val project = selected.nodeProject + val client: SqsClient = project.awsClient() + PurgeQueueAction(project, client, selected.queue).actionPerformed(e) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/SendMessageAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/SendMessageAction.kt new file mode 100644 index 0000000000..e8b32cbf77 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/SendMessageAction.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.jetbrains.services.sqs.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.sqs.SqsQueueNode +import software.aws.toolkits.jetbrains.services.sqs.toolwindow.SqsWindow +import software.aws.toolkits.resources.message + +class SendMessageAction : SingleResourceNodeAction(message("sqs.send.message")), DumbAware { + override fun actionPerformed(selected: SqsQueueNode, e: AnActionEvent) { + SqsWindow.getInstance(selected.nodeProject).sendMessage(selected.queue) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/SubscribeSnsAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/SubscribeSnsAction.kt new file mode 100644 index 0000000000..b774b4766f --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/actions/SubscribeSnsAction.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.services.sqs.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.jetbrains.core.explorer.actions.SingleResourceNodeAction +import software.aws.toolkits.jetbrains.services.sqs.SqsQueueNode +import software.aws.toolkits.jetbrains.services.sqs.SubscribeSnsDialog +import software.aws.toolkits.resources.message + +class SubscribeSnsAction : SingleResourceNodeAction(message("sqs.subscribe.sns")), DumbAware { + override fun update(selected: SqsQueueNode, e: AnActionEvent) { + // TODO: Amazon SNS isn't currently compatible with FIFO queues. + e.presentation.isVisible = !selected.queue.isFifo + } + + override fun actionPerformed(selected: SqsQueueNode, e: AnActionEvent) { + SubscribeSnsDialog(selected.nodeProject, selected.queue).show() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/resources/SqsResources.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/resources/SqsResources.kt new file mode 100644 index 0000000000..12fe9dbb09 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/resources/SqsResources.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.services.sqs.resources + +import software.amazon.awssdk.services.sqs.SqsClient +import software.aws.toolkits.jetbrains.core.ClientBackedCachedResource +import software.aws.toolkits.jetbrains.core.Resource + +object SqsResources { + val LIST_QUEUE_URLS: Resource.Cached> = ClientBackedCachedResource(SqsClient::class, "sqs.list_queues") { + listQueuesPaginator().queueUrls().toList() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/FifoPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/FifoPanel.form new file mode 100644 index 0000000000..574058cbbf --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/FifoPanel.form @@ -0,0 +1,65 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/FifoPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/FifoPanel.kt new file mode 100644 index 0000000000..896676fc6e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/FifoPanel.kt @@ -0,0 +1,78 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.sqs.toolwindow + +import com.intellij.icons.AllIcons +import com.intellij.ide.HelpTooltip +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.components.JBTextField +import software.aws.toolkits.jetbrains.services.sqs.MAX_LENGTH_OF_FIFO_ID +import software.aws.toolkits.resources.message +import java.util.UUID +import javax.swing.JLabel +import javax.swing.JPanel + +class FifoPanel { + lateinit var component: JPanel + private set + lateinit var deduplicationId: JBTextField + private set + lateinit var groupId: JBTextField + private set + lateinit var deduplicationContextHelp: JLabel + private set + lateinit var groupContextHelp: JLabel + private set + + init { + deduplicationContextHelp.icon = AllIcons.General.ContextHelp + HelpTooltip().apply { + setDescription(message("sqs.message.deduplication_id.tooltip")) + installOn(deduplicationContextHelp) + } + groupContextHelp.icon = AllIcons.General.ContextHelp + HelpTooltip().apply { + setDescription(message("sqs.message.group_id.tooltip")) + installOn(groupContextHelp) + } + deduplicationId.emptyText.text = message("sqs.required.empty.text") + groupId.emptyText.text = message("sqs.required.empty.text") + clear() + } + + fun validateFields(): List = listOfNotNull( + validateDedupeId(), + validateGroupId() + ) + + fun clear(isSend: Boolean = false) { + deduplicationId.text = UUID.randomUUID().toString() + if (!isSend) { + groupId.text = "" + } + } + + private fun validateDedupeId(): ValidationInfo? = when { + deduplicationId.text.length > MAX_LENGTH_OF_FIFO_ID -> { + ValidationInfo(message("sqs.message.validation.long.id"), deduplicationId) + } + deduplicationId.text.isBlank() -> { + ValidationInfo(message("sqs.message.validation.empty.deduplication_id"), deduplicationId) + } + else -> { + null + } + } + + private fun validateGroupId(): ValidationInfo? = when { + groupId.text.length > MAX_LENGTH_OF_FIFO_ID -> { + ValidationInfo(message("sqs.message.validation.long.id"), groupId) + } + groupId.text.isBlank() -> { + ValidationInfo(message("sqs.message.validation.empty.group_id"), groupId) + } + else -> { + null + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/MessagesTable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/MessagesTable.kt new file mode 100644 index 0000000000..e667c53c1a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/MessagesTable.kt @@ -0,0 +1,53 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs.toolwindow + +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.TableSpeedSearch +import com.intellij.ui.table.TableView +import com.intellij.util.ui.ListTableModel +import software.amazon.awssdk.services.sqs.model.Message +import software.aws.toolkits.resources.message +import javax.swing.JComponent +import javax.swing.JTable + +class MessagesTable { + val component: JComponent + val table: TableView + val tableModel = ListTableModel( + arrayOf( + MessageIdColumn(), + MessageBodyColumn(), + MessageSenderIdColumn(), + MessageDateColumn() + ), + mutableListOf() + ) + + init { + table = TableView(tableModel).apply { + autoscrolls = true + // Disable the header so the user cannot sort or resize columns + tableHeader.isEnabled = false + autoResizeMode = JTable.AUTO_RESIZE_LAST_COLUMN + emptyText.text = message("sqs.message_table_initial_text", message("sqs.poll.message")) + } + + TableSpeedSearch(table) + component = ScrollPaneFactory.createScrollPane(table) + } + + fun reset() { + while (tableModel.rowCount != 0) { + tableModel.removeRow(0) + } + } + + fun setBusy(busy: Boolean) { + table.setPaintBusy(busy) + if (busy) { + table.emptyText.text = message("loading_resource.loading") + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/PollMessagePane.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/PollMessagePane.form new file mode 100644 index 0000000000..35c6215952 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/PollMessagePane.form @@ -0,0 +1,55 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/PollMessagePane.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/PollMessagePane.kt new file mode 100644 index 0000000000..cc7ff36e17 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/PollMessagePane.kt @@ -0,0 +1,129 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs.toolwindow + +import com.intellij.icons.AllIcons +import com.intellij.ide.HelpTooltip +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.CommonShortcuts +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.ui.PopupHandler +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.sqs.SqsClient +import software.amazon.awssdk.services.sqs.model.Message +import software.amazon.awssdk.services.sqs.model.QueueAttributeName +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.services.sqs.MAX_NUMBER_OF_POLLED_MESSAGES +import software.aws.toolkits.jetbrains.services.sqs.Queue +import software.aws.toolkits.jetbrains.services.sqs.actions.CopyMessageAction +import software.aws.toolkits.jetbrains.services.sqs.actions.PurgeQueueAction +import software.aws.toolkits.jetbrains.services.sqs.approximateNumberOfMessages +import software.aws.toolkits.resources.message +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JPanel + +class PollMessagePane( + private val project: Project, + private val client: SqsClient, + private val queue: Queue +) { + private val coroutineScope = projectCoroutineScope(project) + private val bgContext = getCoroutineBgContext() + + lateinit var component: JPanel + private set + lateinit var messagesAvailableLabel: JLabel + private set + lateinit var tablePanel: SimpleToolWindowPanel + private set + lateinit var pollButton: JButton + private set + lateinit var pollHelpLabel: JLabel + private set + + val messagesTable = MessagesTable() + + private fun createUIComponents() { + tablePanel = SimpleToolWindowPanel(false, true) + } + + init { + tablePanel.setContent(messagesTable.component) + pollButton.addActionListener { + poll() + } + pollHelpLabel.icon = AllIcons.General.ContextHelp + HelpTooltip().apply { + setDescription(message("sqs.poll.warning.text")) + installOn(pollHelpLabel) + } + coroutineScope.launch { + getAvailableMessages() + } + addActionsToTable() + } + + suspend fun requestMessages() { + try { + withContext(bgContext) { + val polledMessages: List = client.receiveMessage { + it.queueUrl(queue.queueUrl) + it.attributeNames(QueueAttributeName.ALL) + it.maxNumberOfMessages(MAX_NUMBER_OF_POLLED_MESSAGES) + // Make poll a real peek by setting the visibility timout to 0, so messages can be + // requested by other consumers of the queue immediately + it.visibilityTimeout(0) + }.messages().distinctBy { it.messageId() } + + messagesTable.tableModel.addRows(polledMessages) + + messagesTable.table.emptyText.text = message("sqs.message.no_messages") + } + } catch (e: Exception) { + messagesTable.table.emptyText.text = message("sqs.failed_to_poll_messages") + } finally { + messagesTable.setBusy(busy = false) + } + } + + suspend fun getAvailableMessages() { + try { + withContext(bgContext) { + val numMessages = client.approximateNumberOfMessages(queue.queueUrl) + messagesAvailableLabel.text = message("sqs.messages.available.text", numMessages ?: 0) + } + } catch (e: Exception) { + messagesAvailableLabel.text = message("sqs.failed_to_load_total") + } + } + + private fun addActionsToTable() { + val actionGroup = DefaultActionGroup().apply { + add(CopyMessageAction(messagesTable.table).apply { registerCustomShortcutSet(CommonShortcuts.getCopy(), component) }) + add(Separator.create()) + add(PurgeQueueAction(project, client, queue)) + } + PopupHandler.installPopupHandler( + messagesTable.table, + actionGroup, + ActionPlaces.EDITOR_POPUP, + ActionManager.getInstance() + ) + } + + private fun poll() = coroutineScope.launch { + // TODO: Add debounce + messagesTable.setBusy(busy = true) + messagesTable.reset() + requestMessages() + getAvailableMessages() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/PollMessageUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/PollMessageUtils.kt new file mode 100644 index 0000000000..08b127fcc6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/PollMessageUtils.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.jetbrains.services.sqs.toolwindow + +import com.intellij.util.ui.ColumnInfo +import software.amazon.awssdk.services.sqs.model.Message +import software.amazon.awssdk.services.sqs.model.MessageSystemAttributeName +import software.aws.toolkits.jetbrains.services.sqs.MAX_LENGTH_OF_POLLED_MESSAGES +import software.aws.toolkits.jetbrains.utils.ui.ResizingDateColumnRenderer +import software.aws.toolkits.jetbrains.utils.ui.ResizingTextColumnRenderer +import software.aws.toolkits.jetbrains.utils.ui.WrappingCellRenderer +import software.aws.toolkits.resources.message +import javax.swing.table.TableCellRenderer + +class MessageIdColumn : ColumnInfo(message("sqs.message.message_id")) { + private val renderer = ResizingTextColumnRenderer() + override fun valueOf(item: Message?): String? = item?.messageId() + override fun isCellEditable(item: Message?): Boolean = false + override fun getRenderer(item: Message?): TableCellRenderer? = renderer +} + +class MessageBodyColumn : ColumnInfo(message("sqs.message.message_body")) { + // Truncated the message body to show up to 1024 characters, as it can be up to 256KB in size. Cannot limit the retrieved message size through API. + private val renderer = WrappingCellRenderer(wrapOnSelection = true, wrapOnToggle = false, truncateAfterChars = MAX_LENGTH_OF_POLLED_MESSAGES) + + override fun valueOf(item: Message?): String? = item?.body() + override fun isCellEditable(item: Message?): Boolean = false + override fun getRenderer(item: Message?): TableCellRenderer? = renderer +} + +class MessageSenderIdColumn : ColumnInfo(message("sqs.message.sender_id")) { + private val renderer = ResizingTextColumnRenderer() + override fun valueOf(item: Message?): String? = item?.attributes()?.getValue(MessageSystemAttributeName.SENDER_ID) + override fun isCellEditable(item: Message?): Boolean = false + override fun getRenderer(item: Message?): TableCellRenderer? = renderer +} + +class MessageDateColumn : ColumnInfo(message("sqs.message.timestamp")) { + private val renderer = ResizingDateColumnRenderer(showSeconds = true) + override fun valueOf(item: Message?): String? = item?.attributes()?.getValue(MessageSystemAttributeName.SENT_TIMESTAMP) + override fun isCellEditable(item: Message?): Boolean = false + override fun getRenderer(item: Message?): TableCellRenderer? = renderer +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SendMessagePane.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SendMessagePane.form new file mode 100644 index 0000000000..41c2e12560 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SendMessagePane.form @@ -0,0 +1,65 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SendMessagePane.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SendMessagePane.kt new file mode 100644 index 0000000000..b389cb79aa --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SendMessagePane.kt @@ -0,0 +1,144 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs.toolwindow + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComponentValidator +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.components.JBTextArea +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.sqs.SqsClient +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.services.sqs.Queue +import software.aws.toolkits.jetbrains.services.sqs.telemetryType +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import software.aws.toolkits.telemetry.SqsTelemetry +import javax.swing.JButton +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JScrollPane + +class SendMessagePane( + private val project: Project, + private val client: SqsClient, + private val queue: Queue, + disposable: Disposable +) { + private val coroutineScope = disposableCoroutineScope(disposable) + + lateinit var component: JPanel + private set + lateinit var inputText: JBTextArea + private set + lateinit var sendButton: JButton + private set + lateinit var clearButton: JButton + private set + lateinit var messageSentLabel: JLabel + private set + lateinit var fifoFields: FifoPanel + private set + lateinit var scrollPane: JScrollPane + private set + private val edt = getCoroutineUiContext() + + init { + loadComponents() + setButtons() + setFields() + } + + private fun loadComponents() { + if (!queue.isFifo) { + fifoFields.component.isVisible = false + } + messageSentLabel.isVisible = false + } + + private fun setButtons() { + sendButton.addActionListener { + coroutineScope.launch { sendMessage() } + } + clearButton.addActionListener { + runBlocking { clear() } + messageSentLabel.isVisible = false + } + } + + private fun setFields() { + scrollPane.apply { + border = IdeBorderFactory.createBorder() + } + inputText.apply { + emptyText.text = message("sqs.send.message.body.empty.text") + } + } + + suspend fun sendMessage() { + if (!validateFields()) { + return + } + try { + withContext(getCoroutineBgContext()) { + val messageId = client.sendMessage { + it.queueUrl(queue.queueUrl) + it.messageBody(inputText.text) + if (queue.isFifo) { + it.messageDeduplicationId(fifoFields.deduplicationId.text) + it.messageGroupId(fifoFields.groupId.text) + } + }.messageId() + messageSentLabel.text = message("sqs.send.message.success", messageId) + } + clear(isSend = true) + SqsTelemetry.sendMessage(project, Result.Succeeded, queue.telemetryType()) + } catch (e: Exception) { + messageSentLabel.text = message("sqs.failed_to_send_message") + SqsTelemetry.sendMessage(project, Result.Failed, queue.telemetryType()) + clear(isSend = true) + } finally { + messageSentLabel.isVisible = true + } + } + + suspend fun validateFields(): Boolean { + val validationIssues = mutableListOf().apply { + if (inputText.text.isEmpty()) { + add(ValidationInfo(message("sqs.message.validation.empty.message.body"), inputText)) + } + if (queue.isFifo) { + addAll(fifoFields.validateFields()) + } + } + return if (validationIssues.isEmpty()) { + true + } else { + withContext(getCoroutineUiContext()) { + validationIssues.forEach { validationIssue -> + val errorComponent = validationIssue.component ?: inputText + ComponentValidator + .createPopupBuilder(validationIssue, null) + .setCancelOnClickOutside(true) + .createPopup() + .showUnderneathOf(errorComponent) + } + } + false + } + } + + suspend fun clear(isSend: Boolean = false) = withContext(edt) { + inputText.text = "" + if (queue.isFifo) { + fifoFields.clear(isSend) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SqsWindow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SqsWindow.kt new file mode 100644 index 0000000000..ea1d67bd46 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SqsWindow.kt @@ -0,0 +1,77 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs.toolwindow + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.ui.content.Content +import com.intellij.ui.content.ContentManager +import software.amazon.awssdk.services.sqs.SqsClient +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.services.sqs.Queue +import software.aws.toolkits.jetbrains.services.sqs.telemetryType +import software.aws.toolkits.telemetry.SqsTelemetry + +class SqsWindow(private val project: Project) { + private val client: SqsClient = project.awsClient() + + fun pollMessage(queue: Queue) { + showQueue(queue).pollMessage() + } + + fun sendMessage(queue: Queue) { + showQueue(queue).sendMessage() + } + + private fun showQueue(queue: Queue): SqsWindowUi { + ApplicationManager.getApplication().assertIsDispatchThread() + + SqsTelemetry.openQueue(project, queue.telemetryType()) + + val toolWindow = SqsWindowFactory.getToolWindow(project) + val contentManager = toolWindow.contentManager + + val sqsViewContent = findQueue(queue.queueUrl) ?: createSqsView(contentManager, queue) + + toolWindow.show() + contentManager.setSelectedContent(sqsViewContent, true) + + return sqsViewContent.component as SqsWindowUi + } + + fun findQueue(queueUrl: String): Content? { + ApplicationManager.getApplication().assertIsDispatchThread() + + val toolWindow = SqsWindowFactory.getToolWindow(project) + val contentManager = toolWindow.contentManager + + return contentManager.contents.find { + val component = it.component + component is SqsWindowUi && component.queue.queueUrl == queueUrl + } + } + + private fun createSqsView(contentManager: ContentManager, queue: Queue): Content { + val sqsView = SqsWindowUi(project, client, queue) + val sqsViewContent = contentManager.factory.createContent(sqsView.component, queue.queueName, false) + sqsViewContent.isCloseable = true + sqsViewContent.isPinnable = true + contentManager.addContent(sqsViewContent) + + return sqsViewContent + } + + fun closeQueue(queueUrl: String) { + ApplicationManager.getApplication().assertIsDispatchThread() + + findQueue(queueUrl)?.let { + SqsWindowFactory.getToolWindow(project).contentManager.removeContent(it, true) + } + } + + companion object { + fun getInstance(project: Project): SqsWindow = project.service() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SqsWindowFactory.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SqsWindowFactory.kt new file mode 100644 index 0000000000..72614dd0df --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SqsWindowFactory.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.services.sqs.toolwindow + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.openapi.wm.ToolWindowManager +import software.aws.toolkits.resources.message + +class SqsWindowFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + // No content, it will be shown when queues are viewed + runInEdt { + toolWindow.installWatcher(toolWindow.contentManager) + } + } + + override fun init(toolWindow: ToolWindow) { + toolWindow.stripeTitle = message("sqs.toolwindow") + } + + override fun shouldBeAvailable(project: Project): Boolean = false + + companion object { + private const val TOOL_WINDOW_ID = "aws.sqs" + + fun getToolWindow(project: Project) = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW_ID) + ?: throw IllegalStateException("Can't find tool window $TOOL_WINDOW_ID") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SqsWindowUi.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SqsWindowUi.kt new file mode 100644 index 0000000000..e2093e7de9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/sqs/toolwindow/SqsWindowUi.kt @@ -0,0 +1,42 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.sqs.toolwindow + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.ui.components.JBTabbedPane +import com.intellij.util.ui.JBUI +import software.amazon.awssdk.services.sqs.SqsClient +import software.aws.toolkits.jetbrains.services.sqs.Queue +import software.aws.toolkits.resources.message + +class SqsWindowUi(private val project: Project, private val client: SqsClient, val queue: Queue) : SimpleToolWindowPanel(false, true), Disposable { + private val mainPanel = JBTabbedPane().apply { + tabComponentInsets = JBUI.emptyInsets() + border = JBUI.Borders.empty() + add(message("sqs.queue.polled.messages"), PollMessagePane(project, client, queue).component) + add(message("sqs.send.message"), SendMessagePane(project, client, queue, this@SqsWindowUi).component) + } + + init { + setContent(mainPanel) + } + + fun pollMessage() { + mainPanel.selectedIndex = POLL_MESSAGE_PANE + } + + fun sendMessage() { + mainPanel.selectedIndex = SEND_MESSAGE_PANE + } + + override fun dispose() { + } + + companion object { + const val POLL_MESSAGE_PANE = 0 + const val SEND_MESSAGE_PANE = 1 + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ssm/SsmPlugin.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ssm/SsmPlugin.kt new file mode 100644 index 0000000000..4ea4eaa5bd --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/ssm/SsmPlugin.kt @@ -0,0 +1,141 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.ssm + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.io.Decompressor +import com.intellij.util.system.CpuArch +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.getTextFromUrl +import software.aws.toolkits.jetbrains.core.saveFileFromUrl +import software.aws.toolkits.jetbrains.core.tools.BaseToolType +import software.aws.toolkits.jetbrains.core.tools.DocumentedToolType +import software.aws.toolkits.jetbrains.core.tools.FourPartVersion +import software.aws.toolkits.jetbrains.core.tools.ManagedToolType +import software.aws.toolkits.jetbrains.core.tools.Tool +import software.aws.toolkits.jetbrains.core.tools.ToolType +import software.aws.toolkits.jetbrains.core.tools.VersionRange +import software.aws.toolkits.jetbrains.core.tools.until +import software.aws.toolkits.jetbrains.utils.checkSuccess +import software.aws.toolkits.telemetry.ToolId +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import kotlin.streams.asSequence + +object SsmPlugin : ManagedToolType, DocumentedToolType, BaseToolType() { + private val hasDpkg by lazy { hasCommand("dpkg-deb") } + private val hasRpm2Cpio by lazy { hasCommand("rpm2cpio") } + + override val telemetryId: ToolId = ToolId.SessionManagerPlugin + override val displayName: String = "AWS Session Manager Plugin" + + override fun supportedVersions(): VersionRange = FourPartVersion(1, 2, 0, 0) until FourPartVersion(2, 0, 0, 0) + + override fun downloadVersion(version: FourPartVersion, destinationDir: Path, indicator: ProgressIndicator?): Path { + val downloadUrl = when { + SystemInfo.isWindows -> windowsUrl(version) + SystemInfo.isMac -> macUrl(version) + SystemInfo.isLinux && hasDpkg && CpuArch.isArm64() -> ubuntuArm64Url(version) + SystemInfo.isLinux && hasDpkg && CpuArch.isIntel64() -> ubuntuI64Url(version) + SystemInfo.isLinux && hasRpm2Cpio && CpuArch.isArm64() -> linuxArm64Url(version) + SystemInfo.isLinux && hasRpm2Cpio && CpuArch.isIntel64() -> linuxI64Url(version) + else -> throw IllegalStateException("Failed to find compatible SSM plugin: SystemInfo=${SystemInfo.OS_NAME}, Arch=${SystemInfo.OS_ARCH}") + } + + val fileName = downloadUrl.substringAfterLast("/") + val destination = destinationDir.resolve(fileName) + + saveFileFromUrl(downloadUrl, destination, indicator) + + return destination + } + + override fun installVersion(downloadArtifact: Path, destinationDir: Path, indicator: ProgressIndicator?) { + when (val extension = downloadArtifact.fileName.toString().substringAfterLast(".")) { + "zip" -> extractZip(downloadArtifact, destinationDir) + "rpm" -> runInstall( + GeneralCommandLine("sh", "-c", """rpm2cpio "$downloadArtifact" | (mkdir -p "$destinationDir" && cd "$destinationDir" && cpio -idmv)""") + ) + "deb" -> runInstall(GeneralCommandLine("sh", "-c", """mkdir -p "$destinationDir" && dpkg-deb -x "$downloadArtifact" "$destinationDir"""")) + else -> throw IllegalStateException("Unknown extension $extension") + } + } + + override fun determineLatestVersion(): FourPartVersion = parseVersion(getTextFromUrl(VERSION_FILE)) + + override fun parseVersion(output: String): FourPartVersion = FourPartVersion.parse(output) + + override fun toTool(installDir: Path): Tool> { + val executableName = if (SystemInfo.isWindows) { + "session-manager-plugin.exe" + } else { + "session-manager-plugin" + } + + return Files.walk(installDir).use { files -> + files.asSequence().filter { it.fileName.toString() == executableName && Files.isExecutable(it) } + .map { Tool(this, it) } + .firstOrNull() + } ?: throw IllegalStateException("Failed to locate $executableName under $installDir") + } + + override fun documentationUrl() = + "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html" + + @VisibleForTesting + fun windowsUrl(version: FourPartVersion) = "$BASE_URL/${version.displayValue()}/windows/SessionManagerPlugin.zip" + + @VisibleForTesting + fun macUrl(version: FourPartVersion) = "$BASE_URL/${version.displayValue()}/mac/sessionmanager-bundle.zip" + + @VisibleForTesting + fun ubuntuArm64Url(version: FourPartVersion) = "$BASE_URL/${version.displayValue()}/ubuntu_arm64/session-manager-plugin.deb" + + @VisibleForTesting + fun ubuntuI64Url(version: FourPartVersion) = "$BASE_URL/${version.displayValue()}/ubuntu_64bit/session-manager-plugin.deb" + + @VisibleForTesting + fun linuxArm64Url(version: FourPartVersion) = "$BASE_URL/${version.displayValue()}/linux_arm64/session-manager-plugin.rpm" + + @VisibleForTesting + fun linuxI64Url(version: FourPartVersion) = "$BASE_URL/${version.displayValue()}/linux_64bit/session-manager-plugin.rpm" + + private fun runInstall(cmd: GeneralCommandLine) { + val processOutput = ExecUtil.execAndGetOutput(cmd, INSTALL_TIMEOUT.toMillis().toInt()) + + if (!processOutput.checkSuccess(LOGGER)) { + throw IllegalStateException("Failed to extract $displayName\nSTDOUT:${processOutput.stdout}\nSTDERR:${processOutput.stderr}") + } + } + + private fun extractZip(downloadArtifact: Path, destinationDir: Path) { + val decompressor = Decompressor.Zip(downloadArtifact).withZipExtensions() + if (!SystemInfo.isWindows) { + decompressor.extract(destinationDir) + return + } + + // on windows there is a zip inside a zip :( + val tempDir = Files.createTempDirectory(id) + decompressor.extract(tempDir) + + val intermediateZip = tempDir.resolve("package.zip") + Decompressor.Zip(intermediateZip).withZipExtensions().extract(destinationDir) + } + + private fun hasCommand(cmd: String): Boolean { + val output = ExecUtil.execAndGetOutput(GeneralCommandLine("sh", "-c", "command -v $cmd"), EXECUTION_TIMEOUT.toMillis().toInt()) + return output.exitCode == 0 + } + private val LOGGER = getLogger() + private const val BASE_URL = "https://s3.us-east-1.amazonaws.com/session-manager-downloads/plugin" + private const val VERSION_FILE = "$BASE_URL/latest/VERSION" + private val EXECUTION_TIMEOUT = Duration.ofSeconds(5) + private val INSTALL_TIMEOUT = Duration.ofSeconds(30) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/ClientMetadata.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/ClientMetadata.kt index 612b9be063..dd4241ca3c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/ClientMetadata.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/ClientMetadata.kt @@ -6,8 +6,12 @@ package software.aws.toolkits.jetbrains.services.telemetry import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.ApplicationNamesInfo import com.intellij.openapi.util.SystemInfo +import software.amazon.awssdk.services.codewhispererruntime.model.IdeCategory +import software.amazon.awssdk.services.codewhispererruntime.model.OperatingSystem +import software.amazon.awssdk.services.codewhispererruntime.model.UserContext import software.amazon.awssdk.services.toolkittelemetry.model.AWSProduct import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererConstants.FEATURE_EVALUATION_PRODUCT_NAME import software.aws.toolkits.jetbrains.settings.AwsSettings data class ClientMetadata( @@ -15,11 +19,27 @@ data class ClientMetadata( val productVersion: String = AwsToolkit.PLUGIN_VERSION, val clientId: String = AwsSettings.getInstance().clientId.toString(), val parentProduct: String = ApplicationNamesInfo.getInstance().fullProductNameWithEdition, - val parentProductVersion: String = ApplicationInfo.getInstance().fullVersion, + val parentProductVersion: String = ApplicationInfo.getInstance().build.baselineVersion.toString(), val os: String = SystemInfo.OS_NAME, - val osVersion: String = SystemInfo.OS_VERSION + val osVersion: String = SystemInfo.OS_VERSION, ) { companion object { val DEFAULT_METADATA = ClientMetadata() } + + private val osForCodeWhisperer: OperatingSystem = + when { + SystemInfo.isWindows -> OperatingSystem.WINDOWS + SystemInfo.isMac -> OperatingSystem.MAC + // For now, categorize everything else as "Linux" (Linux/FreeBSD/Solaris/etc) + else -> OperatingSystem.LINUX + } + + val codeWhispererUserContext = UserContext.builder() + .ideCategory(IdeCategory.JETBRAINS) + .operatingSystem(osForCodeWhisperer) + .product(FEATURE_EVALUATION_PRODUCT_NAME) + .clientId(clientId) + .ideVersion(productVersion) + .build() } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/CognitoIdentityProvider.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/CognitoIdentityProvider.kt index 6daba4af3f..38dba40d60 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/CognitoIdentityProvider.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/CognitoIdentityProvider.kt @@ -10,6 +10,7 @@ import software.amazon.awssdk.services.cognitoidentity.CognitoIdentityClient import software.amazon.awssdk.services.cognitoidentity.model.Credentials import software.amazon.awssdk.services.cognitoidentity.model.GetCredentialsForIdentityRequest import software.amazon.awssdk.services.cognitoidentity.model.GetIdRequest +import software.amazon.awssdk.utils.SdkAutoCloseable import software.amazon.awssdk.utils.cache.CachedSupplier import software.amazon.awssdk.utils.cache.NonBlocking import software.amazon.awssdk.utils.cache.RefreshResult @@ -22,14 +23,13 @@ import java.time.temporal.ChronoUnit * * @constructor Creates a new AwsCredentialsProvider that uses credentials from a Cognito Identity pool. * @property identityPool The name of the pool to create users from - * @param region The region associated with this Cognito pool * @param cacheStorage A storage solution to cache an identity ID, disabled if null */ -class AWSCognitoCredentialsProvider( +class AwsCognitoCredentialsProvider( private val identityPool: String, private val cognitoClient: CognitoIdentityClient, cacheStorage: CachedIdentityStorage? = null -) : AwsCredentialsProvider { +) : AwsCredentialsProvider, SdkAutoCloseable { private val identityIdProvider = AwsCognitoIdentityProvider(cognitoClient, identityPool, cacheStorage) private val cacheSupplier = CachedSupplier.builder(this::updateCognitoCredentials) .prefetchStrategy(NonBlocking("Cognito Identity Credential Refresh")) @@ -58,6 +58,11 @@ class AWSCognitoCredentialsProvider( return cognitoClient.getCredentialsForIdentity(request).credentials() } + + override fun close() { + cognitoClient.close() + cacheSupplier.close() + } } private class AwsCognitoIdentityProvider( diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/DefaultTelemetryPublisher.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/DefaultTelemetryPublisher.kt index e6f772cb69..82f9745d8b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/DefaultTelemetryPublisher.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/DefaultTelemetryPublisher.kt @@ -3,7 +3,7 @@ package software.aws.toolkits.jetbrains.services.telemetry -import kotlinx.coroutines.Dispatchers +import com.intellij.openapi.util.registry.Registry import kotlinx.coroutines.withContext import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider import software.amazon.awssdk.regions.Region @@ -12,19 +12,24 @@ import software.amazon.awssdk.services.toolkittelemetry.ToolkitTelemetryClient import software.amazon.awssdk.services.toolkittelemetry.model.MetadataEntry import software.amazon.awssdk.services.toolkittelemetry.model.MetricDatum import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment -import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.clients.nullDefaultProfileFile import software.aws.toolkits.core.telemetry.MetricEvent import software.aws.toolkits.core.telemetry.TelemetryPublisher import software.aws.toolkits.jetbrains.core.AwsClientManager import software.aws.toolkits.jetbrains.core.AwsSdkClient -import kotlin.streams.toList +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext class DefaultTelemetryPublisher( private val clientMetadata: ClientMetadata = ClientMetadata.DEFAULT_METADATA, - private val client: ToolkitTelemetryClient = createDefaultTelemetryClient() + private val clientProvider: () -> ToolkitTelemetryClient ) : TelemetryPublisher { + constructor() : this(clientProvider = { createDefaultTelemetryClient() }) + + private val lazyClient = lazy { clientProvider() } + private val client by lazyClient + override suspend fun publish(metricEvents: Collection) { - withContext(Dispatchers.IO) { + withContext(getCoroutineBgContext()) { client.postMetrics { it.awsProduct(clientMetadata.productName) it.awsProductVersion(clientMetadata.productVersion) @@ -38,8 +43,8 @@ class DefaultTelemetryPublisher( } } - override suspend fun sendFeedback(sentiment: Sentiment, comment: String) { - withContext(Dispatchers.IO) { + override suspend fun sendFeedback(sentiment: Sentiment, comment: String, metadata: Map) { + withContext(getCoroutineBgContext()) { client.postFeedback { it.awsProduct(clientMetadata.productName) it.awsProductVersion(clientMetadata.productVersion) @@ -49,6 +54,9 @@ class DefaultTelemetryPublisher( it.parentProductVersion(clientMetadata.parentProductVersion) it.sentiment(sentiment) it.comment(comment) + if (metadata.isNotEmpty()) { + it.metadata(metadata.map { (k, v) -> MetadataEntry.builder().key(k).value(v).build() }) + } } } } @@ -62,6 +70,7 @@ class DefaultTelemetryPublisher( .metricName(metricName) .unit(datum.unit) .value(datum.value) + .passive(datum.passive) .metadata( datum.metadata.entries.stream().map { MetadataEntry.builder() @@ -83,28 +92,33 @@ class DefaultTelemetryPublisher( } } - private companion object { + override fun close() { + if (lazyClient.isInitialized()) { + client.close() + } + } + private companion object { private const val METADATA_AWS_ACCOUNT = "awsAccount" private const val METADATA_AWS_REGION = "awsRegion" private fun createDefaultTelemetryClient(): ToolkitTelemetryClient { + val region = Region.of(Registry.get("aws.telemetry.region").asString()) val sdkClient = AwsSdkClient.getInstance() - return ToolkitClientManager.createNewClient( - ToolkitTelemetryClient::class, - sdkClient.sdkHttpClient, - Region.US_EAST_1, - AWSCognitoCredentialsProvider( - "us-east-1:820fd6d1-95c0-4ca4-bffb-3f01d32da842", + + return AwsClientManager.getInstance().createUnmanagedClient( + credProvider = AwsCognitoCredentialsProvider( + Registry.get("aws.telemetry.identityPool").asString(), CognitoIdentityClient.builder() .credentialsProvider(AnonymousCredentialsProvider.create()) - .region(Region.US_EAST_1) - .httpClient(sdkClient.sdkHttpClient) + .region(region) + .httpClient(sdkClient.sharedSdkClient()) + .nullDefaultProfileFile() .build() ), - AwsClientManager.userAgent, - "https://client-telemetry.us-east-1.amazonaws.com" - ) + region = region, + endpointOverride = Registry.get("aws.telemetry.endpoint").asString() + ) { _, _, _, _, clientOverrideConfiguration -> clientOverrideConfiguration.nullDefaultProfileFile() } } } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/OpenTelemetryAction.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/OpenTelemetryAction.kt new file mode 100644 index 0000000000..c097257457 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/OpenTelemetryAction.kt @@ -0,0 +1,71 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.telemetry + +import com.intellij.execution.filters.TextConsoleBuilderFactory +import com.intellij.execution.ui.ConsoleView +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.DefaultProjectFactory +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.ui.FrameWrapper +import com.intellij.openapi.util.Disposer +import com.intellij.ui.JBColor +import com.intellij.util.ui.components.BorderLayoutPanel +import software.aws.toolkits.core.telemetry.MetricEvent +import software.aws.toolkits.jetbrains.AwsToolkit +import javax.swing.BorderFactory +import javax.swing.JComponent + +class OpenTelemetryAction : DumbAwareAction() { + override fun actionPerformed(event: AnActionEvent) { + TelemetryDialog().show() + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabledAndVisible = AwsToolkit.isDeveloperMode() + } + + private class TelemetryDialog : FrameWrapper(null), TelemetryListener { + private val consoleView: ConsoleView by lazy { + TextConsoleBuilderFactory.getInstance().createBuilder(DefaultProjectFactory.getInstance().defaultProject).apply { + setViewer(true) + }.console + } + + init { + title = "Telemetry Viewer" + component = createContent() + } + + private fun createContent(): JComponent { + val panel = BorderLayoutPanel() + val consoleComponent = consoleView.component + + val actionGroup = DefaultActionGroup(*consoleView.createConsoleActions()) + val toolbar = ActionManager.getInstance().createActionToolbar("AWS.TelemetryViewer", actionGroup, false) + + toolbar.setTargetComponent(consoleComponent) + + panel.addToLeft(toolbar.component) + panel.addToCenter(consoleComponent) + + // Add a border to make things look nicer. + consoleComponent.border = BorderFactory.createLineBorder(JBColor.GRAY) + + val telemetryService = TelemetryService.getInstance() + telemetryService.addListener(this) + Disposer.register(this) { telemetryService.removeListener(this) } + Disposer.register(this, consoleView) + + return panel + } + + override fun onTelemetryEvent(event: MetricEvent) { + consoleView.print(event.toString() + "\n", ConsoleViewContentType.NORMAL_OUTPUT) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/TelemetryService.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/TelemetryService.kt index 4c9165af48..ae14c9a3e5 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/TelemetryService.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/services/telemetry/TelemetryService.kt @@ -4,10 +4,12 @@ package software.aws.toolkits.jetbrains.services.telemetry import com.intellij.openapi.Disposable -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment +import software.aws.toolkits.core.ConnectionSettings import software.aws.toolkits.core.telemetry.DefaultMetricEvent +import software.aws.toolkits.core.telemetry.DefaultMetricEvent.Companion.METADATA_INVALID import software.aws.toolkits.core.telemetry.DefaultMetricEvent.Companion.METADATA_NA import software.aws.toolkits.core.telemetry.DefaultMetricEvent.Companion.METADATA_NOT_SET import software.aws.toolkits.core.telemetry.DefaultTelemetryBatcher @@ -15,45 +17,87 @@ import software.aws.toolkits.core.telemetry.MetricEvent import software.aws.toolkits.core.telemetry.TelemetryBatcher import software.aws.toolkits.core.telemetry.TelemetryPublisher import software.aws.toolkits.core.utils.tryOrNull -import software.aws.toolkits.jetbrains.core.AwsResourceCache import software.aws.toolkits.jetbrains.core.credentials.activeRegion +import software.aws.toolkits.jetbrains.core.credentials.getConnectionSettings +import software.aws.toolkits.jetbrains.core.experiments.ToolkitExperimentManager +import software.aws.toolkits.jetbrains.core.getResourceIfPresent import software.aws.toolkits.jetbrains.services.sts.StsResources import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.jetbrains.ui.feedback.ENABLED_EXPERIMENTS import java.util.concurrent.atomic.AtomicBoolean -abstract class TelemetryService(private val publisher: TelemetryPublisher, private val batcher: TelemetryBatcher) : Disposable { - data class MetricEventMetadata( - val awsAccount: String = METADATA_NA, - val awsRegion: String = METADATA_NA - ) +data class MetricEventMetadata( + val awsAccount: String = METADATA_NA, + val awsRegion: String = METADATA_NA +) + +interface TelemetryListener { + fun onTelemetryEvent(event: MetricEvent) +} +abstract class TelemetryService(private val publisher: TelemetryPublisher, private val batcher: TelemetryBatcher) : Disposable { private val isDisposing = AtomicBoolean(false) + private val listeners = mutableSetOf() init { setTelemetryEnabled(AwsSettings.getInstance().isTelemetryEnabled) } - fun record(project: Project?, buildEvent: MetricEvent.Builder.() -> Unit = {}) { - val metricEventMetadata = if (project == null) MetricEventMetadata() else MetricEventMetadata( - awsAccount = project.activeAwsAccountIfKnown() ?: METADATA_NOT_SET, - awsRegion = project.activeRegion().id - ) + fun record(connectionSettings: ConnectionSettings?, buildEvent: MetricEvent.Builder.() -> Unit) { + val metricEventMetadata = when (connectionSettings) { + is ConnectionSettings -> MetricEventMetadata( + awsAccount = connectionSettings.activeAwsAccountIfKnown() ?: METADATA_NOT_SET, + awsRegion = connectionSettings.region.id + ) + else -> MetricEventMetadata() + } + record(metricEventMetadata, buildEvent) + } + + fun record(project: Project?, buildEvent: MetricEvent.Builder.() -> Unit) { + // It is possible that a race can happen if we record telemetry but project has been closed, i.e. async actions + val metricEventMetadata = if (project != null) { + if (project.isDisposed) { + MetricEventMetadata( + awsAccount = METADATA_INVALID, + awsRegion = METADATA_INVALID + ) + } else { + MetricEventMetadata( + awsAccount = project.getConnectionSettings()?.activeAwsAccountIfKnown() ?: METADATA_NOT_SET, + awsRegion = project.activeRegion().id + ) + } + } else { + MetricEventMetadata() + } record(metricEventMetadata, buildEvent) } - private fun Project.activeAwsAccountIfKnown(): String? = tryOrNull { AwsResourceCache.getInstance(this).getResourceIfPresent(StsResources.ACCOUNT) } + private fun ConnectionSettings.activeAwsAccountIfKnown(): String? = tryOrNull { this.getResourceIfPresent(StsResources.ACCOUNT) } @Synchronized fun setTelemetryEnabled(isEnabled: Boolean) { batcher.onTelemetryEnabledChanged(isEnabled and TELEMETRY_ENABLED) } + fun addListener(listener: TelemetryListener) { + listeners.add(listener) + } + + fun removeListener(listener: TelemetryListener) { + listeners.remove(listener) + } + override fun dispose() { if (!isDisposing.compareAndSet(false, true)) { return } + listeners.clear() + batcher.shutdown() + publisher.close() } fun record(metricEventMetadata: MetricEventMetadata, buildEvent: MetricEvent.Builder.() -> Unit) { @@ -63,23 +107,33 @@ abstract class TelemetryService(private val publisher: TelemetryPublisher, priva buildEvent(builder) - batcher.enqueue(builder.build()) + val event = builder.build() + + runCatching { + listeners.forEach { it.onTelemetryEvent(event) } + } + + batcher.enqueue(event) } - suspend fun sendFeedback(sentiment: Sentiment, comment: String) { - publisher.sendFeedback(sentiment, comment) + suspend fun sendFeedback(sentiment: Sentiment, comment: String, metadata: Map = emptyMap()) { + val experiments = ToolkitExperimentManager.enabledExperiments().joinToString(",") { it.id } + publisher.sendFeedback(sentiment, comment, metadata + (ENABLED_EXPERIMENTS to experiments)) } companion object { private const val TELEMETRY_KEY = "aws.toolkits.enableTelemetry" private val TELEMETRY_ENABLED = System.getProperty(TELEMETRY_KEY)?.toBoolean() ?: true - fun getInstance(): TelemetryService = ServiceManager.getService(TelemetryService::class.java) + fun getInstance(): TelemetryService = service() } } -class DefaultTelemetryService : TelemetryService(PUBLISHER, DefaultTelemetryBatcher(PUBLISHER)) { +class DefaultTelemetryService : TelemetryService { + constructor() : super(publisher, batcher) + private companion object { - val PUBLISHER = DefaultTelemetryPublisher() + private val publisher: TelemetryPublisher by lazy { DefaultTelemetryPublisher() } + private val batcher: TelemetryBatcher by lazy { DefaultTelemetryBatcher(publisher) } } } 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 44690150af..450a32ec36 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettings.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettings.kt @@ -6,10 +6,11 @@ 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.application.ApplicationManager 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.components.service import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.DumbAware import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService @@ -21,14 +22,23 @@ interface AwsSettings { var isTelemetryEnabled: Boolean var promptedForTelemetry: Boolean var useDefaultCredentialRegion: UseAwsCredentialRegion + var profilesNotification: ProfilesNotification val clientId: UUID companion object { @JvmStatic - fun getInstance(): AwsSettings = ServiceManager.getService(AwsSettings::class.java) + fun getInstance(): AwsSettings = service() } } +enum class ProfilesNotification(private val description: String) { + Always(message("settings.profiles.always")), + OnFailure(message("settings.profiles.on_failure")), + Never(message("settings.profiles.never")); + + override fun toString(): String = description +} + 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")), @@ -67,9 +77,25 @@ class DefaultAwsSettings : PersistentStateComponent, AwsSettin state.useDefaultCredentialRegion = value.name } + override var profilesNotification: ProfilesNotification + get() = state.profilesNotification?.let { ProfilesNotification.valueOf(it) } ?: ProfilesNotification.Always + set(value) { + state.profilesNotification = 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()) + @Synchronized get() { + val id = when { + ApplicationManager.getApplication().isUnitTestMode || System.getProperty("robot-server.port") != null -> "ffffffff-ffff-ffff-ffff-ffffffffffff" + isTelemetryEnabled == false -> "11111111-1111-1111-1111-111111111111" + else -> { + preferences.get(CLIENT_ID_KEY, UUID.randomUUID().toString()).also { + preferences.put(CLIENT_ID_KEY, it.toString()) + } + } + } + + return UUID.fromString(id) } companion object { @@ -80,7 +106,8 @@ class DefaultAwsSettings : PersistentStateComponent, AwsSettin data class AwsConfiguration( var isTelemetryEnabled: Boolean? = null, var promptedForTelemetry: Boolean? = null, - var useDefaultCredentialRegion: String? = null + var useDefaultCredentialRegion: String? = null, + var profilesNotification: String? = null ) class ShowSettingsAction : AnAction(message("aws.settings.show.label")), DumbAware { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.form deleted file mode 100644 index 36e5e9f6eb..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.form +++ /dev/null @@ -1,162 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.java deleted file mode 100644 index 27d57d29cc..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.java +++ /dev/null @@ -1,299 +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 static com.intellij.openapi.application.ActionsKt.runInEdt; -import static software.aws.toolkits.resources.Localization.message; - -import com.intellij.ide.BrowserUtil; -import com.intellij.openapi.application.ModalityState; -import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; -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; -import com.intellij.ui.components.JBCheckBox; -import com.intellij.ui.components.JBTextField; -import com.intellij.ui.components.labels.LinkLabel; -import com.intellij.util.ui.SwingHelper; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Objects; -import java.util.concurrent.CompletionException; -import javax.swing.JComponent; -import javax.swing.JPanel; -import org.jetbrains.annotations.Nls; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -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.ExecutableType; -import software.aws.toolkits.jetbrains.core.executables.CfnLintExecutable; -import software.aws.toolkits.jetbrains.core.help.HelpIds; -import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable; - -public class AwsSettingsConfigurable implements SearchableConfigurable { - private static final String CLOUDDEBUG = "clouddebug"; - private static final String SAM = "sam"; - private static final String CFNLINT = "cfn-lint"; - - private final Project project; - private JPanel panel; - @NotNull - TextFieldWithBrowseButton samExecutablePath; - @NotNull - TextFieldWithBrowseButton cloudDebugExecutablePath; - @NotNull - TextFieldWithBrowseButton cfnLintExecutablePath; - private LinkLabel samHelp; - private LinkLabel cloudDebugHelp; - private LinkLabel cfnLintHelp; - private JBCheckBox showAllHandlerGutterIcons; - @NotNull - JBCheckBox enableTelemetry; - private JPanel cfnLintSettings; - private JPanel serverlessSettings; - private JPanel remoteDebugSettings; - private JPanel applicationLevelSettings; - - private ComboBox defaultRegionHandling; - - public AwsSettingsConfigurable(Project project) { - this.project = project; - - applicationLevelSettings.setBorder(IdeBorderFactory.createTitledBorder(message("aws.settings.global_label"))); - serverlessSettings.setBorder(IdeBorderFactory.createTitledBorder(message("aws.settings.serverless_label"))); - cfnLintSettings.setBorder(IdeBorderFactory.createTitledBorder(message("aws.settings.cfnlint_label"))); - remoteDebugSettings.setBorder(IdeBorderFactory.createTitledBorder(message("aws.settings.remote_debug_label"))); - - SwingHelper.setPreferredWidth(cfnLintExecutablePath, this.panel.getWidth()); - SwingHelper.setPreferredWidth(samExecutablePath, this.panel.getWidth()); - SwingHelper.setPreferredWidth(cloudDebugExecutablePath, this.panel.getWidth()); - SwingHelper.setPreferredWidth(cfnLintExecutablePath, this.panel.getWidth()); - } - - @Nullable - @Override - public JComponent createComponent() { - return panel; - } - - private void createUIComponents() { - cloudDebugHelp = createHelpLink(HelpIds.CLOUD_DEBUG_ENABLE); - cloudDebugExecutablePath = createCliConfigurationElement(getCloudDebugExecutableInstance(), CLOUDDEBUG); - samHelp = createHelpLink(HelpIds.SAM_CLI_INSTALL); - samExecutablePath = createCliConfigurationElement(getSamExecutableInstance(), SAM); - cfnLintHelp = createHelpLink(HelpIds.CFN_LINT); - cfnLintExecutablePath = createCliConfigurationElement(getCfnLintExecutableInstance(), CFNLINT); - defaultRegionHandling = new ComboBox<>(UseAwsCredentialRegion.values()); - } - - @NotNull - @Override - public String getId() { - return "aws"; - } - - @Nls - @Override - public String getDisplayName() { - return message("aws.settings.title"); - } - - @Override - public boolean isModified() { - AwsSettings awsSettings = AwsSettings.getInstance(); - LambdaSettings lambdaSettings = LambdaSettings.getInstance(project); - - return !Objects.equals(getSamTextboxInput(), getSavedExecutablePath(getSamExecutableInstance(), false)) || - !Objects.equals(getCloudDebugTextboxInput(), getSavedExecutablePath(getCloudDebugExecutableInstance(), false)) || - !Objects.equals(getCfnLintTextboxInput(), getSavedExecutablePath(getCfnLintExecutableInstance(), false)) || - isModified(showAllHandlerGutterIcons, lambdaSettings.getShowAllHandlerGutterIcons()) || - isModified(enableTelemetry, awsSettings.isTelemetryEnabled()) || - isModified(defaultRegionHandling, awsSettings.getUseDefaultCredentialRegion()); - } - - @Override - public void apply() throws ConfigurationException { - validateAndSaveCliSettings((JBTextField) samExecutablePath.getTextField(), - "sam", - getSamExecutableInstance(), - getSavedExecutablePath(getSamExecutableInstance(), false), - getSamTextboxInput()); - validateAndSaveCliSettings((JBTextField) cloudDebugExecutablePath.getTextField(), - "cloud-debug", - getCloudDebugExecutableInstance(), - getSavedExecutablePath(getCloudDebugExecutableInstance(), false), - getCloudDebugTextboxInput()); - validateAndSaveCliSettings((JBTextField) cfnLintExecutablePath.getTextField(), - "cfn-lint", - getCfnLintExecutableInstance(), - getSavedExecutablePath(getCfnLintExecutableInstance(), false), - getCfnLintTextboxInput()); - - saveAwsSettings(); - saveLambdaSettings(); - } - - @Override - public void reset() { - AwsSettings awsSettings = AwsSettings.getInstance(); - LambdaSettings lambdaSettings = LambdaSettings.getInstance(project); - - samExecutablePath.setText(getSavedExecutablePath(getSamExecutableInstance(), false)); - cloudDebugExecutablePath.setText(getSavedExecutablePath(getCloudDebugExecutableInstance(), false)); - cfnLintExecutablePath.setText(getSavedExecutablePath(getCfnLintExecutableInstance(), false)); - showAllHandlerGutterIcons.setSelected(lambdaSettings.getShowAllHandlerGutterIcons()); - enableTelemetry.setSelected(awsSettings.isTelemetryEnabled()); - defaultRegionHandling.setSelectedItem(awsSettings.getUseDefaultCredentialRegion()); - } - - @NotNull - private CloudDebugExecutable getCloudDebugExecutableInstance() { - return ExecutableType.getExecutable(CloudDebugExecutable.class); - } - - @NotNull - private SamExecutable getSamExecutableInstance() { - return ExecutableType.getExecutable(SamExecutable.class); - } - - @NotNull - private CfnLintExecutable getCfnLintExecutableInstance() { - return ExecutableType.getExecutable(CfnLintExecutable.class); - } - - @Nullable - private String getSamTextboxInput() { - return StringUtil.nullize(samExecutablePath.getText().trim()); - } - - @Nullable - private String getCloudDebugTextboxInput() { - return StringUtil.nullize(cloudDebugExecutablePath.getText().trim()); - } - - @Nullable - private String getCfnLintTextboxInput() { - return StringUtil.nullize(cfnLintExecutablePath.getText().trim()); - } - - @NotNull - private LinkLabel createHelpLink(HelpIds helpId) { - return LinkLabel.create(message("aws.settings.learn_more"), () -> BrowserUtil.browse(helpId.getUrl())); - } - - @NotNull - private TextFieldWithBrowseButton createCliConfigurationElement(ExecutableType executableType, String cliName) { - final String autoDetectPath = getSavedExecutablePath(executableType, true); - JBTextField cloudDebugExecutableTextField = new JBTextField(); - final TextFieldWithBrowseButton field = new TextFieldWithBrowseButton(cloudDebugExecutableTextField); - if (autoDetectPath != null) { - cloudDebugExecutableTextField.getEmptyText().setText(autoDetectPath); - } - field.addBrowseFolderListener( - message("aws.settings.find.title", cliName), - message("aws.settings.executables.find.description", cliName), - project, - FileChooserDescriptorFactory.createSingleLocalFileDescriptor() - ); - return field; - } - - @Nullable - // modifyMessageBasedOnDetectionStatus will append "Auto-detected: ...." to the - // message if the executable is found this is used for setting the empty box text - private String getSavedExecutablePath(ExecutableType executableType, boolean modifyMessageBasedOnDetectionStatus) { - try { - return ExecutableManager.getInstance().getExecutable(executableType).thenApply(it -> { - if (it instanceof ExecutableInstance.ExecutableWithPath) { - if (!(it instanceof ExecutableInstance.Executable)) { - return ((ExecutableInstance.ExecutableWithPath) it).getExecutablePath().toString(); - } else { - final String path = ((ExecutableInstance.Executable) it).getExecutablePath().toString(); - final boolean autoResolved = ((ExecutableInstance.Executable) it).getAutoResolved(); - if (autoResolved && modifyMessageBasedOnDetectionStatus) { - return message("aws.settings.auto_detect", path); - } else if (autoResolved) { - // If it is auto detected, we do not want to return text as the - // box will be filled by empty text with the auto-resolve message - return null; - } else { - return path; - } - } - } - return null; - }).toCompletableFuture().join(); - } catch (CompletionException ignored) { - return null; - } - } - - private void validateAndSaveCliSettings( - JBTextField textField, - String executableName, - ExecutableType executableType, - String saved, - String currentInput - ) throws ConfigurationException { - // If input is null, wipe out input and try to autodiscover - if (currentInput == null) { - ExecutableManager.getInstance().removeExecutable(executableType); - ExecutableManager.getInstance() - .getExecutable(executableType) - .thenRun(() -> { - String autoDetectPath = getSavedExecutablePath(executableType, true); - runInEdt(ModalityState.any(), () -> { - if (autoDetectPath != null) { - textField.getEmptyText().setText(autoDetectPath); - } else { - textField.getEmptyText().setText(""); - } - return null; - }); - }); - return; - } - - if (currentInput.equals(saved)) { - return; - } - - final Path path; - try { - path = Paths.get(currentInput); - if (!Files.isExecutable(path) || !path.toFile().exists() || !path.toFile().isFile()) { - throw new IllegalArgumentException("Set file is not an executable"); - } - } catch (Exception e) { - throw new ConfigurationException(message("aws.settings.executables.executable_invalid", executableName, currentInput)); - } - - ExecutableInstance instance = ExecutableManager.getInstance().validateExecutablePath(executableType, path); - - if (instance instanceof ExecutableInstance.BadExecutable) { - throw new ConfigurationException(((ExecutableInstance.BadExecutable) instance).getValidationError()); - } - - // We have validated so now we can set - ExecutableManager.getInstance().setExecutablePath(executableType, path); - } - - private void saveAwsSettings() { - AwsSettings awsSettings = AwsSettings.getInstance(); - awsSettings.setTelemetryEnabled(enableTelemetry.isSelected()); - awsSettings.setUseDefaultCredentialRegion((UseAwsCredentialRegion) Objects.requireNonNull(defaultRegionHandling.getSelectedItem())); - } - - private void saveLambdaSettings() { - LambdaSettings lambdaSettings = LambdaSettings.getInstance(project); - lambdaSettings.setShowAllHandlerGutterIcons(showAllHandlerGutterIcons.isSelected()); - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.kt new file mode 100644 index 0000000000..f34c21fa8d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/AwsSettingsConfigurable.kt @@ -0,0 +1,213 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.options.ConfigurationException +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.util.text.StringUtil +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance +import software.aws.toolkits.jetbrains.core.executables.ExecutableInstance.ExecutableWithPath +import software.aws.toolkits.jetbrains.core.executables.ExecutableManager +import software.aws.toolkits.jetbrains.core.executables.ExecutableType +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable +import software.aws.toolkits.resources.message +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.CompletionException +import javax.swing.JComponent + +class AwsSettingsConfigurable : SearchableConfigurable { + private val samExecutableInstance: SamExecutable + get() = ExecutableType.getExecutable(SamExecutable::class.java) + val samExecutablePath: TextFieldWithBrowseButton = createCliConfigurationElement(samExecutableInstance, SAM) + + private val cfnLintExecutableInstance: CfnLintExecutable + get() = ExecutableType.getExecutable(CfnLintExecutable::class.java) + val cfnLintExecutablePath: TextFieldWithBrowseButton = createCliConfigurationElement(cfnLintExecutableInstance, CFN_LINT) + + private val defaultRegionHandling: ComboBox = ComboBox(UseAwsCredentialRegion.values()) + private val profilesNotification: ComboBox = ComboBox(ProfilesNotification.values()) + + val enableTelemetry: JBCheckBox = JBCheckBox() + override fun createComponent(): JComponent = panel { + group(message("aws.settings.serverless_label")) { + row { + label(message("aws.settings.sam.location")) + // samExecutablePath = createCliConfigurationElement(samExecutableInstance, SAM) + cell(samExecutablePath).align(AlignX.FILL).resizableColumn() + browserLink(message("aws.settings.learn_more"), HelpIds.SAM_CLI_INSTALL.url) + } + } + + group(message("aws.settings.cfnlint")) { + row { + label(message("aws.settings.cfnlint.location")) + // cfnLintExecutablePath = createCliConfigurationElement(cfnLintExecutableInstance, CFN_LINT) + cell(cfnLintExecutablePath).align(AlignX.FILL).resizableColumn() + browserLink(message("aws.settings.learn_more"), HelpIds.SAM_CLI_INSTALL.url) + } + } + group(message("aws.settings.global_label")) { + row { + label(message("settings.credentials.prompt_for_default_region_switch.setting_label")) + cell(defaultRegionHandling).resizableColumn().align(AlignX.FILL).applyToComponent { + this.selectedItem = AwsSettings.getInstance().useDefaultCredentialRegion ?: UseAwsCredentialRegion.Never + } + } + row { + label(message("settings.profiles.label")) + cell(profilesNotification).resizableColumn().align(AlignX.FILL).applyToComponent { + this.selectedItem = AwsSettings.getInstance().profilesNotification ?: ProfilesNotification.Always + } + } + + row { + cell(enableTelemetry).applyToComponent { this.isSelected = AwsSettings.getInstance().isTelemetryEnabled } + text(message("aws.settings.telemetry.option") + " ${message("general.details")}") { + BrowserUtil.open("https://docs.aws.amazon.com/sdkref/latest/guide/support-maint-idetoolkits.html") + } + } + } + } + + override fun isModified(): Boolean = getSamPathWithoutSpaces() != getSavedExecutablePath(samExecutableInstance, false) || + defaultRegionHandling.selectedItem != AwsSettings.getInstance().useDefaultCredentialRegion || + profilesNotification.selectedItem != AwsSettings.getInstance().profilesNotification || + enableTelemetry.isSelected != AwsSettings.getInstance().isTelemetryEnabled + + override fun apply() { + validateAndSaveCliSettings( + samExecutablePath.textField as JBTextField, + "sam", + samExecutableInstance, + getSavedExecutablePath(samExecutableInstance, false), + getSamPathWithoutSpaces() + ) + saveAwsSettings() + } + + override fun reset() { + val awsSettings = AwsSettings.getInstance() + samExecutablePath.setText(getSavedExecutablePath(samExecutableInstance, false)) + enableTelemetry.isSelected = awsSettings.isTelemetryEnabled + defaultRegionHandling.selectedItem = awsSettings.useDefaultCredentialRegion + profilesNotification.selectedItem = awsSettings.profilesNotification + } + + override fun getDisplayName(): String = message("aws.settings.title") + + override fun getId(): String = "aws" + + private fun createCliConfigurationElement(executableType: ExecutableType<*>, cliName: String): TextFieldWithBrowseButton { + val autoDetectPath = getSavedExecutablePath(executableType, true) + val executablePathField = JBTextField() + val field = TextFieldWithBrowseButton(executablePathField) + if (autoDetectPath != null) { + executablePathField.emptyText.setText(autoDetectPath) + } + field.addBrowseFolderListener( + message("aws.settings.find.title", cliName), + message("aws.settings.executables.find.description", cliName), + null, + FileChooserDescriptorFactory.createSingleLocalFileDescriptor() + ) + return field + } + + // modifyMessageBasedOnDetectionStatus will append "Auto-detected: ...." to the + // message if the executable is found this is used for setting the empty box text + private fun getSavedExecutablePath(executableType: ExecutableType<*>, modifyMessageBasedOnDetectionStatus: Boolean): String? = try { + ExecutableManager.getInstance().getExecutable(executableType).thenApply { + if (it is ExecutableWithPath) { + if (it !is ExecutableInstance.Executable) { + return@thenApply (it as ExecutableWithPath).executablePath.toString() + } else { + val path = it.executablePath.toString() + val autoResolved = it.autoResolved + if (autoResolved && modifyMessageBasedOnDetectionStatus) { + return@thenApply message("aws.settings.auto_detect", path) + } else if (autoResolved) { + // If it is auto detected, we do not want to return text as the + // box will be filled by empty text with the auto-resolve message + return@thenApply null + } else { + return@thenApply path + } + } + } + null + }.toCompletableFuture().join() + } catch (ignored: CompletionException) { + null + } + + private fun validateAndSaveCliSettings( + textField: JBTextField, + executableName: String, + executableType: ExecutableType<*>, + saved: String?, + currentInput: String? + ) { + // If input is null, wipe out input and try to autodiscover + if (currentInput == null) { + ExecutableManager.getInstance().removeExecutable(executableType) + ExecutableManager.getInstance() + .getExecutable(executableType) + .thenRun { + val autoDetectPath = getSavedExecutablePath(executableType, true) + runInEdt(ModalityState.any()) { + if (autoDetectPath != null) { + textField.emptyText.setText(autoDetectPath) + } else { + textField.emptyText.setText("") + } + } + } + return + } + if (currentInput == saved) { + return + } + val path: Path + try { + path = Paths.get(currentInput) + if (!Files.isExecutable(path) || !path.toFile().exists() || !path.toFile().isFile) { + throw IllegalArgumentException("Set file is not an executable") + } + } catch (e: Exception) { + throw ConfigurationException(message("aws.settings.executables.executable_invalid", executableName, currentInput)) + } + val instance = ExecutableManager.getInstance().validateExecutablePath(executableType, path) + if (instance is ExecutableInstance.BadExecutable) { + throw ConfigurationException(instance.validationError) + } + + // We have validated so now we can set + ExecutableManager.getInstance().setExecutablePath(executableType, path) + } + private fun saveAwsSettings() { + val awsSettings = AwsSettings.getInstance() + awsSettings.isTelemetryEnabled = enableTelemetry.isSelected + awsSettings.useDefaultCredentialRegion = defaultRegionHandling.selectedItem as? UseAwsCredentialRegion ?: UseAwsCredentialRegion.Never + awsSettings.profilesNotification = profilesNotification.selectedItem as? ProfilesNotification ?: ProfilesNotification.Always + } + + private fun getSamPathWithoutSpaces() = StringUtil.nullize(samExecutablePath.text.trim { it <= ' ' }) + + companion object { + private const val SAM = "sam" + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CawsSpaceTracker.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CawsSpaceTracker.kt new file mode 100644 index 0000000000..e8c8aab972 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CawsSpaceTracker.kt @@ -0,0 +1,38 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@State(name = "cawsSpace", storages = [Storage("aws.xml")]) +class CawsSpaceTracker : PersistentStateComponent { + private val state = CawsSpaceState() + + override fun getState() = state + + override fun loadState(state: CawsSpaceState) { + this.state.lastSpaceName = state.lastSpaceName + } + + fun lastSpaceName() = state.lastSpaceName + + fun changeSpaceName(newName: String?) { + state.lastSpaceName = newName + } + + companion object { + fun getInstance() = service() + } +} + +data class CawsSpaceState( + var lastSpaceName: String? = null +) + +interface CawsSpaceSelectionChange { + fun newSpace(spaceName: String) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CloudDebugSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CloudDebugSettings.kt deleted file mode 100644 index 022a6ab24e..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CloudDebugSettings.kt +++ /dev/null @@ -1,35 +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.openapi.components.PersistentStateComponent -import com.intellij.openapi.components.ServiceManager -import com.intellij.openapi.components.State -import com.intellij.openapi.components.Storage - -@State(name = "cloud_debug", storages = [Storage("aws.xml")]) -class CloudDebugSettings : PersistentStateComponent { - private var state = AwsCloudDebugConfiguration() - - override fun getState(): AwsCloudDebugConfiguration? = state - - override fun loadState(state: AwsCloudDebugConfiguration) { - this.state = state - } - - var showEnableDebugWarning: Boolean - get() = state.showEnableDebugWarning - set(value) { - state.showEnableDebugWarning = value - } - - companion object { - @JvmStatic - fun getInstance(): CloudDebugSettings = ServiceManager.getService(CloudDebugSettings::class.java) - } -} - -data class AwsCloudDebugConfiguration( - var showEnableDebugWarning: Boolean = true -) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DeploySettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DeploySettings.kt index c0530c2a68..11361e62ab 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DeploySettings.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DeploySettings.kt @@ -32,6 +32,11 @@ class DeploySettings : PersistentStateComponent { state.samConfigs.computeIfAbsent(samPath) { DeploySamConfig() }.bucketName = value } + fun samEcrRepoUri(samPath: String): String? = state.samConfigs[samPath]?.repoUri + fun setSamEcrRepoUri(samPath: String, value: String?) { + state.samConfigs.computeIfAbsent(samPath) { DeploySamConfig() }.repoUri = value + } + fun samAutoExecute(samPath: String): Boolean? = state.samConfigs[samPath]?.autoExecute fun setSamAutoExecute(samPath: String, value: Boolean) { state.samConfigs.computeIfAbsent(samPath) { DeploySamConfig() }.autoExecute = value @@ -60,6 +65,7 @@ data class DeployConfigs( data class DeploySamConfig( var stackName: String? = null, var bucketName: String? = null, + var repoUri: String? = null, var autoExecute: Boolean = false, var useContainer: Boolean = false, var enabledCapabilities: List? = null @@ -70,6 +76,6 @@ data class DeploySamConfig( * @see DeployConfigs.samConfigs */ fun relativeSamPath(module: Module, templateFile: VirtualFile): String? = module.rootManager.contentRoots - .find { Paths.get(templateFile.path).startsWith(it.path) } - ?.let { Paths.get(it.path).relativize(Paths.get(templateFile.path)) } - ?.toString() + .find { Paths.get(templateFile.path).startsWith(it.path) } + ?.let { Paths.get(it.path).relativize(Paths.get(templateFile.path)) } + ?.toString() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DynamicResourcesConfigurable.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DynamicResourcesConfigurable.kt new file mode 100644 index 0000000000..ec05619b00 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DynamicResourcesConfigurable.kt @@ -0,0 +1,168 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.ui.InputValidator +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.messages.MessagesService +import com.intellij.ui.CheckBoxList +import com.intellij.ui.FilterComponent +import com.intellij.ui.ListSpeedSearch +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.gridLayout.VerticalAlign +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment +import software.aws.toolkits.core.utils.replace +import software.aws.toolkits.jetbrains.core.coroutines.applicationCoroutineScope +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.explorer.ExplorerToolWindow +import software.aws.toolkits.jetbrains.services.dynamic.DynamicResourceSupportedTypes +import software.aws.toolkits.jetbrains.services.dynamic.explorer.OtherResourcesNode +import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService +import software.aws.toolkits.jetbrains.ui.feedback.FEEDBACK_SOURCE +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.FeedbackTelemetry +import javax.swing.ListSelectionModel + +class DynamicResourcesConfigurable : BoundConfigurable(message("aws.settings.dynamic_resources_configurable.title")) { + + private val coroutineScope = applicationCoroutineScope() + private val checklist = CheckBoxList() + private val allResources = mutableSetOf() + private val selected = mutableSetOf() + private val filter = object : FilterComponent("filter", 5) { + override fun filter() { + updateCheckboxList() + } + } + + init { + checklist.selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION + checklist.setCheckBoxListListener(::checkboxStateHandler) + + ListSpeedSearch(checklist) { + it.text.substringAfter("::") + } + } + + override fun getPreferredFocusedComponent() = checklist + + override fun createPanel() = panel { + selected.replace(DynamicResourcesSettings.getInstance().selected) + coroutineScope.launch(getCoroutineBgContext()) { + allResources.addAll(DynamicResourceSupportedTypes.getInstance().getSupportedTypes()) + withContext(getCoroutineUiContext()) { + updateCheckboxList() + } + } + row { + cell(filter).resizableColumn().align(Align.FILL) + link(message("aws.settings.dynamic_resources_configurable.suggest_types.prompt")) { + showTypeSuggestionBox()?.let { suggestion -> + submitSuggestion(suggestion) + } + } + } + row { + scrollCell(checklist) + .onIsModified { selected != DynamicResourcesSettings.getInstance().selected } + .onApply { + DynamicResourcesSettings.getInstance().selected = selected + refreshAwsExplorer() + } + .onReset { + selected.replace(DynamicResourcesSettings.getInstance().selected) + updateCheckboxList() + }.resizableColumn().align(Align.FILL) + + panel { + val sizeGroup = "buttons" + row { + button(message("aws.settings.dynamic_resources_configurable.select_all")) { + checklist.toggleAll(true) + }.widthGroup(sizeGroup) + } + row { + button(message("aws.settings.dynamic_resources_configurable.clear_all")) { + checklist.toggleAll(false) + }.widthGroup(sizeGroup) + } + }.verticalAlign(VerticalAlign.TOP) + }.resizableRow() + } + + private fun submitSuggestion(suggestion: String) { + coroutineScope.launch(getCoroutineBgContext()) { + try { + TelemetryService.getInstance().sendFeedback(Sentiment.NEGATIVE, suggestion, mapOf(FEEDBACK_SOURCE to "Resource Type Suggestions")).also { + FeedbackTelemetry.result(project = null, success = true) + } + } catch (e: Exception) { + e.notifyError(message("feedback.submit_failed", e)) + FeedbackTelemetry.result(project = null, success = false) + } + } + } + + private fun CheckBoxList<*>.toggleAll(state: Boolean) { + (0 until model.size).forEach { idx -> + checkboxStateHandler(idx, state) + } + updateCheckboxList() + } + + private fun updateCheckboxList() { + checklist.clear() + allResources.filter { it.contains(filter.filter, ignoreCase = true) }.sorted().forEach { checklist.addItem(it, it, it in selected) } + } + + private fun checkboxStateHandler(idx: Int, state: Boolean) { + checklist.getItemAt(idx)?.let { value -> + if (state) { + selected.add(value) + } else { + selected.remove(value) + } + } + } + + private fun refreshAwsExplorer() { + ProjectManager.getInstance().openProjects.forEach { project -> + if (!project.isDisposed) { + val toolWindow = ExplorerToolWindow.getInstance(project) + toolWindow.findNode(OtherResourcesNode::class).then { node -> + node.let { + toolWindow.invalidateTree(it) + } + } + } + } + } + + companion object { + private const val INITIAL_INPUT = "AWS::" + private const val MAX_LENGTH = 2000 + + private fun showTypeSuggestionBox(): String? = MessagesService.getInstance().showMultilineInputDialog( + project = null, + message = message("aws.settings.dynamic_resources_configurable.suggest_types.dialog.message"), + title = message("aws.settings.dynamic_resources_configurable.suggest_types.dialog.title"), + initialValue = INITIAL_INPUT, + icon = Messages.getQuestionIcon(), + object : InputValidator { + override fun checkInput(inputString: String?) = validateSuggestion(inputString) + override fun canClose(inputString: String?) = validateSuggestion(inputString) + } + )?.takeIf { it.isNotBlank() } + + private fun validateSuggestion(inputString: String?) = + inputString != null && inputString.isNotBlank() && inputString != INITIAL_INPUT && inputString.length <= MAX_LENGTH + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DynamicResourcesSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DynamicResourcesSettings.kt new file mode 100644 index 0000000000..fa203b5a9c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/DynamicResourcesSettings.kt @@ -0,0 +1,37 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.SimplePersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.util.xmlb.annotations.Property +import software.aws.toolkits.core.utils.replace + +interface DynamicResourcesSettings { + var selected: Set + + companion object { + fun getInstance(): DynamicResourcesSettings = service() + } +} + +@State(name = "resources", storages = [Storage("aws.xml")]) +internal class DefaultDynamicResourcesSettings : + DynamicResourcesSettings, + SimplePersistentStateComponent(DynamicResourcesConfiguration()) { + override var selected: Set + get() = state.selected.toSet() + set(value) { + state.selected.replace(value) + } +} + +internal class DynamicResourcesConfiguration : BaseState() { + // using a list because `stringSet` doesn't automatically increment the modification counter and ends up not getting persisted + @get:Property + val selected by list() +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/EcsExecCommandSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/EcsExecCommandSettings.kt new file mode 100644 index 0000000000..3dd8885734 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/EcsExecCommandSettings.kt @@ -0,0 +1,34 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage + +@State(name = "ecsExec", storages = [Storage("aws.xml")]) +class EcsExecCommandSettings : PersistentStateComponent { + private var state = AwsEcsExecConfiguration() + + override fun getState(): AwsEcsExecConfiguration? = state + + override fun loadState(state: AwsEcsExecConfiguration) { + this.state = state + } + + var showExecuteCommandWarning: Boolean + get() = state.showExecuteCommandWarning + set(value) { + state.showExecuteCommandWarning = value + } + + companion object { + fun getInstance(): EcsExecCommandSettings = ApplicationManager.getApplication().getService(EcsExecCommandSettings::class.java) + } +} + +data class AwsEcsExecConfiguration( + var showExecuteCommandWarning: Boolean = true +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/GettingStartedSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/GettingStartedSettings.kt new file mode 100644 index 0000000000..3f63f903a6 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/GettingStartedSettings.kt @@ -0,0 +1,32 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@State(name = "gettingStarted", storages = [Storage("aws.xml")]) +class GettingStartedSettings : PersistentStateComponent { + private var state = GettingStartedSettingsConfiguration() + override fun getState(): GettingStartedSettingsConfiguration? = state + + override fun loadState(state: GettingStartedSettingsConfiguration) { + this.state = state + } + + var shouldDisplayPage: Boolean + get() = state.shouldDisplayPage + set(value) { + state.shouldDisplayPage = value + } + + companion object { + fun getInstance(): GettingStartedSettings = service() + } +} +data class GettingStartedSettingsConfiguration( + var shouldDisplayPage: Boolean = true +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/LambdaSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/LambdaSettings.kt index b1cfa7f0cb..7c93a32408 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/LambdaSettings.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/LambdaSettings.kt @@ -4,9 +4,9 @@ package software.aws.toolkits.jetbrains.settings 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.components.service import com.intellij.openapi.project.Project @State(name = "lambda", storages = [Storage("aws.xml")]) @@ -28,7 +28,7 @@ class LambdaSettings(private val project: Project) : PersistentStateComponent { + private var state = MeetQSettingsConfiguration() + override fun getState(): MeetQSettingsConfiguration? = state + + override fun loadState(state: MeetQSettingsConfiguration) { + this.state = state + } + + var shouldDisplayPage: Boolean + get() = state.shouldDisplayPage + set(value) { + state.shouldDisplayPage = value + } + + companion object { + fun getInstance(): MeetQSettings = service() + } +} +data class MeetQSettingsConfiguration( + var shouldDisplayPage: Boolean = true +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/SamDisplayDevModeWarningSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/SamDisplayDevModeWarningSettings.kt new file mode 100644 index 0000000000..878f7002db --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/SamDisplayDevModeWarningSettings.kt @@ -0,0 +1,34 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@State(name = "samAccDevMode", storages = [Storage("aws.xml")]) +class SamDisplayDevModeWarningSettings : PersistentStateComponent { + private var state = SamDevModeWarningConfiguration() + + override fun getState(): SamDevModeWarningConfiguration? = state + + override fun loadState(state: SamDevModeWarningConfiguration) { + this.state = state + } + + var showDevModeWarning: Boolean + get() = state.showDevModeWarning + set(value) { + state.showDevModeWarning = value + } + + companion object { + fun getInstance(): SamDisplayDevModeWarningSettings = service() + } +} + +data class SamDevModeWarningConfiguration( + var showDevModeWarning: Boolean = true +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/SamSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/SamSettings.kt deleted file mode 100644 index 60f6e18722..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/SamSettings.kt +++ /dev/null @@ -1,31 +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.openapi.components.PersistentStateComponent -import com.intellij.openapi.components.ServiceManager -import com.intellij.openapi.components.State -import com.intellij.openapi.components.Storage -import org.jetbrains.annotations.TestOnly - -@State(name = "sam", storages = [Storage("aws.xml")]) -class SamSettings : PersistentStateComponent { - private var state = SamConfiguration() - - override fun getState(): SamConfiguration = state - - override fun loadState(state: SamConfiguration) { - this.state = state - } - - companion object { - @JvmStatic - @TestOnly - fun getInstance(): SamSettings = ServiceManager.getService(SamSettings::class.java) - } -} - -data class SamConfiguration( - var savedExecutablePath: String? = null -) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/SyncSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/SyncSettings.kt new file mode 100644 index 0000000000..30928e218c --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/SyncSettings.kt @@ -0,0 +1,74 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleServiceManager +import software.aws.toolkits.jetbrains.services.lambda.deploy.CreateCapabilities + +@State(name = "syncSettings") +class SyncSettings : PersistentStateComponent { + private var state = SyncConfigs() + + override fun getState(): SyncConfigs = state + + override fun loadState(state: SyncConfigs) { + this.state = state + } + + fun samStackName(samPath: String): String? = state.samConfigs[samPath]?.stackName + fun setSamStackName(samPath: String, value: String) { + state.samConfigs.computeIfAbsent(samPath) { SyncSamConfig() }.stackName = value + } + + fun samBucketName(samPath: String): String? = state.samConfigs[samPath]?.bucketName + fun setSamBucketName(samPath: String, value: String) { + state.samConfigs.computeIfAbsent(samPath) { SyncSamConfig() }.bucketName = value + } + + fun samEcrRepoUri(samPath: String): String? = state.samConfigs[samPath]?.repoUri + fun setSamEcrRepoUri(samPath: String, value: String?) { + state.samConfigs.computeIfAbsent(samPath) { SyncSamConfig() }.repoUri = value + } + + fun samUseContainer(samPath: String): Boolean? = state.samConfigs[samPath]?.useContainer + fun setSamUseContainer(samPath: String, value: Boolean) { + state.samConfigs.computeIfAbsent(samPath) { SyncSamConfig() }.useContainer = value + } + + fun enabledCapabilities(samPath: String): List? = state.samConfigs[samPath]?.enabledCapabilities + fun setEnabledCapabilities(samPath: String, value: List) { + state.samConfigs.computeIfAbsent(samPath) { SyncSamConfig() }.enabledCapabilities = value + } + + fun samTags(samPath: String): Map? = state.samConfigs[samPath]?.tags + fun setSamTags(samPath: String, value: Map) { + state.samConfigs.computeIfAbsent(samPath) { SyncSamConfig() }.tags = value + } + + fun samTempParameterOverrides(samPath: String): Map? = state.samConfigs[samPath]?.tempParameterOverrides + fun setSamTempParameterOverrides(samPath: String, value: Map) { + state.samConfigs.computeIfAbsent(samPath) { SyncSamConfig() }.tempParameterOverrides = value + } + + companion object { + fun getInstance(module: Module): SyncSettings? = ModuleServiceManager.getService(module, SyncSettings::class.java) + } +} + +data class SyncConfigs( + var samConfigs: MutableMap = mutableMapOf() +) + +data class SyncSamConfig( + var stackName: String? = null, + var bucketName: String? = null, + var repoUri: String? = null, + var useContainer: Boolean = false, + var enabledCapabilities: List? = null, + var tags: Map? = null, + var tempParameterOverrides: Map? = null +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/UpdateLambdaSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/UpdateLambdaSettings.kt new file mode 100644 index 0000000000..9bf51fe3da --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/UpdateLambdaSettings.kt @@ -0,0 +1,67 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@State(name = "updateLambdaState", storages = [Storage("aws.xml")]) +private class UpdateLambdaState : PersistentStateComponent { + private var settings = UpdateLambda() + + override fun getState(): UpdateLambda = settings + override fun loadState(state: UpdateLambda) { + this.settings = state + } + + companion object { + @JvmStatic + internal fun getInstance(): UpdateLambdaState = service() + } +} + +class UpdateLambdaSettings private constructor(private val arn: String) { + private val stateService = UpdateLambdaState.getInstance() + + var useContainer: Boolean? + get() = stateService.state.configs[arn]?.useContainer + set(value) { + stateService.state.configs.computeIfAbsent(arn) { UpdateConfig() }.useContainer = value ?: false + } + + var bucketName: String? + get() = stateService.state.configs[arn]?.bucketName + set(value) { + stateService.state.configs.computeIfAbsent(arn) { UpdateConfig() }.bucketName = value + } + + var ecrRepo: String? + get() = stateService.state.configs[arn]?.ecrRepo + set(value) { + stateService.state.configs.computeIfAbsent(arn) { UpdateConfig() }.ecrRepo = value + } + + var dockerfile: String? + get() = stateService.state.configs[arn]?.dockerfile + set(value) { + stateService.state.configs.computeIfAbsent(arn) { UpdateConfig() }.dockerfile = value + } + + companion object { + fun getInstance(arn: String) = UpdateLambdaSettings(arn) + } +} + +data class UpdateLambda( + var configs: MutableMap = mutableMapOf() +) + +data class UpdateConfig( + var bucketName: String? = null, + var ecrRepo: String? = null, + var dockerfile: String? = null, + var useContainer: Boolean = false +) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ActionPopupComboLabel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ActionPopupComboLabel.kt new file mode 100644 index 0000000000..4e7fc1c646 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ActionPopupComboLabel.kt @@ -0,0 +1,49 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui + +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.event.ChangeListener + +open class ActionPopupComboLabel(private val logic: ActionPopupComboLogic) : BorderLayoutPanel() { + private val label = JBLabel() + + init { + isOpaque = false + + val arrowLabel = JBLabel(AllIcons.General.ArrowDown) + addToCenter(label) + addToRight(arrowLabel) + + val clickAdapter = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + logic.showPopup(this@ActionPopupComboLabel) + } + } + label.addMouseListener(clickAdapter) + arrowLabel.addMouseListener(clickAdapter) + logic.addChangeListener { + updateText() + } + + updateText() + } + + fun updateText() { + label.text = logic.displayValue() + label.toolTipText = logic.tooltip() + } +} + +interface ActionPopupComboLogic { + fun showPopup(sourceComponent: JComponent) + fun displayValue(): String + fun addChangeListener(changeListener: ChangeListener) + fun tooltip(): String? = null +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/AsyncComboBox.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/AsyncComboBox.kt new file mode 100644 index 0000000000..c900bea09d --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/AsyncComboBox.kt @@ -0,0 +1,187 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBLabel +import com.intellij.util.Alarm +import com.intellij.util.AlarmFactory +import kotlinx.coroutines.launch +import org.jetbrains.annotations.TestOnly +import org.jetbrains.concurrency.AsyncPromise +import software.aws.toolkits.jetbrains.core.coroutines.disposableCoroutineScope +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message +import java.awt.Component +import java.util.concurrent.Future +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.DefaultComboBoxModel +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.MutableComboBoxModel +import javax.swing.event.ListDataListener + +class AsyncComboBox private constructor( + private val comboBoxModel: MutableComboBoxModel +) : ComboBox(comboBoxModel), Disposable { + private val loading = AtomicBoolean(false) + private val scope = disposableCoroutineScope(this) + + constructor( + comboBoxModel: MutableComboBoxModel = DefaultComboBoxModel(), + customizer: SimpleListCellRenderer.Customizer? = null + ) : this(comboBoxModel) { + renderer = object : SimpleListCellRenderer() { + override fun getListCellRendererComponent( + list: JList?, + value: T?, + index: Int, + selected: Boolean, + hasFocus: Boolean + ): Component { + val component = super.getListCellRendererComponent(list, value, index, selected, hasFocus) as SimpleListCellRenderer<*> + + if (loading.get() && index == -1) { + component.icon = AnimatedIcon.Default.INSTANCE + component.text = message("loading_resource.loading") + } + + return component + } + + override fun customize(list: JList, value: T, index: Int, selected: Boolean, hasFocus: Boolean) { + customizer?.customize(this, value, index) + } + } + } + + constructor( + comboBoxModel: MutableComboBoxModel = DefaultComboBoxModel(), + customRenderer: ListCellRenderer + ) : this(comboBoxModel) { + renderer = ListCellRenderer { list, value, index, selected, hasFocus -> + if (loading.get() && index == -1) { + val component = JBLabel(AnimatedIcon.Default.INSTANCE) + component.text = message("loading_resource.loading") + + return@ListCellRenderer component + } + + customRenderer.getListCellRendererComponent(list, value, index, selected, hasFocus) + } + } + + init { + putClientProperty(AnimatedIcon.ANIMATION_IN_RENDERER_ALLOWED, true) + } + + private val reloadAlarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.SWING_THREAD, this) + private var currentIndicator: ProgressIndicator? = null + + @Synchronized + fun proposeModelUpdate(newModel: suspend (MutableComboBoxModel) -> Unit) { + reloadAlarm.cancelAllRequests() + currentIndicator?.cancel() + loading.set(true) + removeAllItems() + repaint() + val indicator = EmptyProgressIndicator(ModalityState.any()).also { + currentIndicator = it + } + // delay with magic number to debounce + reloadAlarm.addRequest( + { + ProgressManager.getInstance().runProcess( + { + scope.launch { + newModel.invoke(delegatedComboBoxModel(indicator)) + }.invokeOnCompletion { + loading.set(false) + repaint() + } + }, + indicator + ) + }, + 350, + ModalityState.any() + ) + } + + override fun dispose() { + } + + override fun getSelectedItem(): Any? { + if (loading.get()) { + return null + } + return super.getSelectedItem() + } + + @TestOnly + @Synchronized + internal fun waitForSelection(): Future { + val future = AsyncPromise() + while (loading.get()) { + Thread.onSpinWait() + } + future.setResult(selected()) + + return future + } + + override fun setSelectedItem(anObject: Any?) { + if (loading.get()) { + return + } + super.setSelectedItem(anObject) + } + + private fun delegatedComboBoxModel(indicator: ProgressIndicator) = + object : MutableComboBoxModel { + override fun getSize() = comboBoxModel.size + override fun getElementAt(index: Int): T = comboBoxModel.getElementAt(index) + + override fun addListDataListener(l: ListDataListener?) { + throw NotImplementedError() + } + + override fun removeListDataListener(l: ListDataListener?) { + throw NotImplementedError() + } + + override fun setSelectedItem(anItem: Any?) { + comboBoxModel.selectedItem = anItem + } + + override fun getSelectedItem(): Any = comboBoxModel.selectedItem + + override fun addElement(item: T?) { + indicator.checkCanceled() + comboBoxModel.addElement(item) + } + + override fun removeElement(obj: Any?) { + indicator.checkCanceled() + comboBoxModel.removeElement(item) + } + + override fun insertElementAt(item: T?, index: Int) { + indicator.checkCanceled() + comboBoxModel.insertElementAt(item, index) + } + + override fun removeElementAt(index: Int) { + indicator.checkCanceled() + comboBoxModel.removeElementAt(index) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CenteredInfoPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CenteredInfoPanel.kt new file mode 100644 index 0000000000..4503927a63 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CenteredInfoPanel.kt @@ -0,0 +1,51 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui + +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.ui.VerticalFlowLayout +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.panels.NonOpaquePanel +import com.intellij.util.ui.UIUtil +import java.awt.FlowLayout +import java.awt.event.ActionEvent +import javax.swing.JButton +import javax.swing.SwingConstants + +class CenteredInfoPanel : NonOpaquePanel(VerticalFlowLayout(VerticalFlowLayout.MIDDLE)) { + fun addLine(message: String, isError: Boolean = false) = apply { + val line = JBLabel() + line.isOpaque = false + line.text = "
$message
" + line.horizontalAlignment = SwingConstants.CENTER + + if (isError) { + line.foreground = UIUtil.getErrorForeground() + } + + add(line) + } + + fun addAction(message: String, handler: (ActionEvent) -> Unit) = apply { + val action = ActionLink(message, handler) + action.horizontalAlignment = SwingConstants.CENTER + + add(action) + } + + fun addDefaultActionButton(message: String, handler: (ActionEvent) -> Unit) = apply { + add( + NonOpaquePanel(FlowLayout()).apply { + add( + JButton(message).apply { + isOpaque = false + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + addActionListener(handler) + } + ) + } + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ConfirmPolicyPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ConfirmPolicyPanel.form new file mode 100644 index 0000000000..fd96cdb3ba --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ConfirmPolicyPanel.form @@ -0,0 +1,27 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ConfirmPolicyPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ConfirmPolicyPanel.kt new file mode 100644 index 0000000000..ad0033f882 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ConfirmPolicyPanel.kt @@ -0,0 +1,31 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui + +import com.intellij.json.JsonLanguage +import com.intellij.openapi.project.Project +import com.intellij.ui.EditorTextField +import com.intellij.ui.EditorTextFieldProvider +import com.intellij.ui.components.JBLabel +import javax.swing.JPanel + +class ConfirmPolicyPanel( + private val project: Project, + warning: String +) { + lateinit var component: JPanel + private set + lateinit var policyDocument: EditorTextField + private set + lateinit var warningText: JBLabel + private set + + init { + warningText.text = warning + } + + private fun createUIComponents() { + policyDocument = EditorTextFieldProvider.getInstance().getEditorField(JsonLanguage.INSTANCE, project, emptyList()) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialChoice.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialChoice.kt new file mode 100644 index 0000000000..6115343d89 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialChoice.kt @@ -0,0 +1,126 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui + +import com.intellij.openapi.observable.properties.GraphProperty +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.ComboboxSpeedSearch +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.layout.CellBuilder +import com.intellij.ui.layout.PropertyBinding +import com.intellij.ui.layout.Row +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import kotlin.reflect.KMutableProperty0 + +sealed class CredentialChoice(val value: T) +private class ValidCredentialIdentifier(value: CredentialIdentifier) : CredentialChoice(value) +private class InvalidCredentialIdentifier(value: String) : CredentialChoice(value) + +/** + * Combo box used to select a credential provider + */ +class CredentialProviderSelector2 : ComboBox>() { + private val comboBoxModel = Model(CredentialManager.getInstance().getCredentialIdentifiers()) + + init { + model = comboBoxModel + setRenderer( + SimpleListCellRenderer.create("") { + when (it) { + is InvalidCredentialIdentifier -> "${it.value} (Not found)" + is ValidCredentialIdentifier -> it.value.displayName + else -> "" + } + } + ) + ComboboxSpeedSearch(this) + } + + fun setCredentialsProviders(providers: List) { + comboBoxModel.setCredentialsProviders(providers) + } + + fun setSelectedCredentialsProvider(providerId: String) { + val item = comboBoxModel.items.filterIsInstance().firstOrNull { it.value.id == providerId } + ?: comboBoxModel.addInvalidItem(providerId) + selectedItem = item + } + + /** + * Returns the ID of the selected provider, even if it was invalid + */ + fun getSelectedCredentialsIdentifier(): String? { + comboBoxModel.selected.let { + return when (it) { + is ValidCredentialIdentifier -> it.value.id + is InvalidCredentialIdentifier -> it.value + else -> null + } + } + } + + fun isSelectionValid(): Boolean = comboBoxModel.selected is ValidCredentialIdentifier + fun getSelectedCredentialIdentifier(): CredentialIdentifier { + if (!isSelectionValid()) { + throw IllegalStateException("Can't get credential identifier when the selection is an invalid one") + } + + return (comboBoxModel.selected as ValidCredentialIdentifier).value + } + + fun setSelectedCredentialsProvider(provider: CredentialIdentifier) { + selectedItem = provider.id + } + + override fun setSelectedItem(anObject: Any?) { + comboBoxModel.selectedItem = anObject + } + + private class Model(credentialIdentifiers: List) : CollectionComboBoxModel>() { + init { + setCredentialsProviders(credentialIdentifiers) + } + + fun setCredentialsProviders(providers: List) { + replaceAll(providers.sortedBy { it.displayName }.map { ValidCredentialIdentifier(it) }) + } + + fun addInvalidItem(id: String): InvalidCredentialIdentifier { + val invalidItem = InvalidCredentialIdentifier(id) + add(0, invalidItem) + return invalidItem + } + } + + companion object { + fun Row.credentialSelector(prop: KMutableProperty0): CellBuilder { + val binding = PropertyBinding( + get = { prop.get() }, + set = { prop.set(it) } + ) + return credentialSelector(binding) + } + + fun Row.credentialSelector(prop: GraphProperty): CellBuilder { + val binding = PropertyBinding( + get = { runCatching { prop.get() }.getOrNull() }, + set = { prop.set(it) } + ) + return credentialSelector(binding) + } + + fun Row.credentialSelector(binding: PropertyBinding): CellBuilder { + val credentialProviderSelector = CredentialProviderSelector2() + return component(credentialProviderSelector) + .withValidationOnApply { if (!credentialProviderSelector.isSelectionValid()) error("FOO") else null } + .withBinding( + { component -> component.getSelectedCredentialIdentifier() }, + { component, value -> component.selectedItem = value }, + binding + ) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialIdentifierSelector.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialIdentifierSelector.kt new file mode 100644 index 0000000000..6b3aa3c3a2 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialIdentifierSelector.kt @@ -0,0 +1,104 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui + +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.ComboboxSpeedSearch +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.layout.ValidationInfoBuilder +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.credentials.CredentialType +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.resources.message +import javax.swing.JList + +/** + * Combo box used to select a credential identifier + */ +class CredentialIdentifierSelector(identifiers: List = CredentialManager.getInstance().getCredentialIdentifiers()) : + ComboBox() { + private val comboBoxModel = CollectionComboBoxModel(identifiers.toMutableList()) + + init { + model = comboBoxModel + setRenderer(object : ColoredListCellRenderer() { + override fun customizeCellRenderer( + list: JList, + value: CredentialIdentifier?, + index: Int, + selected: Boolean, + hasFocus: Boolean + ) { + if (value is InvalidCredentialIdentifier) { + append(value.displayName, SimpleTextAttributes.ERROR_ATTRIBUTES) + } else { + append(value?.displayName ?: "") + } + } + }) + ComboboxSpeedSearch(this) + } + + /** + * Selects the specified credential identifier with the matching ID + * If none is found, a invalid placeholder is created and selected + */ + fun setSelectedCredentialIdentifierId(identifierId: String?) { + val newSelection = identifierId?.let { + comboBoxModel.items.firstOrNull { it.id == identifierId } ?: InvalidCredentialIdentifier(identifierId).also { + comboBoxModel.add(it) + } + } + selectedItem = newSelection + } + + /** + * Selects the specified credential identifier + * If none is found, a invalid placeholder is created and selected + */ + fun setSelectedCredentialIdentifier(identifier: CredentialIdentifier?) { + setSelectedCredentialIdentifierId(identifier?.id) + } + + fun isSelectionValid(): Boolean = comboBoxModel.selected != null && comboBoxModel.selected !is InvalidCredentialIdentifier + + /** + * Returns the ID of the selected provider, even if it was invalid + */ + fun getSelectedCredentialsIdentifierId(): String? = comboBoxModel.selected?.id + + /** + * Returns the selected CredentialIdentifier if and only if it is valid + */ + fun getSelectedValidCredentialIdentifier(): CredentialIdentifier? = comboBoxModel.selected?.takeIf { isSelectionValid() } + + class InvalidCredentialIdentifier(override val id: String) : CredentialIdentifier { + override val displayName: String = message("credentials.invalid.not_found", id) + override val factoryId: String = "InvalidCredentialIdentifier" + override val credentialType: CredentialType? = null + override val defaultRegionId: String? = null + } + + companion object { + + fun ValidationInfoBuilder.validateSelection(selector: CredentialIdentifierSelector): ValidationInfo? = if (!selector.isSelectionValid()) { + error(message("credentials.invalid.invalid_selection")) + } else { + null + } + } +} + +fun tryResolveConnectionSettings(credentialsIdentifier: CredentialIdentifier?, region: AwsRegion?) = + credentialsIdentifier?.let { credId -> + region?.let { region -> + val credentialProvider = CredentialManager.getInstance().getAwsCredentialProvider(credentialsIdentifier, region) + ConnectionSettings(credentialProvider, region) + } + } 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 53b4df8683..8343aba629 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialProviderSelector.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/CredentialProviderSelector.kt @@ -5,6 +5,7 @@ package software.aws.toolkits.jetbrains.ui import com.intellij.openapi.ui.ComboBox import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.ComboboxSpeedSearch import com.intellij.ui.SimpleListCellRenderer import com.intellij.util.containers.OrderedSet import software.aws.toolkits.core.credentials.CredentialIdentifier @@ -21,6 +22,7 @@ class CredentialProviderSelector : ComboBox() { init { model = comboBoxModel setRenderer(RENDERER) + ComboboxSpeedSearch(this) } fun setCredentialsProviders(providers: List) { diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/EnvironmentVariablesTextField.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/EnvironmentVariablesTextField.kt deleted file mode 100644 index 097be87d7c..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/EnvironmentVariablesTextField.kt +++ /dev/null @@ -1,88 +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 - -import com.intellij.execution.configuration.EnvironmentVariablesData -import com.intellij.execution.util.EnvVariablesTable -import com.intellij.execution.util.EnvironmentVariable -import com.intellij.icons.AllIcons -import com.intellij.openapi.ui.DialogWrapper -import com.intellij.openapi.ui.TextFieldWithBrowseButton -import software.aws.toolkits.resources.message -import java.awt.Component -import java.util.LinkedHashMap -import javax.swing.Icon -import javax.swing.JComponent - -/** - * Our version of [com.intellij.execution.configuration.EnvironmentVariablesTextFieldWithBrowseButton] to fit our - * needs but with same UX so users are used to it. Namely we do not support inheriting system env vars, but rest - * of UX is the same - */ -class EnvironmentVariablesTextField : TextFieldWithBrowseButton() { - private var data = EnvironmentVariablesData.create(emptyMap(), false) - var envVars: Map - get() = data.envs - set(value) { - data = EnvironmentVariablesData.create(value, false) - text = stringify(data.envs) - } - - init { - isEditable = false - addActionListener { - EnvironmentVariablesDialog(this).show() - } - } - - private fun convertToVariables(envVars: Map, readOnly: Boolean): List = envVars.map { (key, value) -> - object : EnvironmentVariable(key, value, readOnly) { - override fun getNameIsWriteable(): Boolean = !readOnly - } - } - - override fun getDefaultIcon(): Icon = AllIcons.General.InlineVariables - - override fun getHoveredIcon(): Icon = AllIcons.General.InlineVariablesHover - - private fun stringify(envVars: Map): String { - if (envVars.isEmpty()) { - return "" - } - - val buf = StringBuilder() - for ((key, value) in envVars) { - if (buf.isNotEmpty()) { - buf.append(";") - } - buf.append(key).append("=").append(value) - } - - return buf.toString() - } - - private inner class EnvironmentVariablesDialog(parent: Component) : DialogWrapper(parent, true) { - private val envVarTable = EnvVariablesTable().apply { - setValues(convertToVariables(data.envs, false)) - setPasteActionEnabled(true) - } - - init { - title = message("environment.variables.dialog.title") - init() - } - - override fun createCenterPanel(): JComponent = envVarTable.component - - override fun doOKAction() { - envVarTable.stopEditing() - val newEnvVars = LinkedHashMap() - for (variable in envVarTable.environmentVariables) { - newEnvVars[variable.name] = variable.value - } - envVars = newEnvVars - super.doOKAction() - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/HandlerPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/HandlerPanel.kt index 1374eea891..1982633099 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/HandlerPanel.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/HandlerPanel.kt @@ -3,26 +3,36 @@ package software.aws.toolkits.jetbrains.ui +import com.intellij.openapi.module.ModuleUtil import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ValidationInfo import com.intellij.ui.EditorTextField +import com.intellij.util.text.nullize import com.intellij.util.textCompletion.TextFieldWithCompletion import net.miginfocom.swing.MigLayout import software.amazon.awssdk.services.lambda.model.Runtime +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.lambda.Lambda.findPsiElementsForHandler import software.aws.toolkits.jetbrains.services.lambda.completion.HandlerCompletionProvider +import software.aws.toolkits.jetbrains.utils.ui.validationInfo import software.aws.toolkits.resources.message import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent import javax.swing.JPanel class HandlerPanel(private val project: Project) : JPanel(MigLayout("novisualpadding, ins 0, fillx, hidemode 3")) { - private var handlerCompletionProvider = HandlerCompletionProvider(project, null) private val simpleHandler = EditorTextField() private val handlerWithCompletion = TextFieldWithCompletion(project, handlerCompletionProvider, "", true, true, true, true) + private var runtime: Runtime? = null + val handler: EditorTextField - get() = if (handlerCompletionProvider.isCompletionSupported) handlerWithCompletion - else simpleHandler + get() = if (handlerCompletionProvider.isCompletionSupported) { + handlerWithCompletion + } else { + simpleHandler + } init { initSimpleHandler() @@ -53,29 +63,34 @@ class HandlerPanel(private val project: Project) : JPanel(MigLayout("novisualpad switchCompletion() } - fun setRuntime(runtime: Runtime) { - handlerCompletionProvider = HandlerCompletionProvider(project, runtime) + fun setRuntime(runtime: Runtime?) { + this.runtime = runtime + handlerCompletionProvider = HandlerCompletionProvider(project, runtime?.let { LambdaRuntime.fromValue(it) }) switchCompletion() } private fun initSimpleHandler() { simpleHandler.toolTipText = message("lambda.function.handler.tooltip") - simpleHandler.addComponentListener(object : ComponentAdapter() { - override fun componentShown(e: ComponentEvent?) { - super.componentShown(e) - simpleHandler.text = handlerWithCompletion.text + simpleHandler.addComponentListener( + object : ComponentAdapter() { + override fun componentShown(e: ComponentEvent?) { + super.componentShown(e) + simpleHandler.text = handlerWithCompletion.text + } } - }) + ) } private fun initHandlerWithCompletion() { handlerWithCompletion.toolTipText = message("lambda.function.handler.tooltip") - handlerWithCompletion.addComponentListener(object : ComponentAdapter() { - override fun componentShown(e: ComponentEvent?) { - super.componentShown(e) - handlerWithCompletion.text = simpleHandler.text + handlerWithCompletion.addComponentListener( + object : ComponentAdapter() { + override fun componentShown(e: ComponentEvent?) { + super.componentShown(e) + handlerWithCompletion.text = simpleHandler.text + } } - }) + ) } private fun switchCompletion() { @@ -83,4 +98,22 @@ class HandlerPanel(private val project: Project) : JPanel(MigLayout("novisualpad handlerWithCompletion.isVisible = isCompletionSupported simpleHandler.isVisible = !isCompletionSupported } + + fun validateHandler(handlerMustExist: Boolean): ValidationInfo? { + val handlerValue = handler.text.nullize(true) + ?: return handler.validationInfo(message("lambda.upload_validation.handler")) + + if (handlerMustExist) { + val runtime = runtime + ?: throw IllegalStateException("Runtime was not set in the HandlerPanel") + + val psiFile = findPsiElementsForHandler(project, runtime, handlerValue).firstOrNull()?.containingFile + ?: return handler.validationInfo(message("lambda.upload_validation.handler_not_found")) + + ModuleUtil.findModuleForFile(psiFile) + ?: return handler.validationInfo(message("lambda.upload_validation.module_not_found", psiFile)) + } + + return null + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/KeyValueTextField.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/KeyValueTextField.kt new file mode 100644 index 0000000000..20538d149a --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/KeyValueTextField.kt @@ -0,0 +1,126 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui + +import com.intellij.execution.configuration.EnvironmentVariablesData +import com.intellij.execution.util.EnvVariablesTable +import com.intellij.execution.util.EnvironmentVariable +import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.openapi.util.text.StringUtil +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.UserActivityProviderComponent +import org.jetbrains.annotations.Nls +import software.aws.toolkits.resources.message +import java.awt.Component +import java.util.LinkedHashMap +import java.util.concurrent.CopyOnWriteArrayList +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.event.ChangeEvent +import javax.swing.event.ChangeListener +import javax.swing.event.DocumentEvent + +/** + * Our version of [com.intellij.execution.configuration.EnvironmentVariablesTextFieldWithBrowseButton]. + * It has been modified to support our use case of having a compact, generic key-value entry dialog. + * Inheriting system env vars is not supported, but rest of UX is generally the same + */ +class KeyValueTextField( + @Nls dialogTitle: String = message("environment.variables.dialog.title") +) : TextFieldWithBrowseButton(), UserActivityProviderComponent { + private var data = EnvironmentVariablesData.create(emptyMap(), false) + private val listeners = CopyOnWriteArrayList() + + var envVars: Map + get() = data.envs + set(value) { + data = EnvironmentVariablesData.create(value, false) + text = stringify(data.envs) + } + + init { + addActionListener { + EnvironmentVariablesDialog(this, dialogTitle).show() + } + + textField.document.addDocumentListener( + object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + if (!StringUtil.equals(stringify(data.envs), text)) { + val textEnvs = EnvVariablesTable.parseEnvsFromText(text) + data = EnvironmentVariablesData.create(textEnvs, data.isPassParentEnvs) + fireStateChanged() + } + } + } + ) + } + + private fun convertToVariables(envVars: Map, readOnly: Boolean): List = envVars.map { (key, value) -> + object : EnvironmentVariable(key, value, readOnly) { + override fun getNameIsWriteable(): Boolean = !readOnly + } + } + + override fun getDefaultIcon(): Icon = AllIcons.General.InlineVariables + + override fun getHoveredIcon(): Icon = AllIcons.General.InlineVariablesHover + + override fun addChangeListener(changeListener: ChangeListener) { + listeners.add(changeListener) + } + + override fun removeChangeListener(changeListener: ChangeListener) { + listeners.remove(changeListener) + } + + private fun fireStateChanged() { + listeners.forEach { + it.stateChanged(ChangeEvent(this)) + } + } + + private fun stringify(envVars: Map): String { + if (envVars.isEmpty()) { + return "" + } + + return buildString { + for ((key, value) in envVars) { + if (isNotEmpty()) { + append(";") + } + append(StringUtil.escapeChar(key, ';')) + append("=") + append(StringUtil.escapeChar(value, ';')) + } + } + } + + private inner class EnvironmentVariablesDialog(parent: Component, title: String) : DialogWrapper(parent, true) { + private val envVarTable = EnvVariablesTable().apply { + setValues(convertToVariables(data.envs, false)) + setPasteActionEnabled(true) + } + + init { + this.title = title + init() + } + + override fun createCenterPanel(): JComponent = envVarTable.component + + override fun doOKAction() { + envVarTable.stopEditing() + val newEnvVars = LinkedHashMap() + for (variable in envVarTable.environmentVariables) { + newEnvVars[variable.name] = variable.value + } + envVars = newEnvVars + super.doOKAction() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProgressPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProgressPanel.form deleted file mode 100644 index 566ada3167..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProgressPanel.form +++ /dev/null @@ -1,73 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProgressPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProgressPanel.kt deleted file mode 100644 index 64b325bee4..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProgressPanel.kt +++ /dev/null @@ -1,64 +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 - -import com.intellij.openapi.progress.util.AbstractProgressIndicatorExBase -import com.intellij.openapi.wm.ex.ProgressIndicatorEx -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.UIUtil.invokeLaterIfNeeded -import javax.swing.JButton -import javax.swing.JLabel -import javax.swing.JPanel -import javax.swing.JProgressBar - -class ProgressPanel(progressIndicator: ProgressIndicatorEx) : AbstractProgressIndicatorExBase() { - private lateinit var text2Label: JBLabel - private lateinit var textLabel: JLabel - private lateinit var progressBar: JProgressBar - private lateinit var cancelButton: JButton - private lateinit var content: JPanel - - init { - progressIndicator.addStateDelegate(this) - setModalityProgress(null) - - cancelButton.addActionListener { - progressIndicator.cancel() - } - } - - override fun setText(text: String?) { - super.setText(text) - invokeLaterIfNeeded { - textLabel.text = text - } - } - - override fun setFraction(fraction: Double) { - super.setFraction(fraction) - invokeLaterIfNeeded { - val value = (100 * fraction).toInt() - progressBar.value = value - progressBar.string = "$value%" - } - } - - override fun setText2(text: String?) { - super.setText2(text) - invokeLaterIfNeeded { - text2Label.text = text - } - } - - override fun setIndeterminate(indeterminate: Boolean) { - invokeLaterIfNeeded { - progressBar.isIndeterminate = indeterminate - } - } - - override fun cancel() { - super.cancel() - cancelButton.isEnabled = false - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProjectFileBrowseListener.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProjectFileBrowseListener.kt index f7d45a8014..02cf4057ab 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProjectFileBrowseListener.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ProjectFileBrowseListener.kt @@ -4,28 +4,33 @@ package software.aws.toolkits.jetbrains.ui import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.fileChooser.FileChooserFactory import com.intellij.openapi.project.Project import com.intellij.openapi.project.guessProjectDir import com.intellij.openapi.ui.ComponentWithBrowseButton import com.intellij.openapi.ui.TextComponentAccessor import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.ComboboxWithBrowseButton import javax.swing.JComponent +import javax.swing.JTextField -class ProjectFileBrowseListener @JvmOverloads constructor( +/** Similar to [com.intellij.openapi.ui.TextBrowseFolderListener], but tries to set the initial directory to the assumed project root **/ +private class ProjectFileBrowseListener( project: Project, - textField: ComponentWithBrowseButton, - chooserDescriptor: FileChooserDescriptor, - accessor: TextComponentAccessor, - private val onChosen: ((VirtualFile) -> Unit)? = null + component: ComponentWithBrowseButton, + fileChooserDescriptor: FileChooserDescriptor, + textComponentAccessor: TextComponentAccessor, + private val onChosen: ((VirtualFile) -> String?)? = null ) : ComponentWithBrowseButton.BrowseFolderActionListener( + /* title */ null, + /* description */ null, - textField, + component, project, - chooserDescriptor, - accessor + fileChooserDescriptor, + textComponentAccessor ) { - override fun getInitialFile(): VirtualFile? { val text = componentText if (text.isEmpty()) { @@ -38,6 +43,71 @@ class ProjectFileBrowseListener @JvmOverloads constructor( } override fun onFileChosen(chosenFile: VirtualFile) { - onChosen?.invoke(chosenFile) ?: super.onFileChosen(chosenFile) + if (onChosen == null) { + super.onFileChosen(chosenFile) + } else { + myTextComponent?.let { textComponent -> + val text = onChosen.invoke(chosenFile) ?: return@let + myAccessor.setText(textComponent, text) + } + } + } +} + +/** Customization of [com.intellij.ui.components.installFileCompletionAndBrowseDialog] **/ +fun installProjectFileRootedCompletionAndBrowseDialog( + project: Project, + component: ComponentWithBrowseButton, + textField: JTextField?, + fileChooserDescriptor: FileChooserDescriptor, + textComponentAccessor: TextComponentAccessor, + onChosen: ((VirtualFile) -> String)? = null +) { + component.addActionListener( + ProjectFileBrowseListener(project, component, fileChooserDescriptor, textComponentAccessor, onChosen) + ) + + textField?.let { + FileChooserFactory.getInstance().installFileCompletion( + it, + fileChooserDescriptor, + true, + null /* infer disposable from UI context */ + ) } } + +@JvmOverloads +fun installTextFieldProjectFileBrowseListener( + project: Project, + component: ComponentWithBrowseButton, + fileChooserDescriptor: FileChooserDescriptor, + onChosen: ((VirtualFile) -> String)? = null +) { + installProjectFileRootedCompletionAndBrowseDialog( + project = project, + component = component, + textField = component.childComponent, + fileChooserDescriptor = fileChooserDescriptor, + textComponentAccessor = TextComponentAccessor.TEXT_FIELD_WHOLE_TEXT, + onChosen = onChosen + ) +} + +/* because [com.intellij.ui.ComboboxWithBrowseButton] is deprecated anyways and can't seem to make java happy */ +@Deprecated("ComboboxWithBrowseButton is deprecated") +fun installComboBoxProjectFileBrowseListener( + project: Project, + component: ComboboxWithBrowseButton, + fileChooserDescriptor: FileChooserDescriptor, + onChosen: ((VirtualFile) -> String)? = null +) { + installProjectFileRootedCompletionAndBrowseDialog( + project = project, + component = component, + textField = null, + fileChooserDescriptor = fileChooserDescriptor, + textComponentAccessor = TextComponentAccessor.STRING_COMBOBOX_WHOLE_TEXT, + onChosen = onChosen + ) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/RegionSelector.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/RegionSelector.kt index 57ed8e5948..9a0d17133f 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/RegionSelector.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/RegionSelector.kt @@ -5,6 +5,7 @@ package software.aws.toolkits.jetbrains.ui import com.intellij.openapi.ui.ComboBox import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.ComboboxSpeedSearch import com.intellij.ui.SimpleListCellRenderer import software.aws.toolkits.core.region.AwsRegion import software.aws.toolkits.jetbrains.utils.ui.selected @@ -14,32 +15,30 @@ import software.aws.toolkits.jetbrains.utils.ui.selected * TODO: Determine the UX for the box, do we want to categorize? */ class RegionSelector : ComboBox() { - private val comboBoxModel = object : CollectionComboBoxModel() { - fun setItems(newItems: List) { - internalList.apply { - clear() - addAll(newItems) - } - } - } + private val comboBoxModel = CollectionComboBoxModel() init { model = comboBoxModel setRenderer(RENDERER) // use the setter, not protected field + ComboboxSpeedSearch(this) } fun setRegions(regions: List) { - comboBoxModel.items = regions + comboBoxModel.replaceAll(regions) } var selectedRegion: AwsRegion? get() = selected() set(value) { - selectedItem = value + selectedItem = if (comboBoxModel.items.contains(value)) { + value + } else { + null + } } - private companion object { - val RENDERER = SimpleListCellRenderer.create("") { + companion object { + private val RENDERER = SimpleListCellRenderer.create("") { it.displayName } } 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 c8a8ca85ce..5514dc4d7c 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ResourceSelector.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/ResourceSelector.kt @@ -9,12 +9,11 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.ComponentValidator import com.intellij.openapi.ui.ValidationInfo -import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.ComboboxSpeedSearch import com.intellij.ui.MutableCollectionComboBoxModel import com.intellij.util.ExceptionUtil import org.jetbrains.annotations.TestOnly -import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider -import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.ConnectionSettings import software.aws.toolkits.core.utils.Either import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn @@ -26,29 +25,28 @@ import software.aws.toolkits.jetbrains.utils.ui.find import software.aws.toolkits.resources.message import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture -import javax.swing.JList +import javax.swing.ListCellRenderer private typealias Selector = Either Boolean> -typealias AwsConnection = Pair -typealias LazyAwsConnectionEvaluator = () -> AwsConnection +typealias ConnectionSettingsSupplier = () -> ConnectionSettings? @Suppress("UNCHECKED_CAST") class ResourceSelector private constructor( - private val project: Project, private val resourceType: () -> Resource>?, comboBoxModel: MutableCollectionComboBoxModel, - customRenderer: ColoredListCellRenderer?, + customRenderer: ListCellRenderer?, loadOnCreate: Boolean, private val sortOnLoad: Boolean, - private val awsConnection: LazyAwsConnectionEvaluator + private val connectionSettingsSupplier: ConnectionSettingsSupplier ) : ComboBox(comboBoxModel) { + private val loading = message("loading_resource.loading") - private val resourceCache = AwsResourceCache.getInstance(project) @Volatile private var loadingStatus: Status = Status.NOT_LOADED private var shouldBeEnabled: Boolean = isEnabled private var selector: Selector? = null + @Volatile private var loadingFuture: CompletableFuture<*>? = null @@ -57,6 +55,8 @@ class ResourceSelector private constructor( setRenderer(customRenderer) } + ComboboxSpeedSearch(this) + if (loadOnCreate) { reload() } @@ -66,24 +66,31 @@ class ResourceSelector private constructor( @Synchronized fun reload(forceFetch: Boolean = false) { val previouslySelected = model.selectedItem - loadingStatus = Status.LOADING - - // If this reload will supersede a previous once, cancel it - loadingFuture?.cancel(true) + val previousLoading = loadingFuture runInEdt(ModalityState.any()) { loadingStatus = Status.LOADING super.setEnabled(false) setEditable(true) - super.setSelectedItem(message("loading_resource.loading")) + super.setSelectedItem(loading) val resource = resourceType.invoke() - if (resource == null) { + val connectionSettings = connectionSettingsSupplier() + if (resource == null || connectionSettings == null) { processSuccess(emptyList(), null) } else { - val (region, credentials) = awsConnection() - val resultFuture = resourceCache.getResource(resource, region, credentials, forceFetch = forceFetch).toCompletableFuture() + val resultFuture = AwsResourceCache.getInstance().getResource( + resource, + connectionSettings = connectionSettings, + forceFetch = forceFetch + ).toCompletableFuture() + + // If this reload will supersede a previous once, cancel it + if (previousLoading != resultFuture) { + previousLoading?.cancel(true) + } + loadingFuture = resultFuture resultFuture.whenComplete { value, error -> when { @@ -98,8 +105,8 @@ class ResourceSelector private constructor( } override fun getModel(): MutableCollectionComboBoxModel = - // javax.swing.DefaultComboBoxModel.addAll(java.util.Collection) isn't in Java 8 - // The addElement method can lead to multiple selection events firing as elements are added + // javax.swing.DefaultComboBoxModel.addAll(java.util.Collection) isn't in Java 8 + // The addElement method can lead to multiple selection events firing as elements are added // Use IntelliJ's to work around this short coming super.getModel() as MutableCollectionComboBoxModel @@ -164,7 +171,16 @@ class ResourceSelector private constructor( when { value.isEmpty() -> super.setSelectedItem(null) value.size == 1 -> super.setSelectedItem(value.first()) - else -> super.setSelectedItem(determineSelection(selector, previouslySelected)) + else -> { + // if the select item was removed or set to an invalid value, setSelectedItem + // will cause the selected item to be the string "Loading..." so we need to + // make sure after setting that that is not the case. Otherwise, set it to + // null to inform the user they need to set it again + super.setSelectedItem(determineSelection(selector, previouslySelected)) + if (selectedItem == loading) { + super.setSelectedItem(null) + } + } } } } @@ -175,7 +191,7 @@ class ResourceSelector private constructor( runInEdt(ModalityState.any()) { loadingStatus = Status.NOT_LOADED super.setSelectedItem(message) - notifyError(message, ExceptionUtil.getThrowableText(error), project) + notifyError(message, ExceptionUtil.getThrowableText(error)) val validationInfo = ValidationInfo(error.message ?: message, this) ComponentValidator.createPopupBuilder(validationInfo, null) .setCancelOnClickOutside(true) @@ -198,9 +214,10 @@ class ResourceSelector private constructor( companion object { private val LOG = getLogger>() + @JvmStatic - fun builder(project: Project) = object : ResourceBuilder { - override fun resource(resource: (() -> Resource>?)): ResourceBuilderOptions = ResourceBuilderOptions(project, resource) + fun builder() = object : ResourceBuilder { + override fun resource(resource: (() -> Resource>?)): ResourceBuilderOptions = ResourceBuilderOptions(resource) } } @@ -209,61 +226,40 @@ class ResourceSelector private constructor( fun resource(resource: (() -> Resource>?)): ResourceBuilderOptions } - class ResourceBuilderOptions internal constructor(private val project: Project, private val resource: (() -> Resource>?)) { + class ResourceBuilderOptions internal constructor(private val resource: (() -> Resource>?)) { private var loadOnCreate = true private var sortOnLoad = true private var comboBoxModel: MutableCollectionComboBoxModel = MutableCollectionComboBoxModel() - private var customRendererFunction: ((T, ColoredListCellRenderer) -> ColoredListCellRenderer)? = null - private var customRenderer: ColoredListCellRenderer? = null - private var awsConnection: LazyAwsConnectionEvaluator? = null + private var customRenderer: ListCellRenderer? = null + private lateinit var connectionSettings: ConnectionSettingsSupplier fun comboBoxModel(comboBoxModel: MutableCollectionComboBoxModel): ResourceBuilderOptions = also { it.comboBoxModel = comboBoxModel } - fun customRenderer(customRenderer: (T, ColoredListCellRenderer) -> ColoredListCellRenderer): ResourceBuilderOptions = also { - if (it.customRenderer != null) { - throw IllegalStateException("Please specify either of the customRenderer factory methods, but not both") - } - it.customRendererFunction = customRenderer - } - - fun customRenderer(customRenderer: ColoredListCellRenderer): ResourceBuilderOptions = also { - if (it.customRendererFunction != null) { - throw IllegalStateException("Please specify either of the customRenderer factory methods, but not both") - } + fun customRenderer(customRenderer: ListCellRenderer): ResourceBuilderOptions = also { it.customRenderer = customRenderer } - private fun resolveCustomRenderer(): ColoredListCellRenderer? = customRenderer ?: customRendererFunction?.let { renderer -> - object : ColoredListCellRenderer() { - override fun customizeCellRenderer( - list: JList, - value: T?, - index: Int, - selected: Boolean, - hasFocus: Boolean - ) { - value?.let { - renderer.invoke(it, this) - } - } - } - } - fun disableAutomaticLoading(): ResourceBuilderOptions = also { it.loadOnCreate = false } fun disableAutomaticSorting(): ResourceBuilderOptions = also { it.sortOnLoad = false } - fun awsConnection(awsConnection: AwsConnection): ResourceBuilderOptions = awsConnection { awsConnection } + fun awsConnection(project: Project): ResourceBuilderOptions = awsConnection { AwsConnectionManager.getInstance(project).connectionSettings() } + + fun awsConnection(connectionSettings: ConnectionSettings): ResourceBuilderOptions = awsConnection { connectionSettings } - fun awsConnection(awsConnection: LazyAwsConnectionEvaluator): ResourceBuilderOptions = also { - it.awsConnection = awsConnection + fun awsConnection(connectionSettings: ConnectionSettingsSupplier): ResourceBuilderOptions = also { + it.connectionSettings = connectionSettings } - fun build() = ResourceSelector(project, resource, comboBoxModel, resolveCustomRenderer(), loadOnCreate, sortOnLoad, awsConnection ?: { - val settings = AwsConnectionManager.getInstance(project) - settings.activeRegion to settings.activeCredentialProvider - }) + fun build() = ResourceSelector( + resource, + comboBoxModel, + customRenderer, + loadOnCreate, + sortOnLoad, + connectionSettings + ) } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.form index 3d10a5337d..a41658865b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.form @@ -22,7 +22,9 @@
- + + + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.java deleted file mode 100644 index 10890552d3..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.java +++ /dev/null @@ -1,115 +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; - -import com.intellij.openapi.ui.ValidationInfo; -import com.intellij.uiDesigner.core.GridConstraints; -import com.intellij.uiDesigner.core.GridLayoutManager; -import java.awt.Dimension; -import java.awt.Insets; -import javax.swing.JComponent; -import org.jetbrains.annotations.TestOnly; - -import javax.swing.JPanel; -import javax.swing.JSlider; -import javax.swing.JTextField; -import java.awt.event.FocusAdapter; -import java.awt.event.FocusEvent; - -import static software.aws.toolkits.resources.Localization.message; - -// A panel with a slider and text field, of which the slider and text field always synced up. -public class SliderPanel { - private final int min, max, minTick, maxTick, minorTick, majorTick, defaultValue; - private final boolean snap; - - private JPanel content; - public JSlider slider; - public JTextField textField; - - public SliderPanel(int min, int max, int defaultValue, int minTick, int maxTick, int minorTick, int majorTick, boolean snap) { - this.min = min; - this.max = max; - this.defaultValue = defaultValue; - this.minTick = minTick; - this.maxTick = maxTick; - this.minorTick = minorTick; - this.majorTick = majorTick; - this.snap = snap; - bind(); - } - - public void setValue(int value) { - slider.setValue(value); - } - - public int getValue() { - return slider.getValue(); - } - - public ValidationInfo validate() { - Integer value = null; - try { - value = Integer.parseInt(textField.getText()); - } catch (Exception ignored) { - } - - if (value == null || value < min || value > max) { - return new ValidationInfo(message("lambda.slider_validation", min, max), textField); - } - return null; - } - - public void setEnabled(Boolean enabled) { - slider.setEnabled(enabled); - textField.setEnabled(enabled); - } - - @TestOnly - public boolean isVisible() { - return slider.getParent().isVisible() && slider.isVisible() && textField.isVisible(); - } - - private void bind() { - slider.setMajorTickSpacing(majorTick); - slider.setMinorTickSpacing(minorTick); - slider.setMaximum(maxTick); - slider.setMinimum(minTick); - slider.setPaintLabels(true); - slider.setSnapToTicks(snap); - slider.setValue(defaultValue); - slider.addChangeListener(e -> - textField.setText(Integer.toString(validValue(slider.getValue()))) - ); - textField.setText(Integer.toString(slider.getValue())); - textField.addFocusListener(new FocusAdapter() { - // When the text field lost focus, we force the value to be valid to reset to - // - default value if the input is not a valid integer, or - // - min if it is smaller than min, or - // - max if it is bigger than max. - @Override - public void focusLost(FocusEvent e) { - int value; - try { - value = validValue(Integer.parseInt(textField.getText())); - } catch (Exception e2) { - value = defaultValue; - } - slider.setValue(value); - textField.setText(Integer.toString(value)); - } - }); - } - - private int validValue(int originalValue) { - if (originalValue < min) { - return min; - } else if (originalValue > max) { - return max; - } else { - return originalValue; - } - } - -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.kt new file mode 100644 index 0000000000..bcfc8b5df7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/SliderPanel.kt @@ -0,0 +1,82 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.ui + +import com.intellij.openapi.ui.ValidationInfo +import org.jetbrains.annotations.TestOnly +import software.aws.toolkits.resources.message +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import javax.swing.JPanel +import javax.swing.JSlider +import javax.swing.JTextField + +// A panel with a slider and text field, of which the slider and text field always synced up. +class SliderPanel( + private val min: Int, + private val max: Int, + defaultValue: Int = min, + minTick: Int = min, + maxTick: Int = max, + minorTick: Int = (max - min) / 30, + majorTick: Int = (max - min) / 5, + snap: Boolean = false +) { + @Suppress("UnusedPrivateMember") // root element must be bound + private lateinit var content: JPanel + lateinit var slider: JSlider + private set + lateinit var textField: JTextField + private set + + init { + slider.majorTickSpacing = majorTick + slider.minorTickSpacing = minorTick + slider.maximum = maxTick + slider.minimum = minTick + slider.paintLabels = true + slider.snapToTicks = snap + slider.value = defaultValue + slider.addChangeListener { textField.text = validValue(slider.value).toString() } + textField.text = slider.value.toString() + textField.addFocusListener( + object : FocusAdapter() { + // When the text field lost focus, we force the value to be valid to reset to + // - default value if the input is not a valid integer, or + // - min if it is smaller than min, or + // - max if it is bigger than max. + override fun focusLost(e: FocusEvent) { + val value = validValue(textField.text.toInt()) + slider.value = value + textField.text = value.toString() + } + } + ) + } + + var value: Int + get() = slider.value + set(value) { + slider.value = value + } + + fun validate(): ValidationInfo? { + val value = textField.text.toIntOrNull() + return if (value == null || value < min || value > max) { + ValidationInfo(message("lambda.slider_validation", min, max), textField) + } else { + null + } + } + + fun setEnabled(enabled: Boolean) { + slider.isEnabled = enabled + textField.isEnabled = enabled + } + + @get:TestOnly + val isVisible: Boolean + get() = slider.parent.isVisible && slider.isVisible && textField.isVisible + + private fun validValue(originalValue: Int): Int = originalValue.coerceAtMost(max).coerceAtLeast(min) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/clouddebug/ArtifactMappingPopup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/clouddebug/ArtifactMappingPopup.kt deleted file mode 100644 index 460bedba86..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/clouddebug/ArtifactMappingPopup.kt +++ /dev/null @@ -1,158 +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.clouddebug - -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.openapi.ui.popup.ListPopup -import com.intellij.openapi.ui.popup.PopupStep -import com.intellij.openapi.ui.popup.util.BaseListPopupStep -import com.intellij.ui.CellRendererPanel -import com.intellij.ui.components.JBLabel -import com.intellij.ui.popup.list.ListPopupImpl -import com.intellij.util.ui.JBUI -import net.miginfocom.swing.MigLayout -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.trace -import software.aws.toolkits.jetbrains.services.ecs.execution.ArtifactMapping -import software.aws.toolkits.resources.message -import java.awt.Component -import javax.swing.Icon -import javax.swing.JList -import javax.swing.JPanel -import javax.swing.ListCellRenderer - -/** - * Popup represents values from Artifact Mapping table - */ -class ArtifactMappingPopup { - - companion object { - private const val MAX_ROW_COUNT = 5 - - fun createPopup( - artifactMappingItems: List, - onSelected: (ArtifactMapping?) -> Unit - ): ListPopup { - val step = ArtifactMappingPopupStep(artifactMappingItems, onSelected) - val popup = JBPopupFactory.getInstance().createListPopup(step, MAX_ROW_COUNT) - - val popupToUpdate = popup as? ListPopupImpl - ?: throw IllegalStateException("Unable to cast popup in type: '${popup.javaClass}' to ${ListPopupImpl::class.qualifiedName}") - - popupToUpdate.list.setCellRenderer(PathMappingPopupCellRenderer()) - return popupToUpdate - } - } -} - -class ArtifactMappingPopupStep(paths: List, private val onSelected: (ArtifactMapping?) -> Unit) : - BaseListPopupStep(message("cloud_debug.run_configuration.auto_fill_link.popup_title"), paths) { - - override fun isSpeedSearchEnabled(): Boolean = true - - override fun hasSubstep(selectedValue: ArtifactMapping?): Boolean = false - - override fun getIconFor(value: ArtifactMapping?): Icon? = null - - override fun onChosen(value: ArtifactMapping?, finalChoice: Boolean): PopupStep<*>? { - val stepResult = super.onChosen(value, finalChoice) - onSelected(value) - return stepResult - } - - override fun isAutoSelectionEnabled(): Boolean = false -} - -/** - * Renderer for a popup cell. - * A popup contains a line presentation for mapping between local path and remote path in container. - * A renderer present this information using the following layout: {{local_path} > {remote_path}}, where - * {local_path} has min fixed width for every element in a popup to align all values. - * Width for {local_path} component is set to a maximum width of existing components, but not more then a defined maximum. - */ -class PathMappingPopupCellRenderer : CellRendererPanel(), ListCellRenderer { - - companion object { - private val logger = getLogger() - - const val LEFT_COMPONENT_MAX_WIDTH = 300 - const val LEFT_COMPONENT_MIN_WIDTH = 50 - } - - private var leftComponentWidth: Int = -1 - private val localPathLabel = JBLabel() - private val separatorLabel = JBLabel(" > ") - private val remotePathLabel = JBLabel() - - override fun getListCellRendererComponent( - list: JList?, - value: ArtifactMapping?, - index: Int, - selected: Boolean, - hasFocus: Boolean - ): Component { - - if (leftComponentWidth == -1) - leftComponentWidth = getMaxListComponentLocalPathSize(list) - - localPathLabel.text = value?.localPath.toString() - remotePathLabel.text = value?.remotePath.toString() - - val rowPanel = JPanel(MigLayout("novisualpadding, ins 0, gap 0", "[${JBUI.scale(leftComponentWidth)}][min!][]")).apply { - add(localPathLabel, "wmin 0, growx, gapbefore ${JBUI.scale(5)}") - add(separatorLabel) - add(remotePathLabel, "growx, gapafter ${JBUI.scale(5)}") - } - - val valueBoundWidth = list?.font?.getStringBounds(value?.localPath, list.getFontMetrics(list.font).fontRenderContext)?.width - if (valueBoundWidth != null && valueBoundWidth > LEFT_COMPONENT_MAX_WIDTH) { - rowPanel.toolTipText = localPathLabel.text - } - - if (selected) { - rowPanel.background = list?.selectionBackground - - localPathLabel.foreground = list?.selectionForeground - separatorLabel.foreground = list?.selectionForeground - remotePathLabel.foreground = list?.selectionForeground - } else { - rowPanel.background = list?.background - - localPathLabel.foreground = list?.foreground - separatorLabel.foreground = list?.foreground - remotePathLabel.foreground = list?.foreground - } - - return rowPanel - } - - /** - * Calculate the max width for a left component in a popup cell - * - * @param list - list of components containing all elements to be rendered in a popup. - * @return [Int] value with width for a left most component in a popup cell. - */ - private fun getMaxListComponentLocalPathSize(list: JList?): Int { - logger.trace { "Calculate width for a left-most component." } - list ?: return LEFT_COMPONENT_MIN_WIDTH - - val size = list.model.size - var maxStringSize = LEFT_COMPONENT_MIN_WIDTH - - for (componentIndex in 0 until size) { - val value = list.model.getElementAt(componentIndex) ?: continue - val bounds = list.font.getStringBounds(value.localPath, list.getFontMetrics(list.font).fontRenderContext) - if (bounds.width >= LEFT_COMPONENT_MAX_WIDTH) { - logger.trace { "Found a component with width size: '${bounds.width}'. Return max value: '$LEFT_COMPONENT_MAX_WIDTH'." } - return LEFT_COMPONENT_MAX_WIDTH - } - - if (bounds.width > maxStringSize) - maxStringSize = bounds.width.toInt() - } - - logger.trace { "Found component with max width: '$maxStringSize'." } - return maxStringSize - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/clouddebug/StartupCommandWithAutoFill.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/clouddebug/StartupCommandWithAutoFill.kt deleted file mode 100644 index 529c723f54..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/clouddebug/StartupCommandWithAutoFill.kt +++ /dev/null @@ -1,96 +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.clouddebug - -import com.intellij.execution.configurations.RuntimeConfigurationError -import com.intellij.ide.DataManager -import com.intellij.openapi.project.Project -import com.intellij.ui.components.fields.ExpandableTextField -import com.intellij.ui.components.labels.LinkLabel -import com.intellij.util.ui.JBUI -import net.miginfocom.swing.MigLayout -import software.aws.toolkits.jetbrains.services.clouddebug.CloudDebuggingPlatform -import software.aws.toolkits.jetbrains.services.clouddebug.DebuggerSupport -import software.aws.toolkits.jetbrains.services.ecs.execution.ArtifactMapping -import software.aws.toolkits.resources.message -import javax.swing.JPanel - -/** - * Component to display startup command with ability to generate command automatically based on existing Artifacts mapping table. - * - * @param project - [com.intellij.openapi.project.Project] instance - * @param containerName - AWS Docker container name - */ -class StartupCommandWithAutoFill(private val project: Project, private val containerName: String) : - JPanel(MigLayout("novisualpadding, ins 0, gap 0, fillx, wrap 2, hidemode 3", "[][min!]")) { - - private val startupCommandField = ExpandableTextField() - private val autoFillLink = LinkLabel(message("cloud_debug.run_configuration.auto_fill_link.text"), null, ::onAutoFillLinkClicked) - - var command: String - get() = startupCommandField.text - set(value) { startupCommandField.text = value } - - var autoFillPopupContent: () -> List = { emptyList() } - - var platform: CloudDebuggingPlatform? = null - set(value) { - field = value - - val commandHelper = DebuggerSupport.debugger(myPlatform).startupCommand() - - // Show/hide Auto-fill button - autoFillLink.isVisible = commandHelper.isStartCommandAutoFillSupported - - // Update start command hints - startupCommandField.emptyText.text = commandHelper.getStartupCommandTextFieldHintText() - } - - private val myPlatform: CloudDebuggingPlatform - get() = platform ?: throw RuntimeConfigurationError(message("cloud_debug.run_configuration.missing.platform", containerName)) - - init { - add(startupCommandField, "growx") - add(autoFillLink, "gapbefore ${JBUI.scale(3)}") - - initStartupCommandField() - } - - /** - * Set "Auto-fill" button enabled state. - * When the button is enabled we skip the tooltip. Otherwise, show a tooltip with text to clarify the behavior. - */ - fun setAutoFillLinkEnabled(isEnabled: Boolean) { - autoFillLink.isEnabled = isEnabled - autoFillLink.toolTipText = if (isEnabled) "" else message("cloud_debug.run_configuration.auto_fill_link.tooltip_text") - } - - private fun initStartupCommandField() { - startupCommandField.toolTipText = message("cloud_debug.ecs.run_config.container.start_cmd.tooltip") - } - - @Suppress("UNUSED_PARAMETER") - private fun onAutoFillLinkClicked(label: LinkLabel, ignored: Any?) { - val artifactMappingItems = autoFillPopupContent().filter { artifact -> - !artifact.localPath?.trim().isNullOrEmpty() && !artifact.remotePath?.trim().isNullOrEmpty() - } - - val popup = ArtifactMappingPopup.createPopup( - artifactMappingItems = artifactMappingItems, - onSelected = { artifact -> - artifact ?: return@createPopup - - val commandHelper = DebuggerSupport.debugger(myPlatform).startupCommand() - commandHelper.updateStartupCommand( - project = project, - originalCommand = command, - artifact = artifact, - onCommandGet = { command = it } - ) - } - ) - val dataContext = DataManager.getInstance().getDataContext(autoFillLink) - popup.showInBestPositionFor(dataContext) - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.form index 2efe39f71a..134ac18861 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.form +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.form @@ -15,7 +15,7 @@ - + @@ -25,7 +25,7 @@ - + @@ -42,9 +42,7 @@ - - - + diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.java deleted file mode 100644 index 612771f97e..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.java +++ /dev/null @@ -1,14 +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.connection; - -import javax.swing.JPanel; -import software.aws.toolkits.jetbrains.ui.CredentialProviderSelector; -import software.aws.toolkits.jetbrains.ui.RegionSelector; - -public final class AwsConnectionSettings { - JPanel panel; - CredentialProviderSelector credentialProvider; - RegionSelector region; -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.kt new file mode 100644 index 0000000000..a1947ed696 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettings.kt @@ -0,0 +1,13 @@ +// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.ui.connection + +import software.aws.toolkits.jetbrains.ui.CredentialProviderSelector +import software.aws.toolkits.jetbrains.ui.RegionSelector +import javax.swing.JPanel + +class AwsConnectionSettings { + lateinit var panel: JPanel + lateinit var credentialProvider: CredentialProviderSelector + lateinit var region: RegionSelector +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettingsEditor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettingsEditor.kt index 1dd91844a3..64646cadd4 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettingsEditor.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/AwsConnectionSettingsEditor.kt @@ -19,11 +19,22 @@ import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider import software.aws.toolkits.resources.message import javax.swing.JComponent +/** + * AWS settings for run configurations. Settings will be serialized with the rest of the run configuration. + * @param project The active project + * @param serviceName The ID of the service (accessed through .SERVICE_NAME in the JAVA SDK v2) + * used to filter out regions based on what regions a service is available in. + * @param settingsChangedListener A callback for when settings are changed + */ class AwsConnectionSettingsEditor>( project: Project, - private val settingsChangedListener: (AwsRegion?, String?) -> Unit = { _, _ -> } + serviceName: String? = null, + settingsChangedListener: (AwsRegion?, String?) -> Unit = { _, _ -> } ) : SettingsEditor() { - private val awsConnectionSelector = AwsConnectionSettingsSelector(project, settingsChangedListener) + private val awsConnectionSelector = AwsConnectionSettingsSelector(project, serviceName) { + // TODO: Undo this unwrapping + settingsChangedListener.invoke(it?.region, it?.credentials?.id) + } override fun createEditor(): JComponent = awsConnectionSelector.selectorPanel() 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 860b11c727..d78848eab7 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 @@ -4,60 +4,105 @@ package software.aws.toolkits.jetbrains.ui.connection import com.intellij.openapi.project.Project +import com.intellij.ui.PopupMenuListenerAdapter +import software.aws.toolkits.core.ConnectionSettings import software.aws.toolkits.core.region.AwsRegion -import software.aws.toolkits.jetbrains.core.credentials.CredentialManager import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.CredentialManager +import software.aws.toolkits.jetbrains.core.credentials.profiles.DEFAULT_PROFILE_ID import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider import javax.swing.JComponent +import javax.swing.event.PopupMenuEvent class AwsConnectionSettingsSelector( - private val project: Project, - private val settingsChangedListener: (AwsRegion?, String?) -> Unit = { _, _ -> } + project: Project?, + serviceId: String? = null, + private val settingsChangedListener: (ConnectionSettings?) -> Unit = { _ -> }, ) { private val regionProvider = AwsRegionProvider.getInstance() private val credentialManager = CredentialManager.getInstance() val view = AwsConnectionSettings() init { - view.region.setRegions(regionProvider.allRegions().values.toMutableList()) + val regions = if (serviceId != null) { + regionProvider.allRegionsForService(serviceId).values.toMutableList() + } else { + regionProvider.allRegions().values.toMutableList() + } + view.region.setRegions(regions) view.credentialProvider.setCredentialsProviders(credentialManager.getCredentialIdentifiers()) - val accountSettingsManager = AwsConnectionManager.getInstance(project) - view.region.selectedRegion = accountSettingsManager.activeRegion - if (accountSettingsManager.isValidConnectionSettings()) { - accountSettingsManager.selectedCredentialIdentifier?.let { + // nullable; unfortunately we can't rely on connection manager instance being retrievable from the default project + if (project != null) { + val accountSettingsManager = AwsConnectionManager.getInstance(project) + if (accountSettingsManager.isValidConnectionSettings()) { + view.region.selectedRegion = accountSettingsManager.activeRegion + accountSettingsManager.selectedCredentialIdentifier?.let { + view.credentialProvider.setSelectedCredentialsProvider(it) + } + } + } else { + view.region.selectedRegion = regionProvider.defaultRegion() + + // pick default or the first one + (credentialManager.getCredentialIdentifierById(DEFAULT_PROFILE_ID) ?: credentialManager.getCredentialIdentifiers().firstOrNull())?.let { view.credentialProvider.setSelectedCredentialsProvider(it) } } - view.region.addActionListener { - fireChange() - } - view.credentialProvider.addActionListener { - fireChange() - } + fireChange() + + view.credentialProvider.addPopupMenuListener( + object : PopupMenuListenerAdapter() { + override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent?) { + fireChange() + } + } + ) + + view.region.addPopupMenuListener( + object : PopupMenuListenerAdapter() { + override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent?) { + fireChange() + } + } + ) } private fun fireChange() { - settingsChangedListener(view.region.selectedRegion, view.credentialProvider.getSelectedCredentialsProvider()) + settingsChangedListener(connectionSettings()) } fun selectorPanel(): JComponent = view.panel fun resetAwsConnectionOptions(regionId: String?, credentialProviderId: String?) { - regionId?.let { view.region.selectedRegion = regionProvider[it] } + if (regionId != null) { + view.region.selectedRegion = regionProvider[regionId] + } + if (credentialProviderId == null) { + return + } - credentialProviderId?.let { providerId -> - try { - credentialManager.getCredentialIdentifierById(providerId)?.let { - view.credentialProvider.setSelectedCredentialsProvider(it) - } - } catch (_: Exception) { - view.credentialProvider.setSelectedInvalidCredentialsProvider(providerId) + try { + val credentialIdentifier = credentialManager.getCredentialIdentifierById(credentialProviderId) + if (credentialIdentifier != null) { + view.credentialProvider.setSelectedCredentialsProvider(credentialIdentifier) } + } catch (_: Exception) { + view.credentialProvider.setSelectedInvalidCredentialsProvider(credentialProviderId) } + fireChange() } fun selectedCredentialProvider(): String? = view.credentialProvider.getSelectedCredentialsProvider() fun selectedRegion(): AwsRegion? = view.region.selectedRegion + + fun connectionSettings() = view.region.selectedRegion?.let { region -> + view.credentialProvider.getSelectedCredentialsProvider()?.let { credId -> + val manager = CredentialManager.getInstance() + manager.getCredentialIdentifierById(credId)?.let { + ConnectionSettings(manager.getAwsCredentialProvider(it, region), region) + } + } + } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/SonoLoginOverlay.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/SonoLoginOverlay.kt new file mode 100644 index 0000000000..43a242ce19 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/connection/SonoLoginOverlay.kt @@ -0,0 +1,105 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui.connection + +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.VerticalFlowLayout +import com.intellij.openapi.util.Disposer +import com.intellij.ui.AppIcon +import com.intellij.ui.components.panels.NonOpaquePanel +import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBFont +import icons.AwsIcons +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.jetbrains.core.credentials.sono.SonoCredentialManager +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.services.caws.CawsEndpoints +import software.aws.toolkits.resources.message +import javax.swing.JComponent + +open class SonoLoginOverlay( + private val project: Project?, + private val disposable: Disposable, + private val drawPostLoginContent: (SonoLoginOverlay.(ClientConnectionSettings<*>) -> JComponent) +) : + NonOpaquePanel(VerticalFlowLayout(VerticalFlowLayout.TOP, 0, 0, true, true)) { + + private val contentWrapper = Wrapper() + private val loginSubpanel by lazy { + // TODO: pending final UX + panel { + row { + label(message("code.aws")).applyToComponent { + icon = AwsIcons.Logos.AWS_SMILE_LARGE + font = JBFont.h2() + } + } + + row { + label(message("code.aws.value_prop_text")) + } + + row { + browserLink(message("aws.settings.learn_more"), CawsEndpoints.ConsoleFactory.marketing()) + } + + row { + button(message("caws.login")) { + ApplicationManager.getApplication().executeOnPooledThread { + SonoCredentialManager.loginSono(project) + } + }.apply { + applyToComponent { + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + } + } + } + }.andTransparent() + } + + init { + border = null + add(contentWrapper) + + redraw() + + ApplicationManager.getApplication().messageBus.connect(disposable).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun onChange(providerId: String) { + redraw() + } + } + ) + } + + open fun initBorders() {} + + fun redraw() { + with(contentWrapper.targetComponent) { + if (this is Disposable) { + Disposer.dispose(this) + } + } + + val connectionSettings = SonoCredentialManager.getInstance().getConnectionSettings() + + // specify 'any' because if we're currently in a modal dialog, we noop until the dialog is closed + runInEdt(ModalityState.any()) { + initBorders() + if (connectionSettings != null) { + AppIcon.getInstance().requestAttention(null, false) + contentWrapper.setContent(drawPostLoginContent(connectionSettings)) + } else { + contentWrapper.setContent(loginSubpanel) + } + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/Constants.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/Constants.kt new file mode 100644 index 0000000000..4ebf0ab004 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/Constants.kt @@ -0,0 +1,7 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui.feedback + +const val FEEDBACK_SOURCE = "source" +const val ENABLED_EXPERIMENTS = "experimentsEnabled" diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/FeedbackDialog.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/FeedbackDialog.kt index 4ec5b15081..2a91152312 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/FeedbackDialog.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/FeedbackDialog.kt @@ -3,55 +3,170 @@ package software.aws.toolkits.jetbrains.ui.feedback +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.application.runInEdt import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.ColorUtil +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.dsl.builder.Cell +import com.intellij.ui.dsl.builder.bind +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.rows +import com.intellij.util.IconUtil +import com.intellij.util.ui.UIUtil import icons.AwsIcons -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.annotations.TestOnly +import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment +import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService -import software.aws.toolkits.jetbrains.utils.ApplicationThreadPoolScope -import software.aws.toolkits.jetbrains.utils.getCoroutineUiContext import software.aws.toolkits.jetbrains.utils.notifyInfo import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.FeedbackTelemetry import software.aws.toolkits.telemetry.Result +import java.net.URLEncoder -class FeedbackDialog(private val project: Project) : DialogWrapper(project), CoroutineScope by ApplicationThreadPoolScope("FeedbackDialog") { - val panel = SubmitFeedbackPanel() +class FeedbackDialog( + val project: Project, + initialSentiment: Sentiment = Sentiment.POSITIVE, + initialComment: String = "", + val productName: String = "Toolkit" +) : DialogWrapper(project) { + private val coroutineScope = projectCoroutineScope(project) + private var sentiment = initialSentiment + private val smileIcon = IconUtil.scale(AwsIcons.Misc.SMILE, null, 3f) + private val sadIcon = IconUtil.scale(AwsIcons.Misc.FROWN, null, 3f) + private var commentText: String = initialComment + private lateinit var comment: Cell + private var lengthLimitLabel = JBLabel(message("feedback.comment.textbox.initial.length")).also { it.foreground = UIUtil.getLabelInfoForeground() } - init { - title = feedbackTitle - setOKButtonText(message("feedback.submit_button")) - init() + private val dialogPanel = panel { + if (isToolkit()) { + row { + text(message("feedback.initial.help.text")) + } + } + group(message("feedback.connect.with.github.title")) { + row { + icon(AllIcons.Toolwindows.ToolWindowDebugger) + link(message("feedback.report.issue.link")) { + BrowserUtil.browse("${GITHUB_LINK_BASE}${URLEncoder.encode("${comment.component.text}\n\n$toolkitMetadata", Charsets.UTF_8.name())}") + } + } + row { + icon(AllIcons.Actions.IntentionBulbGrey) + + link(message("feedback.request.feature.link")) { + BrowserUtil.browse("${GITHUB_LINK_BASE}${URLEncoder.encode("${comment.component.text}\n\n$toolkitMetadata", Charsets.UTF_8.name())}") + } + } + row { + icon(AllIcons.Nodes.Tag) + link(message("feedback.view.source.code.link")) { + BrowserUtil.browse(TOOLKIT_REPOSITORY_LINK) + } + } + } + + group(message("feedback.share.feedback.title")) { + buttonsGroup { + row { + radioButton("", value = Sentiment.POSITIVE).applyToComponent { + icon(smileIcon) + } + + radioButton("", value = Sentiment.NEGATIVE).applyToComponent { + icon(sadIcon) + } + } + }.bind({ sentiment }, { sentiment = it }) + + if (isAmazonQ()) { + row(message("feedback.comment.textbox.title.amazonq")) {} + } else { + row(message("feedback.comment.textbox.title", productName)) {} + } + row { comment(message("feedback.customer.alert.info")) } + row { + comment = textArea().rows(6).columns(52).bindText(::commentText).applyToComponent { + this.emptyText.text = message("feedback.comment.emptyText") + this.lineWrap = true + + this.document.addUndoableEditListener { + onTextAreaUpdate(this.text) + commentText = this.text + } + } + }.comment(commentText) + row { + cell(lengthLimitLabel) + } + } + } + + override fun createCenterPanel() = dialogPanel + + override fun doCancelAction() { + super.doCancelAction() + // kill any remaining coroutines + coroutineScope.coroutineContext.cancel() + FeedbackTelemetry.result(project, result = Result.Cancelled) } override fun doOKAction() { if (okAction.isEnabled) { + dialogPanel.apply() setOKButtonText(message("feedback.submitting")) isOKActionEnabled = false var result = Result.Succeeded - - val sentiment = panel.sentiment ?: throw IllegalStateException("sentiment was null after validation") - val comment = panel.comment ?: throw IllegalStateException("comment was null after validation") - launch { - val edtContext = getCoroutineUiContext(ModalityState.stateForComponent(panel.panel)) + coroutineScope.launch { + val edtContext = getCoroutineUiContext() try { - TelemetryService.getInstance().sendFeedback(sentiment, comment) + if (isCodeWhisperer()) { + TelemetryService.getInstance().sendFeedback( + sentiment, + "CodeWhisperer onboarding: $commentText", + mapOf(FEEDBACK_SOURCE to "CodeWhisperer onboarding") + ) + } else if (isAmazonQ()) { + TelemetryService.getInstance().sendFeedback( + sentiment, + "Amazon Q onboarding: $commentText", + mapOf(FEEDBACK_SOURCE to "Amazon Q onboarding") + ) + } else { + TelemetryService.getInstance().sendFeedback(sentiment, commentText) + } withContext(edtContext) { close(OK_EXIT_CODE) } - notifyInfo(message("aws.notification.title"), message("feedback.submit_success"), project) + val notificationTitle = if (isCodeWhisperer()) { + message("aws.notification.title.codewhisperer") + } else if (isAmazonQ()) { + message("aws.notification.title.amazonq") + } else { + message("aws.notification.title") + } + notifyInfo(notificationTitle, message("feedback.submit_success"), project) } catch (e: Exception) { withContext(edtContext) { - Messages.showMessageDialog(panel.panel, message("feedback.submit_failed", e), message("feedback.submit_failed_title"), null) + Messages.showMessageDialog(message("feedback.submit_failed", e), message("feedback.submit_failed_title"), null) setOKButtonText(message("feedback.submit_button")) isOKActionEnabled = true } @@ -63,37 +178,77 @@ class FeedbackDialog(private val project: Project) : DialogWrapper(project), Cor } } - override fun doCancelAction() { - super.doCancelAction() - // kill any remaining coroutines - coroutineContext.cancel() - FeedbackTelemetry.result(project, result = Result.Cancelled) - } - - public override fun doValidate(): ValidationInfo? { - panel.sentiment ?: return ValidationInfo(message("feedback.validation.no_sentiment")) - val comment = panel.comment + override fun doValidate(): ValidationInfo? { + super.doValidate() + val comment = commentText return when { - comment.isNullOrEmpty() -> ValidationInfo(message("feedback.validation.empty_comment")) - comment.length >= SubmitFeedbackPanel.MAX_LENGTH -> ValidationInfo(message("feedback.validation.comment_too_long")) + comment.isEmpty() -> null + comment.length >= MAX_LENGTH -> ValidationInfo(message("feedback.validation.comment_too_long")) else -> null } } - override fun createCenterPanel() = panel.panel + private fun onTextAreaUpdate(commentText: String) { + this.commentText = commentText + val currentLength = this.commentText.length + val lengthText = message("feedback.limit.label", MAX_LENGTH - currentLength) + lengthLimitLabel.text = if (currentLength >= MAX_LENGTH) { + "$lengthText" + } else { + lengthText + } + } + + init { + super.init() + if (isAmazonQ()) { + title = message("feedback.title.amazonq") + } else { + title = message("feedback.title", productName) + } + setOKButtonText(message("feedback.submit_button")) + } + + override fun getHelpId(): String? = if (isToolkit()) { + HelpIds.AWS_TOOLKIT_GETTING_STARTED.id + } else if (isCodeWhisperer()) { + HelpIds.CODEWHISPERER_TOKEN.id + } else { + null + } + + private fun isCodeWhisperer(): Boolean = (productName == "CodeWhisperer") + private fun isAmazonQ(): Boolean = (productName == "Amazon Q") + private fun isToolkit(): Boolean = (productName == "Toolkit") @TestOnly - internal fun getViewForTesting(): SubmitFeedbackPanel = panel + fun getFeedbackDialog() = dialogPanel companion object { - private val feedbackTitle = message("feedback.title") + const val MAX_LENGTH = 2000 // backend restriction + private const val TOOLKIT_REPOSITORY_LINK = AwsToolkit.GITHUB_URL + private const val GITHUB_LINK_BASE = "$TOOLKIT_REPOSITORY_LINK/issues/new?body=" + private val toolkitMetadata = ClientMetadata.DEFAULT_METADATA.let { + """ + --- + Toolkit: ${it.productName} ${it.productVersion} + OS: ${it.os} ${it.osVersion} + IDE: ${it.parentProduct} ${it.parentProductVersion} + """.trimIndent() + } + } +} - fun getAction(project: Project) = - object : DumbAwareAction(feedbackTitle, message("feedback.description"), AwsIcons.Misc.SMILE_GREY) { - override fun actionPerformed(e: AnActionEvent) { - FeedbackDialog(project).showAndGet() - } - } +class ShowFeedbackDialogAction : DumbAwareAction(message("feedback.title", "Toolkit"), message("feedback.description"), AwsIcons.Misc.SMILE_GREY) { + override fun actionPerformed(e: AnActionEvent) { + runInEdt { + FeedbackDialog(e.getRequiredData(LangDataKeys.PROJECT)).show() + } + } + + override fun update(e: AnActionEvent) { + super.update(e) + e.presentation.icon = AwsIcons.Misc.SMILE_GREY } } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackInGateway.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackInGateway.kt new file mode 100644 index 0000000000..010918e4f7 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackInGateway.kt @@ -0,0 +1,18 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.ui.feedback + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.DefaultProjectFactory +import com.intellij.openapi.project.DumbAwareAction +import software.aws.toolkits.resources.message + +class SubmitFeedbackInGateway : DumbAwareAction(message("feedback.title", "Toolkit")) { + override fun actionPerformed(e: AnActionEvent) { + runInEdt { + FeedbackDialog(DefaultProjectFactory.getInstance().defaultProject).show() + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackPanel.form deleted file mode 100644 index 1eff89ef72..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackPanel.form +++ /dev/null @@ -1,114 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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 deleted file mode 100644 index 5b909a735d..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/feedback/SubmitFeedbackPanel.kt +++ /dev/null @@ -1,107 +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.feedback - -import com.intellij.openapi.ui.DialogWrapper -import com.intellij.ui.ColorUtil -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBRadioButton -import com.intellij.ui.components.JBScrollPane -import com.intellij.ui.components.JBTextArea -import com.intellij.util.IconUtil -import com.intellij.util.text.nullize -import icons.AwsIcons -import org.jetbrains.annotations.TestOnly -import software.amazon.awssdk.services.toolkittelemetry.model.Sentiment -import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata -import software.aws.toolkits.resources.message -import java.net.URLEncoder -import javax.swing.ButtonGroup -import javax.swing.JLabel -import javax.swing.JPanel - -class SubmitFeedbackPanel(initialSentiment: Sentiment? = null) { - private lateinit var rootPanel: JPanel - private lateinit var smileButton: JBRadioButton - private lateinit var sadButton: JBRadioButton - private lateinit var smileIcon: JLabel - private lateinit var sadIcon: JLabel - private lateinit var textArea: JBTextArea - private lateinit var textAreaPane: JBScrollPane - private lateinit var lengthLimitLabel: JLabel - private lateinit var githubLink: JBLabel - private lateinit var sentimentButtonGroup: ButtonGroup - - val panel: JPanel - get() = rootPanel - - val sentiment: Sentiment? - get() = when { - smileButton.isSelected -> Sentiment.POSITIVE - sadButton.isSelected -> Sentiment.NEGATIVE - else -> null - } - - var comment: String? - get() = textArea.text?.nullize(true) - @TestOnly - internal set(value) { textArea.text = value } - - private fun createUIComponents() { - textArea = JBTextArea(6, 70) - textArea.lineWrap = true - textAreaPane = JBScrollPane(textArea) - smileIcon = JLabel(IconUtil.scale(AwsIcons.Misc.SMILE, null, 3f)) - sadIcon = JLabel(IconUtil.scale(AwsIcons.Misc.FROWN, null, 3f)) - lengthLimitLabel = JLabel() - - textArea.document.addUndoableEditListener { onTextAreaUpdate() } - } - - init { - // runs after $$$setupUI$$$ - // null out placeholder text - smileIcon.text = null - sadIcon.text = null - - // select initial value - when (initialSentiment) { - Sentiment.POSITIVE -> smileButton.isSelected = true - Sentiment.NEGATIVE -> sadButton.isSelected = true - else -> sentimentButtonGroup.clearSelection() - } - - // update remaining character count - onTextAreaUpdate() - // make links work - githubLink.setCopyable(true) - } - - private fun onTextAreaUpdate() { - val currentLength = comment?.length ?: 0 - val lengthText = message("feedback.limit.label", MAX_LENGTH - currentLength) - lengthLimitLabel.text = if (currentLength >= MAX_LENGTH) { - "$lengthText" - } else { - lengthText - } - - val currentBody = comment ?: "" - githubLink.text = message("feedback.github.link", "$GITHUB_LINK_BASE${URLEncoder.encode("$currentBody\n\n$toolkitMetadata", Charsets.UTF_8.name())}") - } - - companion object { - const val MAX_LENGTH = 2000 // backend restriction - - private const val GITHUB_LINK_BASE = "https://github.com/aws/aws-toolkit-jetbrains/issues/new?body=" - private val toolkitMetadata = ClientMetadata.DEFAULT_METADATA.let { - """ - --- - Toolkit: ${it.productName} ${it.productVersion} - OS: ${it.os} ${it.osVersion} - IDE: ${it.parentProduct} ${it.parentProductVersion} - """.trimIndent() - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/AsyncTreeModel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/AsyncTreeModel.java deleted file mode 100644 index abe91df549..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/AsyncTreeModel.java +++ /dev/null @@ -1,984 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -package software.aws.toolkits.jetbrains.ui.tree; - -import com.intellij.openapi.Disposable; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.progress.ProcessCanceledException; -import com.intellij.openapi.util.Disposer; -import com.intellij.ui.LoadingNode; -import com.intellij.ui.tree.AbstractTreeWalker; -import com.intellij.ui.tree.ChildrenProvider; -import com.intellij.ui.tree.Identifiable; -import com.intellij.ui.tree.LeafState; -import com.intellij.ui.tree.Navigatable; -import com.intellij.ui.tree.Searchable; -import com.intellij.ui.tree.TreePathUtil; -import com.intellij.ui.tree.TreeVisitor; -import com.intellij.util.Consumer; -import com.intellij.util.containers.ContainerUtil; -import com.intellij.util.containers.SmartHashSet; -import com.intellij.util.ui.tree.AbstractTreeModel; -import com.intellij.util.ui.tree.TreeModelAdapter; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.concurrency.AsyncPromise; -import org.jetbrains.concurrency.Obsolescent; -import org.jetbrains.concurrency.Promise; - -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Deque; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Set; -import java.util.function.IntFunction; -import java.util.function.Predicate; -import java.util.function.Supplier; -import java.util.function.ToIntFunction; -import javax.swing.event.TreeModelEvent; -import javax.swing.event.TreeModelListener; -import javax.swing.tree.TreeModel; -import javax.swing.tree.TreePath; - -import static java.util.Collections.emptyList; -import static java.util.Collections.singletonList; -import static org.jetbrains.concurrency.Promises.rejectedPromise; - -/** - * Fork of JetBrain's intellij-community AsyncTreeModel allowing us to use a custom Invoker. - * The only changes to this from original version are removal of deprecated constructors, and changing of imports - */ -public final class AsyncTreeModel extends AbstractTreeModel implements Identifiable, Searchable, Navigatable, TreeVisitor.Acceptor { - private static final Logger LOG = Logger.getInstance(AsyncTreeModel.class); - private final Command.Processor processor; - private final Tree tree = new Tree(); - private final TreeModel model; - private final boolean showLoadingNode; - private final TreeModelListener listener = new TreeModelAdapter() { - @Override - protected void process(@NotNull TreeModelEvent event, @NotNull EventType type) { - TreePath path = event.getTreePath(); - if (path == null) { - // request a new root from model according to the specification - processor.process(new CmdGetRoot("Reload root", null)); - return; - } - Object object = path.getLastPathComponent(); - if (object == null) { - LOG.warn("unsupported path: " + path); - return; - } - if (path.getParentPath() == null && type == EventType.StructureChanged) { - // set a new root object according to the specification - processor.process(new CmdGetRoot("Update root", object)); - return; - } - onValidThread(() -> { - Node node = tree.map.get(object); - if (node == null) { - LOG.debug("ignore updating of nonexistent node: ", object); - } - else if (type == EventType.NodesChanged) { - // the object is already updated, so we should not start additional command to update - AsyncTreeModel.this.treeNodesChanged(event.getTreePath(), event.getChildIndices(), event.getChildren()); - } - else if (node.isLoadingRequired()) { - // update the object presentation only, if its children are not requested yet - AsyncTreeModel.this.treeNodesChanged(event.getTreePath(), null, null); - } - else if (type == EventType.NodesInserted) { - processor.process(new CmdGetChildren("Insert children", node, false)); - } - else if (type == EventType.NodesRemoved) { - processor.process(new CmdGetChildren("Remove children", node, false)); - } - else { - processor.process(new CmdGetChildren("Update children", node, true)); - } - }); - } - }; - - public AsyncTreeModel(@NotNull TreeModel model, boolean showLoadingNode, @NotNull Disposable parent) { - if (model instanceof Disposable) { - Disposer.register(this, (Disposable)model); - } - Invoker foreground = new Invoker.EDT(this); - Invoker background = foreground; - if (model instanceof InvokerSupplier) { - InvokerSupplier supplier = (InvokerSupplier)model; - background = supplier.getInvoker(); - } - this.processor = new Command.Processor(foreground, background); - this.model = model; - this.model.addTreeModelListener(listener); - this.showLoadingNode = showLoadingNode; - Disposer.register(parent, this); - } - - @Override - public void dispose() { - super.dispose(); - model.removeTreeModelListener(listener); - } - - @Override - public Object getUniqueID(@NotNull TreePath path) { - return model instanceof Identifiable ? ((Identifiable)model).getUniqueID(path) : null; - } - - @NotNull - @Override - public Promise getTreePath(Object object) { - if (disposed) return rejectedPromise(); - return resolve(model instanceof Searchable ? ((Searchable)model).getTreePath(object) : null); - } - - @NotNull - @Override - public Promise nextTreePath(@NotNull TreePath path, Object object) { - if (disposed) return rejectedPromise(); - return resolve(model instanceof Navigatable ? ((Navigatable)model).nextTreePath(path, object) : null); - } - - @NotNull - @Override - public Promise prevTreePath(@NotNull TreePath path, Object object) { - if (disposed) return rejectedPromise(); - return resolve(model instanceof Navigatable ? ((Navigatable)model).prevTreePath(path, object) : null); - } - - @NotNull - public Promise resolve(TreePath path) { - AsyncPromise async = new AsyncPromise<>(); - onValidThread(() -> resolve(async, path)); - return async; - } - - @NotNull - private Promise resolve(Promise promise) { - if (promise == null && isValidThread()) { - return rejectedPromise(); - } - AsyncPromise async = new AsyncPromise<>(); - if (promise == null) { - onValidThread(() -> async.setError("rejected")); - } - else { - promise.onError(onValidThread(async::setError)); - promise.onSuccess(onValidThread(path -> resolve(async, path))); - } - return async; - } - - private void resolve(@NotNull AsyncPromise async, TreePath path) { - LOG.debug("resolve path: ", path); - if (path == null) { - async.setError("path is null"); - return; - } - Object object = path.getLastPathComponent(); - if (object == null) { - async.setError("path is wrong"); - return; - } - accept(new TreeVisitor.ByTreePath<>(path, o -> o)) - .onProcessed(result -> { - if (result == null) { - async.setError("path not found"); - return; - } - async.setResult(result); - }); - } - - @Override - public Object getRoot() { - if (disposed) return null; - onValidThread(this::promiseRootEntry); - Node node = tree.root; - return node == null ? null : node.object; - } - - @Override - public Object getChild(Object object, int index) { - List children = getEntryChildren(object); - return 0 <= index && index < children.size() ? children.get(index).object : null; - } - - @Override - public int getChildCount(Object object) { - return getEntryChildren(object).size(); - } - - @Override - public boolean isLeaf(Object object) { - Node node = getEntry(object); - if (node == null) return true; - if (node.leafState == LeafState.ALWAYS) return true; - if (node.leafState == LeafState.NEVER) return false; - List children = node.children; - // leaf only if no children were loaded - return children != null && children.isEmpty(); - } - - @Override - public void valueForPathChanged(@NotNull TreePath path, Object value) { - processor.background.runOrInvokeLater(() -> model.valueForPathChanged(path, value)); - } - - @Override - public int getIndexOfChild(Object object, Object child) { - if (child != null) { - List children = getEntryChildren(object); - for (int i = 0; i < children.size(); i++) { - if (child.equals(children.get(i).object)) return i; - } - } - return -1; - } - - /** - * Starts visiting the tree structure with loading all needed children. - * - * @param visitor an object that controls visiting a tree structure - * @return a promise that will be resolved when visiting is finished - */ - @Override - @NotNull - public Promise accept(@NotNull TreeVisitor visitor) { - return accept(visitor, true); - } - - /** - * Starts visiting the tree structure. - * - * @param visitor an object that controls visiting a tree structure - * @param allowLoading load all needed children if {@code true} - * @return a promise that will be resolved when visiting is finished - */ - @NotNull - public Promise accept(@NotNull TreeVisitor visitor, boolean allowLoading) { - AbstractTreeWalker walker = new AbstractTreeWalker(visitor, node -> node.object) { - @Override - protected Collection getChildren(@NotNull Node node) { - if (node.leafState == LeafState.ALWAYS || !allowLoading) return node.getChildren(); - promiseChildren(node) - .onSuccess(parent -> setChildren(parent.getChildren())) - .onError(this::setError); - return null; - } - }; - if (allowLoading) { - // start visiting on the background thread to ensure that root node is already invalidated - processor.background.invokeLater(() -> onValidThread(() -> promiseRootEntry().onSuccess(walker::start).onError(walker::setError))); - } - else { - onValidThread(() -> walker.start(tree.root)); - } - return walker.promise(); - } - - /** - * @return {@code true} if this model is updating its structure - */ - public boolean isProcessing() { - if (processor.getTaskCount() > 0) return true; - ObsolescentCommand command = tree.queue.get(); - return command != null && command.isPending(); - } - - private boolean isValidThread() { - if (processor.foreground.isValidThread()) return true; - LOG.warn(new IllegalStateException("AsyncTreeModel is used from unexpected thread")); - return false; - } - - public void onValidThread(@NotNull Runnable runnable) { - processor.foreground.runOrInvokeLater(runnable); - } - - @NotNull - private java.util.function.Consumer onValidThread(@NotNull Consumer consumer) { - return value -> onValidThread(() -> consumer.consume(value)); - } - - @NotNull - private Promise promiseRootEntry() { - if (disposed) return rejectedPromise(); - return tree.queue.promise(processor, () -> new CmdGetRoot("Load root", null)); - } - - @NotNull - private Promise promiseChildren(@NotNull Node node) { - if (disposed) return rejectedPromise(); - return node.queue.promise(processor, () -> { - node.setLoading(!showLoadingNode ? null : new Node(new LoadingNode(), LeafState.ALWAYS)); - return new CmdGetChildren("Load children", node, false); - }); - } - - private Node getEntry(Object object) { - return disposed || object == null || !isValidThread() ? null : tree.map.get(object); - } - - @NotNull - private List getEntryChildren(Object object) { - Node node = getEntry(object); - if (node == null) return emptyList(); - if (node.isLoadingRequired()) promiseChildren(node); - return node.getChildren(); - } - - @NotNull - private TreeModelEvent createEvent(@NotNull TreePath path, Map map) { - if (map == null || map.isEmpty()) return new TreeModelEvent(this, path, null, null); - int i = 0; - int size = map.size(); - int[] indices = new int[size]; - Object[] children = new Object[size]; - for (Entry entry : map.entrySet()) { - indices[i] = entry.getValue(); - children[i] = entry.getKey(); - i++; - } - return new TreeModelEvent(this, path, indices, children); - } - - private void treeNodesChanged(@NotNull Node node, Map map) { - if (!listeners.isEmpty()) { - for (TreePath path : node.paths) { - listeners.treeNodesChanged(createEvent(path, map)); - } - } - } - - private void treeNodesInserted(@NotNull Node node, Map map) { - if (!listeners.isEmpty()) { - for (TreePath path : node.paths) { - listeners.treeNodesInserted(createEvent(path, map)); - } - } - } - - private void treeNodesRemoved(@NotNull Node node, Map map) { - if (!listeners.isEmpty()) { - for (TreePath path : node.paths) { - listeners.treeNodesRemoved(createEvent(path, map)); - } - } - } - - @NotNull - private static LinkedHashMap getIndices(@NotNull List children, @Nullable ToIntFunction function) { - LinkedHashMap map = new LinkedHashMap<>(); - for (int i = 0; i < children.size(); i++) { - Node child = children.get(i); - if (map.containsKey(child.object)) { - LOG.warn("ignore duplicated " + (function == null ? "old" : "new") + " child at " + i); - } - else { - map.put(child.object, function == null ? i : function.applyAsInt(child)); - } - } - return map; - } - - private static int getIntersectionCount(@NotNull Map indices, @NotNull Iterable objects) { - int count = 0; - int last = -1; - for (Object object : objects) { - Integer index = indices.get(object); - if (index != null && last < index.intValue()) { - last = index; - count++; - } - } - return count; - } - - @NotNull - private static List getIntersection(@NotNull Map indices, @NotNull Iterable objects) { - List list = new ArrayList<>(indices.size()); - int last = -1; - for (Object object : objects) { - Integer index = indices.get(object); - if (index != null && last < index.intValue()) { - last = index; - list.add(object); - } - } - return list; - } - - @NotNull - private static List getIntersection(@NotNull Map removed, @NotNull Map inserted) { - if (removed.isEmpty() || inserted.isEmpty()) return emptyList(); - int countOne = getIntersectionCount(removed, inserted.keySet()); - int countTwo = getIntersectionCount(inserted, removed.keySet()); - if (countOne > countTwo) return getIntersection(removed, inserted.keySet()); - if (countTwo > 0) return getIntersection(inserted, removed.keySet()); - return emptyList(); - } - - @NotNull - private LeafState getLeafState(Object object) { - LOG.assertTrue(processor.background.isValidThread()); - if (object instanceof LeafState.Supplier) { - LeafState.Supplier supplier = (LeafState.Supplier)object; - LeafState leafState = supplier.getLeafState(); - if (LeafState.DEFAULT != leafState) return leafState; - } - return model.isLeaf(object) ? LeafState.ALWAYS : LeafState.NEVER; - } - - private abstract static class ObsolescentCommand implements Obsolescent, Command { - final AsyncPromise promise = new AsyncPromise<>(); - final String name; - final Object object; - volatile boolean started; - - ObsolescentCommand(@NotNull String name, Object object) { - this.name = name; - this.object = object; - LOG.debug("create command: ", this); - } - - abstract Node getNode(Object object); - - abstract void setNode(Node node); - - boolean isPending() { - return Promise.State.PENDING == promise.getState(); - } - - @Override - public String toString() { - return object == null ? name : name + ": " + object; - } - - @Override - public Node get() { - started = true; - if (isObsolete()) { - LOG.debug("obsolete command: ", this); - return null; - } - else { - LOG.debug("background command: ", this); - return getNode(object); - } - } - - @Override - public void accept(Node node) { - if (isObsolete()) { - LOG.debug("obsolete command: ", this); - } - else { - LOG.debug("foreground command: ", this); - setNode(node); - } - } - } - - private final class CmdGetRoot extends ObsolescentCommand { - private CmdGetRoot(@NotNull String name, Object object) { - super(name, object); - tree.queue.add(this, old -> old.started || old.object != object); - } - - @Override - public boolean isObsolete() { - return disposed || this != tree.queue.get(); - } - - @Override - Node getNode(Object object) { - if (object == null) object = model.getRoot(); - if (object == null || isObsolete()) return null; - return new Node(object, getLeafState(object)); - } - - @Override - void setNode(Node loaded) { - Node root = tree.root; - if (root == null && loaded == null) { - LOG.debug("no root"); - tree.queue.done(this, null); - return; - } - - if (root != null && loaded != null && root.object.equals(loaded.object)) { - tree.fixEqualButNotSame(root, loaded.object); - LOG.debug("same root: ", root.object); - if (!root.isLoadingRequired()) processor.process(new CmdGetChildren("Update root children", root, true)); - tree.queue.done(this, root); - return; - } - - if (root != null) { - root.removeMapping(null, tree); - } - if (!tree.map.isEmpty()) { - tree.map.values().forEach(node -> { - node.queue.close(); - LOG.warn("remove staled node: " + node.object); - }); - tree.map.clear(); - } - - tree.root = loaded; - if (loaded != null) { - tree.map.put(loaded.object, loaded); - TreePath path = new TreePath(loaded.object); - loaded.insertPath(path); - treeStructureChanged(path, null, null); - LOG.debug("new root: ", loaded.object); - tree.queue.done(this, loaded); - } - else { - treeStructureChanged(null, null, null); - LOG.debug("root removed"); - tree.queue.done(this, null); - } - } - } - - private final class CmdGetChildren extends ObsolescentCommand { - private final Node node; - private volatile boolean deep; - - CmdGetChildren(@NotNull String name, @NotNull Node node, boolean deep) { - super(name, node.object); - this.node = node; - if (deep) this.deep = true; - node.queue.add(this, old -> { - if (!deep && old.deep && old.isPending()) this.deep = true; - return true; - }); - } - - @Override - public boolean isObsolete() { - return disposed || this != node.queue.get(); - } - - @Override - Node getNode(Object object) { - Node loaded = new Node(object, getLeafState(object)); - if (loaded.leafState == LeafState.ALWAYS || isObsolete()) return loaded; - - if (model instanceof ChildrenProvider) { - //noinspection unchecked - ChildrenProvider provider = (ChildrenProvider)model; - List children = provider.getChildren(object); - if (children == null) throw new ProcessCanceledException(); // cancel this command - loaded.children = load(children.size(), index -> children.get(index)); - } - else { - loaded.children = load(model.getChildCount(object), index -> model.getChild(object, index)); - } - return loaded; - } - - @Nullable - private List load(int count, @NotNull IntFunction function) { - if (count < 0) LOG.warn("illegal child count: " + count); - if (count <= 0) return emptyList(); - - SmartHashSet set = new SmartHashSet<>(count); - List children = new ArrayList<>(count); - for (int i = 0; i < count; i++) { - if (isObsolete()) return null; - Object child = function.apply(i); - if (child == null) { - LOG.warn("ignore null child at " + i); - } - else if (!set.add(child)) { - LOG.warn("ignore duplicated child at " + i + ": " + child); - } - else { - if (isObsolete()) return null; - children.add(new Node(child, getLeafState(child))); - } - } - return children; - } - - @Override - void setNode(Node loaded) { - if (loaded == null || loaded.isLoadingRequired()) { - LOG.debug("cancelled command: ", this); - return; - } - if (node != tree.map.get(loaded.object)) { - node.queue.close(); - LOG.warn("ignore removed node: " + node.object); - return; - } - List oldChildren = node.getChildren(); - List newChildren = loaded.getChildren(); - if (oldChildren.isEmpty() && newChildren.isEmpty()) { - node.setLeafState(loaded.leafState); - treeNodesChanged(node, null); - LOG.debug("no children: ", node.object); - node.queue.done(this, node); - return; - } - - LinkedHashMap removed = getIndices(oldChildren, null); - if (newChildren.isEmpty()) { - oldChildren.forEach(child -> child.removeMapping(node, tree)); - node.setLeafState(loaded.leafState); - treeNodesRemoved(node, removed); - LOG.debug("children removed: ", node.object); - node.queue.done(this, node); - return; - } - - // remove duplicated nodes during indices calculation - ArrayList list = new ArrayList<>(newChildren.size()); - SmartHashSet reload = new SmartHashSet<>(); - LinkedHashMap inserted = getIndices(newChildren, child -> { - Node found = tree.map.get(child.object); - if (found == null) { - tree.map.put(child.object, child); - list.add(child); - } - else { - tree.fixEqualButNotSame(found, child.object); - list.add(found); - if (found.leafState == LeafState.ALWAYS) { - if (child.leafState != LeafState.ALWAYS) { - found.setLeafState(child.leafState); // mark existing leaf node as not a leaf - reload.add(found.object); // and request to load its children - } - } - else if (child.leafState == LeafState.ALWAYS || !found.isLoadingRequired() && (deep || !removed.containsKey(found.object))) { - reload.add(found.object); // request to load children of existing node - } - } - return list.size() - 1; - }); - newChildren = list; - - if (oldChildren.isEmpty()) { - newChildren.forEach(child -> child.insertMapping(node)); - node.setChildren(newChildren); - treeNodesInserted(node, inserted); - LOG.debug("children inserted: ", node.object); - node.queue.done(this, node); - return; - } - - LinkedHashMap contained = new LinkedHashMap<>(); - for (Object object : getIntersection(removed, inserted)) { - Integer oldIndex = removed.remove(object); - if (oldIndex == null) { - LOG.warn("intersection failed"); - } - Integer newIndex = inserted.remove(object); - if (newIndex == null) { - LOG.warn("intersection failed"); - } - else { - contained.put(object, newIndex); - } - } - - for (Node child : newChildren) { - if (!removed.containsKey(child.object) && inserted.containsKey(child.object)) { - child.insertMapping(node); - } - } - - for (Node child : oldChildren) { - if (removed.containsKey(child.object) && !inserted.containsKey(child.object)) { - child.removeMapping(node, tree); - } - } - - node.setChildren(newChildren); - if (!removed.isEmpty()) treeNodesRemoved(node, removed); - if (!inserted.isEmpty()) treeNodesInserted(node, inserted); - if (!contained.isEmpty()) treeNodesChanged(node, contained); - if (removed.isEmpty() && inserted.isEmpty()) treeNodesChanged(node, null); - LOG.debug("children changed: ", node.object); - - if (!reload.isEmpty()) { - for (Node child : newChildren) { - if (reload.contains(child.object)) { - processor.process(new CmdGetChildren("Update children recursively", child, true)); - } - } - } - node.queue.done(this, node); - } - } - - private static final class CommandQueue { - private final Deque deque = new ArrayDeque<>(); - private volatile boolean closed; - - T get() { - synchronized (deque) { - return deque.peekFirst(); - } - } - - @NotNull - Promise promise(@NotNull Command.Processor processor, @NotNull Supplier supplier) { - T command; - synchronized (deque) { - command = deque.peekFirst(); - if (command != null) return command.promise; - command = supplier.get(); - } - processor.process(command); - return command.promise; - } - - void add(@NotNull T command, @NotNull Predicate predicate) { - synchronized (deque) { - if (closed) return; - T old = deque.peekFirst(); - boolean add = old == null || predicate.test(old); - if (add) deque.addFirst(command); - } - } - - void done(@NotNull T command, Node node) { - Iterable> promises; - synchronized (deque) { - if (closed) return; - if (!deque.contains(command)) return; - promises = getPromises(command); - if (deque.isEmpty()) deque.addLast(command); - } - promises.forEach(promise -> promise.setResult(node)); - } - - void close() { - Iterable> promises; - synchronized (deque) { - if (closed) return; - closed = true; - if (deque.isEmpty()) return; - promises = getPromises(null); - } - promises.forEach(promise -> promise.setError("cancel loading")); - } - - @NotNull - private Iterable> getPromises(T command) { - ArrayList> list = new ArrayList<>(); - while (true) { - T last = deque.pollLast(); - if (last == null) break; - if (last.isPending()) list.add(last.promise); - if (last.equals(command)) break; - } - return list; - } - } - - private static final class Tree { - private final CommandQueue queue = new CommandQueue<>(); - private final Map map = new HashMap<>(); - private volatile Node root; - - private void removeEmpty(@NotNull Node child) { - child.forEachChildExceptLoading(this::removeEmpty); - if (child.paths.isEmpty()) { - child.queue.close(); - Node node = map.remove(child.object); - if (node != child) { - LOG.warn("invalid node: " + child.object); - if (node != null) map.put(node.object, node); - } - } - } - - private void fixEqualButNotSame(@NotNull Node node, @NotNull Object object) { - if (object == node.object) return; - // always use new instance of user's object, because - // some trees provide equal nodes with different behavior - map.remove(node.object); - node.updatePaths(node.object, object); - node.object = object; - map.put(object, node); // update key - } - } - - private static final class Node { - private final CommandQueue queue = new CommandQueue<>(); - private final Set paths = new SmartHashSet<>(); - private volatile Object object; - private volatile LeafState leafState; - @Nullable - private volatile List children; - private volatile Node loading; - - private Node(@NotNull Object object, @NotNull LeafState leafState) { - this.object = object; - this.leafState = leafState; - } - - private void setLeafState(@NotNull LeafState leafState) { - this.leafState = leafState; - this.children = leafState == LeafState.ALWAYS ? null : emptyList(); - this.loading = null; - } - - private void setChildren(@NotNull List children) { - this.leafState = LeafState.NEVER; - this.children = children; - this.loading = null; - } - - private void setLoading(Node loading) { - this.leafState = LeafState.NEVER; - this.children = loading != null ? singletonList(loading) : emptyList(); - this.loading = loading; - } - - private boolean isLoadingRequired() { - return leafState != LeafState.ALWAYS && children == null; - } - - @NotNull - private List getChildren() { - List list = children; - return list != null ? list : emptyList(); - } - - private void forEachChildExceptLoading(Consumer consumer) { - for (Node node : getChildren()) { - if (node != loading) consumer.consume(node); - } - } - - private void insertPath(@NotNull TreePath path) { - if (!paths.add(path)) { - LOG.warn("node is already attached to " + path); - } - forEachChildExceptLoading(child -> child.insertPath(path.pathByAddingChild(child.object))); - } - - private void insertMapping(Node parent) { - if (parent == null) { - insertPath(new TreePath(object)); - } - else if (parent.loading == this) { - LOG.warn("insert loading node unexpectedly"); - } - else if (parent.paths.isEmpty()) { - LOG.warn("insert to invalid parent"); - } - else { - parent.paths.forEach(path -> insertPath(path.pathByAddingChild(object))); - } - } - - private void removePath(@NotNull TreePath path) { - if (!paths.remove(path)) { - LOG.warn("node is not attached to " + path); - } - forEachChildExceptLoading(child -> child.removePath(path.pathByAddingChild(child.object))); - } - - private void removeMapping(Node parent, @NotNull Tree tree) { - if (parent == null) { - removePath(new TreePath(object)); - tree.removeEmpty(this); - } - else if (parent.loading == this) { - parent.loading = null; - } - else if (parent.paths.isEmpty()) { - LOG.warn("remove from invalid parent"); - } - else { - parent.paths.forEach(path -> removePath(path.pathByAddingChild(object))); - tree.removeEmpty(this); - } - } - - private void updatePaths(@NotNull Object oldObject, @NotNull Object newObject) { - if (paths.stream().anyMatch(path -> contains(path, oldObject))) { - // replace instance of user's object in all internal maps to avoid memory leaks - List updated = ContainerUtil.map(paths, path -> update(path, oldObject, newObject)); - paths.clear(); - paths.addAll(updated); - forEachChildExceptLoading(child -> child.updatePaths(oldObject, newObject)); - } - } - - @NotNull - private static TreePath update(@NotNull TreePath path, @NotNull Object oldObject, @NotNull Object newObject) { - if (!contains(path, oldObject)) return path; - LOG.debug("update path: ", path); - Object[] objects = TreePathUtil.convertTreePathToArray(path); - for (int i = 0; i < objects.length; i++) { - if (oldObject == objects[i]) objects[i] = newObject; - } - return TreePathUtil.convertArrayToTreePath(objects); - } - - private static boolean contains(@NotNull TreePath path, @NotNull Object object) { - while (object != path.getLastPathComponent()) { - path = path.getParentPath(); - if (path == null) return false; - } - return true; - } - } - - @Override - protected void treeStructureChanged(TreePath path, int[] indices, Object[] children) { - try { - super.treeStructureChanged(path, indices, children); - } - catch (Throwable throwable) { - LOG.error("custom model: " + model, throwable); - } - } - - @Override - protected void treeNodesChanged(TreePath path, int[] indices, Object[] children) { - try { - super.treeNodesChanged(path, indices, children); - } - catch (Throwable throwable) { - LOG.error("custom model: " + model, throwable); - } - } - - @Override - protected void treeNodesInserted(TreePath path, int[] indices, Object[] children) { - try { - super.treeNodesInserted(path, indices, children); - } - catch (Throwable throwable) { - LOG.error("custom model: " + model, throwable); - } - } - - @Override - protected void treeNodesRemoved(TreePath path, int[] indices, Object[] children) { - try { - super.treeNodesRemoved(path, indices, children); - } - catch (Throwable throwable) { - LOG.error("custom model: " + model, throwable); - } - } -} \ No newline at end of file diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/Command.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/Command.java deleted file mode 100644 index 1dfb8d2b85..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/Command.java +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -/* - * Copyright 2000-2017 JetBrains s.r.o. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package software.aws.toolkits.jetbrains.ui.tree; - -import org.jetbrains.annotations.NotNull; - -import java.util.function.Consumer; -import java.util.function.Supplier; - -/** - * Coped over from JetBrains intellij-community to make the imports and casts align correctly - */ -public interface Command extends Supplier, Consumer { - final class Processor { - public final Invoker foreground; - public final Invoker background; - - public Processor(@NotNull Invoker foreground, @NotNull Invoker background) { - this.foreground = foreground; - this.background = background; - } - - /** - * Lets the specified consumer to accept the given value on the foreground thread. - */ - public void consume(Consumer consumer, T value) { - if (consumer != null) foreground.runOrInvokeLater(() -> consumer.accept(value)); - } - - /** - * Lets the specified command to produce a value on the background thread - * and to accept this value on the foreground thread. - */ - public void process(Command command) { - if (command != null) background.runOrInvokeLater(() -> consume(command, command.get())); - } - - /** - * Lets the specified supplier to produce a value on the background thread - * and the specified consumer to accept this value on the foreground thread. - */ - public void process(Supplier supplier, Consumer consumer) { - if (supplier != null) { - background.runOrInvokeLater(() -> consume(consumer, supplier.get())); - } - else { - consume(consumer, null); - } - } - - /** - * Returns a workload of both task queues. - * - * @return amount of tasks, which are executing or waiting for execution - */ - public int getTaskCount() { - if (foreground == background) return background.getTaskCount(); - return foreground.getTaskCount() + background.getTaskCount(); - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/Invoker.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/Invoker.java deleted file mode 100644 index a2fa64e094..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/Invoker.java +++ /dev/null @@ -1,323 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -package software.aws.toolkits.jetbrains.ui.tree; - -import com.intellij.openapi.Disposable; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.progress.ProcessCanceledException; -import com.intellij.openapi.progress.ProgressManager; -import com.intellij.openapi.progress.util.ProgressIndicatorBase; -import com.intellij.openapi.project.IndexNotReadyException; -import com.intellij.util.concurrency.AppExecutorUtil; -import com.intellij.util.concurrency.EdtExecutorService; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.concurrency.AsyncPromise; -import org.jetbrains.concurrency.CancellablePromise; -import org.jetbrains.concurrency.Obsolescent; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.atomic.AtomicInteger; - -import static com.intellij.openapi.util.Disposer.register; -import static java.awt.EventQueue.isDispatchThread; -import static java.util.concurrent.TimeUnit.MILLISECONDS; - -/** - * Fork of JB's Invoker that eliminates the read action in the invokeSafely method. We should migrate back if/when - * they remove that limitation - */ -public abstract class Invoker implements Disposable { - private static final int THRESHOLD = Integer.MAX_VALUE; - private static final Logger LOG = Logger.getInstance(Invoker.class); - private static final AtomicInteger UID = new AtomicInteger(); - private final ConcurrentHashMap, ProgressIndicatorBase> indicators = new ConcurrentHashMap<>(); - private final AtomicInteger count = new AtomicInteger(); - private final String description; - private volatile boolean disposed; - - private Invoker(@NotNull String prefix, @NotNull Disposable parent) { - description = UID.getAndIncrement() + ".Invoker." + prefix + ": " + parent; - register(parent, this); - } - - @Override - public String toString() { - return description; - } - - @Override - public void dispose() { - disposed = true; - while (!indicators.isEmpty()) { - indicators.keySet().forEach(AsyncPromise::cancel); - } - } - - /** - * Returns {@code true} if the current thread allows to process a task. - * - * @return {@code true} if the current thread is valid, or {@code false} otherwise - */ - public abstract boolean isValidThread(); - - /** - * Invokes the specified task asynchronously on the valid thread. - * Even if this method is called from the valid thread - * the specified task will still be deferred - * until all pending events have been processed. - * - * @param task a task to execute asynchronously on the valid thread - * @return an object to control task processing - */ - @NotNull - public final CancellablePromise invokeLater(@NotNull Runnable task) { - return invokeLater(task, 0); - } - - /** - * Invokes the specified task on the valid thread after the specified delay. - * - * @param task a task to execute asynchronously on the valid thread - * @param delay milliseconds for the initial delay - * @return an object to control task processing - */ - @NotNull - public final CancellablePromise invokeLater(@NotNull Runnable task, int delay) { - if (delay < 0) throw new IllegalArgumentException("delay must be non-negative: " + delay); - AsyncPromise promise = new AsyncPromise<>(); - if (canInvoke(task, promise)) { - count.incrementAndGet(); - offer(() -> invokeSafely(task, promise, 0), delay); - } - return promise; - } - - /** - * Invokes the specified task immediately if the current thread is valid, - * or asynchronously after all pending tasks have been processed. - * - * @param task a task to execute on the valid thread - * @return an object to control task processing - */ - @NotNull - public final CancellablePromise runOrInvokeLater(@NotNull Runnable task) { - if (isValidThread()) { - count.incrementAndGet(); - AsyncPromise promise = new AsyncPromise<>(); - invokeSafely(task, promise, 0); - return promise; - } - return invokeLater(task); - } - - /** - * @deprecated use {@link #runOrInvokeLater(Runnable)} - */ - @Deprecated - public final void invokeLaterIfNeeded(@NotNull Runnable task) { - runOrInvokeLater(task); - } - - /** - * Returns a workload of the task queue. - * - * @return amount of tasks, which are executing or waiting for execution - */ - public final int getTaskCount() { - return disposed ? 0 : count.get(); - } - - abstract void offer(@NotNull Runnable runnable, int delay); - - /** - * @param task a task to execute on the valid thread - * @param promise an object to control task processing - * @param attempt an attempt to run the specified task - */ - private void invokeSafely(@NotNull Runnable task, @NotNull AsyncPromise promise, int attempt) { - - // NOTE: This is the spot where we differ from JetBrains!!!! - // Original code: https://github.com/JetBrains/intellij-community/blob/351994570e5f6e5d8cc57e8febe217d933a13a09/platform/platform-impl/src/com/intellij/util/concurrency/Invoker.java#L138 - - try { - if (canInvoke(task, promise)) { - ProgressManager.getInstance().runProcess(task, indicator(promise)); - promise.setResult(null); - } - } - catch (ProcessCanceledException | IndexNotReadyException exception) { - if (canRestart(task, promise, attempt)) { - count.incrementAndGet(); - int nextAttempt = attempt + 1; - offer(() -> invokeSafely(task, promise, nextAttempt), 10); - LOG.debug("Task is restarted"); - } - } - catch (Throwable throwable) { - try { - LOG.error(throwable); - } - finally { - promise.setError(throwable); - } - } - finally { - count.decrementAndGet(); - } - } - - /** - * @param task a task to execute on the valid thread - * @param promise an object to control task processing - * @param attempt an attempt to run the specified task - * @return {@code false} if too many attempts to run the task, - * or if the given promise is already done or cancelled, - * or if the current invoker is disposed, - * or if the specified task is obsolete - */ - private boolean canRestart(@NotNull Runnable task, @NotNull AsyncPromise promise, int attempt) { - LOG.debug("Task is canceled"); - if (attempt < THRESHOLD) return canInvoke(task, promise); - LOG.warn("Task is always canceled: " + task); - promise.setError("timeout"); - return false; - } - - /** - * @param task a task to execute on the valid thread - * @param promise an object to control task processing - * @return {@code false} if the given promise is already done or cancelled, - * or if the current invoker is disposed, - * or if the specified task is obsolete - */ - private boolean canInvoke(@NotNull Runnable task, @NotNull AsyncPromise promise) { - if (promise.isDone()) { - LOG.debug("Promise is cancelled: ", promise.isCancelled()); - return false; - } - if (disposed) { - LOG.debug("Invoker is disposed"); - promise.setError("disposed"); - return false; - } - if (task instanceof Obsolescent) { - Obsolescent obsolescent = (Obsolescent)task; - if (obsolescent.isObsolete()) { - LOG.debug("Task is obsolete"); - promise.setError("obsolete"); - return false; - } - } - return true; - } - - @NotNull - private ProgressIndicatorBase indicator(@NotNull AsyncPromise promise) { - ProgressIndicatorBase indicator = indicators.get(promise); - if (indicator == null) { - indicator = new ProgressIndicatorBase(true); - ProgressIndicatorBase old = indicators.put(promise, indicator); - if (old != null) LOG.error("the same task is running in parallel"); - promise.onProcessed(done -> indicators.remove(promise).cancel()); - } - return indicator; - } - - /** - * This class is the {@code Invoker} in the Event Dispatch Thread, - * which is the only one valid thread for this invoker. - */ - public static final class EDT extends Invoker { - public EDT(@NotNull Disposable parent) { - super("EDT", parent); - } - - @Override - public boolean isValidThread() { - return isDispatchThread(); - } - - @Override - void offer(@NotNull Runnable runnable, int delay) { - if (delay > 0) { - EdtExecutorService.getScheduledExecutorInstance().schedule(runnable, delay, MILLISECONDS); - } - else { - EdtExecutorService.getInstance().execute(runnable); - } - } - } - - /** - * This class is the {@code Invoker} in a background thread pool. - * Every thread is valid for this invoker except the EDT. - * It allows to run background tasks in parallel, - * but requires a good synchronization. - */ - public static final class BackgroundPool extends Invoker { - public BackgroundPool(@NotNull Disposable parent) { - super("Background.Pool", parent); - } - - @Override - public boolean isValidThread() { - return !isDispatchThread(); - } - - @Override - void offer(@NotNull Runnable runnable, int delay) { - schedule(AppExecutorUtil.getAppScheduledExecutorService(), runnable, delay); - } - } - - /** - * This class is the {@code Invoker} in a single background thread. - * This invoker does not need additional synchronization. - */ - public static final class BackgroundThread extends Invoker { - private final ScheduledExecutorService executor; - private volatile Thread thread; - - public BackgroundThread(@NotNull Disposable parent) { - super("Background.Thread", parent); - executor = AppExecutorUtil.createBoundedScheduledExecutorService(toString(), 1); - } - - @Override - public void dispose() { - super.dispose(); - executor.shutdown(); - } - - @Override - public boolean isValidThread() { - return thread == Thread.currentThread(); - } - - @Override - void offer(@NotNull Runnable runnable, int delay) { - schedule(executor, () -> { - if (thread != null) LOG.error("unexpected thread: " + thread); - try { - thread = Thread.currentThread(); - runnable.run(); // may throw an assertion error - } - finally { - thread = null; - } - }, delay); - } - } - - private static void schedule(ScheduledExecutorService executor, Runnable runnable, int delay) { - if (delay > 0) { - executor.schedule(runnable, delay, MILLISECONDS); - } - else { - executor.execute(runnable); - } - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/InvokerSupplier.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/InvokerSupplier.java deleted file mode 100644 index cb1a046ffd..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/InvokerSupplier.java +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -/* - * Copyright 2000-2017 JetBrains s.r.o. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package software.aws.toolkits.jetbrains.ui.tree; - -import org.jetbrains.annotations.NotNull; - -/** - * Coped over from JetBrains intellij-community to make the imports and casts align correctly - */ -public interface InvokerSupplier { - /** - * @return preferable invoker to be used to access the supplier - */ - @NotNull - Invoker getInvoker(); -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/Reference.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/Reference.java deleted file mode 100644 index 97a2eb5ab9..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/Reference.java +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -/* - * Copyright 2000-2017 JetBrains s.r.o. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package software.aws.toolkits.jetbrains.ui.tree; - -final class Reference { - private volatile boolean valid; - private volatile T value; - - boolean isValid() { - return valid; - } - - void invalidate() { - valid = false; - } - - T set(T value) { - T old = this.value; - this.value = value; - valid = true; - return old; - } - - T get() { - return value; - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/StructureTreeModel.java b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/StructureTreeModel.java deleted file mode 100644 index 2c6fbba5e7..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/tree/StructureTreeModel.java +++ /dev/null @@ -1,636 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -package software.aws.toolkits.jetbrains.ui.tree; - -import com.intellij.ide.util.treeView.AbstractTreeNode; -import com.intellij.ide.util.treeView.AbstractTreeStructure; -import com.intellij.ide.util.treeView.NodeDescriptor; -import com.intellij.ide.util.treeView.ValidateableNode; -import com.intellij.openapi.Disposable; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.util.Disposer; -import com.intellij.ui.tree.ChildrenProvider; -import com.intellij.ui.tree.LeafState; -import com.intellij.ui.tree.TreePathUtil; -import com.intellij.ui.tree.TreeVisitor; -import com.intellij.util.ui.tree.AbstractTreeModel; -import com.intellij.util.ui.tree.TreeUtil; -import org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.concurrency.AsyncPromise; -import org.jetbrains.concurrency.Promise; - -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.Enumeration; -import java.util.HashMap; -import java.util.List; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.function.Function; -import javax.swing.JTree; -import javax.swing.tree.DefaultMutableTreeNode; -import javax.swing.tree.MutableTreeNode; -import javax.swing.tree.TreeNode; -import javax.swing.tree.TreePath; - -import static java.util.Collections.emptyList; -import static java.util.Collections.enumeration; -import static java.util.Collections.unmodifiableList; -import static org.jetbrains.concurrency.Promises.rejectedPromise; - -/** - * @author Sergey.Malenkov - */ -public class StructureTreeModel - extends AbstractTreeModel implements Disposable, InvokerSupplier, ChildrenProvider { - - private static final TreePath ROOT_INVALIDATED = new TreePath(new DefaultMutableTreeNode()); - private static final Logger LOG = Logger.getInstance(StructureTreeModel.class); - private final Reference root = new Reference<>(); - private final String description; - private final Invoker invoker; - private final Structure structure; - private volatile Comparator comparator; - - private StructureTreeModel(@NotNull Structure structure, boolean background, @NotNull Disposable parentDisposable) { - this.structure = structure; - description = format(structure.toString()); - invoker = background - ? new Invoker.BackgroundThread(this) - : new Invoker.EDT(this); - Disposer.register(parentDisposable, this); - } - - /** - * @deprecated Please use {@link #StructureTreeModel(AbstractTreeStructure, Disposable)} - */ - @Deprecated - @ApiStatus.ScheduledForRemoval(inVersion = "2019.3") - public StructureTreeModel(@NotNull Structure structure) { - this(structure, Disposer.newDisposable()); - } - - /** - * @deprecated Please use {@link #StructureTreeModel(AbstractTreeStructure, Comparator, Disposable)} - */ - @Deprecated - @ApiStatus.ScheduledForRemoval(inVersion = "2019.3") - public StructureTreeModel(@NotNull Structure structure, - @NotNull Comparator comparator) { - this(structure, comparator, Disposer.newDisposable()); - } - - public StructureTreeModel(@NotNull Structure structure, @NotNull Disposable parentDisposable) { - this(structure, true, parentDisposable); - } - - public StructureTreeModel(@NotNull Structure structure, - @NotNull Comparator comparator, - @NotNull Disposable parentDisposable) { - this(structure, parentDisposable); - this.comparator = wrapToNodeComparator(comparator); - } - - @NotNull - private static Comparator wrapToNodeComparator(@NotNull Comparator comparator) { - return (node1, node2) -> comparator.compare(node1.getDescriptor(), node2.getDescriptor()); - } - - /** - * @param comparator a comparator to sort tree nodes or {@code null} to disable sorting - */ - public final void setComparator(@Nullable Comparator comparator) { - if (disposed) return; - if (comparator != null) { - this.comparator = wrapToNodeComparator(comparator); - invalidate(); - } - else if (this.comparator != null) { - this.comparator = null; - invalidate(); - } - } - - @Override - public void dispose() { - comparator = null; - Node node = root.set(null); - if (node != null) node.dispose(); - // notify tree to clean up inner structures - treeStructureChanged(null, null, null); - super.dispose(); // remove listeners after notification - } - - @NotNull - @Override - public final Invoker getInvoker() { - return invoker; - } - - private boolean isValidThread() { - if (invoker.isValidThread()) return true; - LOG.warn(new IllegalStateException("StructureTreeModel is used from unexpected thread")); - return false; - } - - /** - * @param function a function to process current structure on a valid thread - * @return a promise that will be succeed if the specified function returns non-null value - */ - @NotNull - private Promise onValidThread(@NotNull Function function) { - AsyncPromise promise = new AsyncPromise<>(); - invoker.runOrInvokeLater(() -> { - if (!disposed) { - Result result = function.apply(structure); - if (result != null) promise.setResult(result); - } - if (!promise.isDone()) promise.cancel(); - }).onError(promise::setError); - return promise; - } - - /** - * @param path a path to the node - * @param function a function to process corresponding node on a valid thread - * @return a promise that will be succeed if the specified function returns non-null value - */ - @NotNull - private Promise onValidThread(@NotNull TreePath path, @NotNull Function function) { - Object component = path.getLastPathComponent(); - if (component instanceof Node) { - Node node = (Node)component; - return onValidThread(structure -> disposed || isNodeRemoved(node) ? null : function.apply(node)); - } - return rejectedPromise("unexpected node: " + component); - } - - /** - * @param element an element of the internal tree structure - * @param function a function to process corresponding node on a valid thread - * @return a promise that will be succeed if the specified function returns non-null value - */ - @NotNull - private Promise onValidThread(@NotNull Object element, @NotNull Function function) { - return onValidThread(structure -> { - Node node = root.get(); - if (node == null) return null; - if (node.matches(element)) return function.apply(node); - ArrayDeque stack = new ArrayDeque<>(); - for (Object e = element; e != null; e = structure.getParentElement(e)) stack.push(e); - if (!node.matches(stack.pop())) return null; - while (!stack.isEmpty()) { - node = node.findChild(stack.pop()); - if (node == null) return null; - } - return function.apply(node); - }); - } - - /** - * Invalidates all nodes and notifies Swing model that a whole tree hierarchy is changed. - */ - @NotNull - public final Promise invalidate() { - return onValidThread(structure -> invalidateInternal(null, true)); - } - - /** - * Invalidates specified nodes and notifies Swing model that these nodes are changed. - * - * @param path a path to the node to invalidate - * @param structure {@code true} means that all child nodes must be invalidated; - * {@code false} means that only the node specified by {@code path} must be updated - * @return a promise that will be succeed if path to invalidate is found - * @see #invalidate(Object, boolean) - */ - @NotNull - public final Promise invalidate(@NotNull TreePath path, boolean structure) { - return onValidThread(path, node -> invalidateInternal(node, structure)); - } - - /** - * Invalidates specified nodes and notifies Swing model that these nodes are changed. - * This method does not bother Swing model if the corresponding nodes have not yet been loaded. - * - * @param element an element of the internal tree structure - * @param structure {@code true} means that all child nodes must be invalidated; - * {@code false} means that only the node specified by {@code path} must be updated - * @return a promise that will be succeed if path to invalidate is found - * @see #invalidate(TreePath, boolean) - */ - @NotNull - public final Promise invalidate(@NotNull Object element, boolean structure) { - return onValidThread(element, node -> invalidateInternal(node, structure)); - } - - @Nullable - private TreePath invalidateInternal(@Nullable Node node, boolean structure) { - assert invoker.isValidThread(); - while (node != null && !isValid(node)) { - LOG.debug("invalid element cannot be updated: ", node); - node = (Node)node.getParent(); - structure = true; - } - if (node == null) { - node = root.get(); - if (node != null) node.invalidate(); - root.invalidate(); - LOG.debug("root invalidated: ", node); - treeStructureChanged(null, null, null); - return ROOT_INVALIDATED; - } - boolean updated = node.update(); - if (structure) { - node.invalidate(); - TreePath path = TreePathUtil.pathToTreeNode(node); - treeStructureChanged(path, null, null); - return path; - } - if (updated) { - TreePath path = TreePathUtil.pathToTreeNode(node); - treeNodesChanged(path, null, null); - return path; - } - return null; - } - - /** - * Expands a node in the specified tree. - * - * @param element an element of the internal tree structure - * @param tree a tree, which nodes should be expanded - * @param consumer a path consumer called on EDT if path is found and expanded - */ - public final void expand(@NotNull Object element, @NotNull JTree tree, @NotNull Consumer consumer) { - promiseVisitor(element).onSuccess(visitor -> TreeUtil.expand(tree, visitor, consumer)); - } - - /** - * Makes visible a node in the specified tree. - * - * @param element an element of the internal tree structure - * @param tree a tree, which nodes should be made visible - * @param consumer a path consumer called on EDT if path is found and made visible - */ - public final void makeVisible(@NotNull Object element, @NotNull JTree tree, @NotNull Consumer consumer) { - promiseVisitor(element).onSuccess(visitor -> TreeUtil.makeVisible(tree, visitor, consumer)); - } - - /** - * Selects a node in the specified tree. - * - * @param element an element of the internal tree structure - * @param tree a tree, which nodes should be selected - * @param consumer a path consumer called on EDT if path is found and selected - */ - public final void select(@NotNull Object element, @NotNull JTree tree, @NotNull Consumer consumer) { - promiseVisitor(element).onSuccess(visitor -> TreeUtil.select(tree, visitor, consumer)); - } - - /** - * Promises to create default visitor to find the specified element. - * - * @param element an element of the internal tree structure - * @return a promise that will be succeed if visitor is created - * @see TreeUtil#promiseExpand(JTree, TreeVisitor) - * @see TreeUtil#promiseSelect(JTree, TreeVisitor) - */ - @NotNull - public final Promise promiseVisitor(@NotNull Object element) { - return onValidThread(structure -> new TreeVisitor.ByTreePath<>( - TreePathUtil.pathToCustomNode(element, structure::getParentElement), - node -> node instanceof Node ? ((Node)node).getElement() : null)); - } - - @Override - public final TreeNode getRoot() { - if (disposed || !isValidThread()) return null; - if (!root.isValid()) { - Node newRoot = getValidRoot(); - root.set(newRoot); - LOG.debug("root updated: ", newRoot); - } - return root.get(); - } - - private Node getNode(Object object, boolean validateChildren) { - if (disposed || !(object instanceof Node) || !isValidThread()) return null; - Node node = (Node)object; - if (isNodeRemoved(node)) return null; - if (validateChildren) validateChildren(node); - return node; - } - - private void validateChildren(@NotNull Node node) { - if (!node.children.isValid()) { - List newChildren = getValidChildren(node); - List oldChildren = node.children.set(newChildren); - if (oldChildren != null) oldChildren.forEach(child -> child.setParent(null)); - if (newChildren != null) newChildren.forEach(child -> child.setParent(node)); - LOG.debug("children updated: ", node); - } - } - - private boolean isNodeRemoved(@NotNull Node node) { - return !node.isNodeAncestor(root.get()); - } - - @Override - public final List getChildren(Object object) { - Node node = getNode(object, true); - List list = node == null ? null : node.children.get(); - if (list == null || list.isEmpty()) return emptyList(); - list.forEach(Node::update); - return unmodifiableList(list); - } - - @Override - public final int getChildCount(Object object) { - Node node = getNode(object, true); - return node == null ? 0 : node.getChildCount(); - } - - @Override - public final TreeNode getChild(Object object, int index) { - Node node = getNode(object, true); - return node == null ? null : node.getChildAt(index); - } - - @Override - public final boolean isLeaf(Object object) { - Node node = getNode(object, false); - return node == null || node.isLeaf(this::validateChildren); - } - - @Override - public final int getIndexOfChild(Object object, Object child) { - return object instanceof Node && child instanceof Node ? ((Node)object).getIndex((TreeNode)child) : -1; - } - - private boolean isValid(@NotNull Node node) { - return isValid(structure, node.getElement()); - } - - private static boolean isValid(@NotNull AbstractTreeStructure structure, Object element) { - if (element == null) return false; - if (element instanceof AbstractTreeNode) { - AbstractTreeNode node = (AbstractTreeNode)element; - if (null == node.getValue()) return false; - } - if (element instanceof ValidateableNode) { - ValidateableNode node = (ValidateableNode)element; - if (!node.isValid()) return false; - } - return structure.isValid(element); - } - - @Nullable - private Node getValidRoot() { - Object element = structure.getRootElement(); - if (!isValid(structure, element)) return null; - - Node newNode = new Node(structure, element, null); // an exception may be thrown while getting a root - Node oldNode = root.get(); - if (oldNode != null && oldNode.canReuse(newNode, element)) { - return oldNode; // reuse old node with possible children - } - return newNode; - } - - @Nullable - private List getValidChildren(@NotNull Node node) { - NodeDescriptor descriptor = node.getDescriptor(); - if (descriptor == null) return null; - - Object parent = descriptor.getElement(); - if (!isValid(structure, parent)) return null; - - Object[] elements = structure.getChildElements(parent); - if (elements.length == 0) return null; - - List list = new ArrayList<>(elements.length); - for (Object element : elements) { - if (isValid(structure, element)) { - list.add(new Node(structure, element, descriptor)); // an exception may be thrown while getting children - } - } - Comparator comparator = this.comparator; - if (comparator != null) { - try { - list.sort(comparator); // an exception may be thrown while sorting children - } - catch (IllegalArgumentException exception) { - StringBuilder sb = new StringBuilder("unexpected sorting failed in "); - sb.append(this); - for (Node next : list) sb.append('\n').append(next); - LOG.error(sb.toString(), exception); - } - } - HashMap map = new HashMap<>(); - node.getChildren().forEach(child -> { - Object element = child.getElement(); - if (element != null) map.put(element, child); - }); - for (int i = 0; i < list.size(); i++) { - Node newNode = list.get(i); - Node oldNode = map.get(newNode.getElement()); - if (oldNode != null && oldNode.canReuse(newNode, null)) { - list.set(i, oldNode); // reuse old node with possible children - } - } - return list; - } - - private static final class Node extends DefaultMutableTreeNode implements LeafState.Supplier { - private final Reference> children = new Reference<>(); - private final LeafState leafState; - private final int hashCode; - - private Node(@NotNull AbstractTreeStructure structure, @NotNull Object element, NodeDescriptor parent) { - this(structure.createDescriptor(element, parent), structure.getLeafState(element), element.hashCode()); - } - - private Node(@NotNull NodeDescriptor descriptor, @NotNull LeafState leafState, int hashCode) { - super(descriptor, leafState != LeafState.ALWAYS); - this.leafState = leafState; - this.hashCode = hashCode; - if (leafState == LeafState.ALWAYS) children.set(null); // validate children for leaf node - update(); // an exception may be thrown while updating - } - - private void dispose() { - setParent(null); - List list = children.set(null); - if (list != null) list.forEach(Node::dispose); - } - - private boolean canReuse(@NotNull Node node, Object element) { - if (leafState != node.leafState || hashCode != node.hashCode) return false; - if (element != null && !matches(element)) return false; - userObject = node.userObject; // replace old descriptor - return true; - } - - private boolean update() { - NodeDescriptor descriptor = getDescriptor(); - return descriptor != null && descriptor.update(); - } - - private void invalidate() { - if (leafState != LeafState.ALWAYS) { - children.invalidate(); - LOG.debug("node invalidated: ", this); - getChildren().forEach(Node::invalidate); - } - } - - private boolean matches(@NotNull Object element) { - return matches(element, element.hashCode()); - } - - private boolean matches(@NotNull Object element, int hashCode) { - return this.hashCode == hashCode && element.equals(getElement()); - } - - private Node findChild(@NotNull Object element) { - List list = children.get(); - if (list != null) { - if (!list.isEmpty()) { - int hashCode = element.hashCode(); - Optional result = list.stream().filter(node -> node.matches(element, hashCode)).findFirst(); - if (result.isPresent()) return result.get(); // found child node that matches given element - } - if (LOG.isTraceEnabled()) LOG.debug("node '", getElement(), "' have no child: ", element); - } - else { - if (LOG.isTraceEnabled()) LOG.debug("node '", getElement(), "' have no loaded children"); - } - return null; - } - - @NotNull - private List getChildren() { - List list = children.get(); - return list != null ? list : emptyList(); - } - - private NodeDescriptor getDescriptor() { - Object object = getUserObject(); - return object instanceof NodeDescriptor ? (NodeDescriptor)object : null; - } - - private Object getElement() { - NodeDescriptor descriptor = getDescriptor(); - return descriptor == null ? null : descriptor.getElement(); - } - - @Override - public void setUserObject(Object object) { - throw new UnsupportedOperationException("cannot modify node"); - } - - @Override - public void setAllowsChildren(boolean value) { - throw new UnsupportedOperationException("cannot modify node"); - } - - @Override - public Object clone() { - throw new UnsupportedOperationException("cannot clone node"); - } - - @Override - public void insert(MutableTreeNode child, int index) { - throw new UnsupportedOperationException("cannot insert node"); - } - - @Override - public void remove(int index) { - throw new UnsupportedOperationException("cannot remove node"); - } - - @Override - public Enumeration children() { - return enumeration(getChildren()); - } - - @Override - public TreeNode getChildAt(int index) { - List list = getChildren(); - return 0 <= index && index < list.size() ? list.get(index) : null; - } - - @Override - public int getChildCount() { - return getChildren().size(); - } - - @Override - public boolean isLeaf() { - return isLeaf(null); - } - - private boolean isLeaf(@Nullable Consumer validator) { - // root node should not be a leaf node when it is not visible in a tree - // javax.swing.tree.VariableHeightLayoutCache.TreeStateNode.expand(boolean) - if (null == getParent()) return false; - if (leafState == LeafState.ALWAYS) return true; - if (leafState == LeafState.NEVER) return false; - if (leafState == LeafState.DEFAULT && validator != null) validator.accept(this); - return children.isValid() && super.isLeaf(); - } - - @Override - public int getIndex(@NotNull TreeNode child) { - return child instanceof Node && isNodeChild(child) ? getChildren().indexOf(child) : -1; - } - - @NotNull - @Override - public LeafState getLeafState() { - return leafState; - } - } - - - /** - * @deprecated do not use - */ - @Deprecated - public final TreeNode getRootImmediately() { - if (!root.isValid()) { - root.set(getValidRoot()); - } - return root.get(); - } - - /** - * @return a descriptive name for the instance to help a tree identification - * @see Invoker#Invoker(String, Disposable) - */ - @Override - public String toString() { - return description; - } - - @NotNull - private static String format(@NotNull String prefix) { - for (StackTraceElement element : new Exception().getStackTrace()) { - if (!StructureTreeModel.class.getName().equals(element.getClassName())) { - return prefix + " @ " + element.getFileName() + " : " + element.getLineNumber(); - } - } - return prefix; - } - - @Override - public void valueForPathChanged(TreePath path, Object value) { - throw new UnsupportedOperationException("editable tree have to implement TreeModel#valueForPathChanged"); - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/AwsConnectionSettingsPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/AwsConnectionSettingsPanel.kt deleted file mode 100644 index 512d95b0ef..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/AwsConnectionSettingsPanel.kt +++ /dev/null @@ -1,75 +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.openapi.actionSystem.CommonDataKeys -import com.intellij.openapi.actionSystem.DataProvider -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.ValidationInfo -import software.aws.toolkits.core.region.AwsRegion -import software.aws.toolkits.jetbrains.services.lambda.SamProjectTemplate -import software.aws.toolkits.jetbrains.ui.connection.AwsConnectionSettingsSelector -import software.aws.toolkits.resources.message -import javax.swing.JComponent -import javax.swing.JLabel -import javax.swing.JPanel - -interface AwsConnectionSettingsPanel { - val selectionPanel: JComponent - - val selectionLabel: JLabel? - - fun validateAll(): List? = null - - companion object { - @JvmStatic - fun create( - selectedTemplate: SamProjectTemplate, - generator: SamProjectGenerator, - settingsChangedListener: (AwsRegion?, String?) -> Unit - ): AwsConnectionSettingsPanel = - if (selectedTemplate.supportsDynamicSchemas()) - InlineAwsConnectionSettingsPanel(generator.defaultSourceCreatingProject, settingsChangedListener) - else - NoOpAwsConnectionSettingsPanel() - } -} - -/* - * A panel encapsulating AWS credential selection during SAM new project creation wizard, so the default provided project may not have had credentials selected yet - */ -class InlineAwsConnectionSettingsPanel( - private val project: Project, - private val settingsChangedListener: (AwsRegion?, String?) -> Unit = { _, _ -> } -) : AwsConnectionSettingsPanel, DataProvider { - - private val awsConnectionSettingsSelector = AwsConnectionSettingsSelector(project, settingsChangedListener) - - override val selectionPanel: JComponent = awsConnectionSettingsSelector.selectorPanel() - - override val selectionLabel: JLabel = JLabel(message("sam.init.schema.aws_credentials.label")) - - override fun getData(dataId: String): Any? { - if (CommonDataKeys.PROJECT.`is`(dataId)) { - return project - } - return null - } - - override fun validateAll(): List? { - if (awsConnectionSettingsSelector.selectedCredentialProvider() == null) { - return listOf(ValidationInfo(message("sam.init.schema.aws_credentials_select"), awsConnectionSettingsSelector.selectorPanel())) - } - if (awsConnectionSettingsSelector.selectedRegion() == null) { - return listOf(ValidationInfo(message("sam.init.schema.aws_credentials_select_region"), awsConnectionSettingsSelector.selectorPanel())) - } - return null - } -} - -class NoOpAwsConnectionSettingsPanel : AwsConnectionSettingsPanel { - override val selectionPanel: JComponent = JPanel() - - override val selectionLabel: JLabel? = null -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/IntelliJSdkSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/IntelliJSdkSelectionPanel.kt deleted file mode 100644 index a05cd471ca..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/IntelliJSdkSelectionPanel.kt +++ /dev/null @@ -1,58 +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.ide.util.projectWizard.SdkSettingsStep -import com.intellij.ide.util.projectWizard.WizardContext -import com.intellij.openapi.projectRoots.Sdk -import com.intellij.openapi.projectRoots.SdkTypeId -import com.intellij.openapi.ui.ValidationInfo -import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup -import software.aws.toolkits.jetbrains.services.lambda.SdkBasedSdkSettings -import software.aws.toolkits.jetbrains.services.lambda.SdkSettings -import software.aws.toolkits.resources.message -import javax.swing.JComponent -import javax.swing.JLabel - -class IntelliJSdkSelectionPanel(val builder: SamProjectBuilder, val runtimeGroup: RuntimeGroup) : SdkSelectionPanelBase() { - private var currentSdk: Sdk? = null - private val dummyContext = object : WizardContext(null, {}) { - override fun setProjectJdk(sdk: Sdk?) { - currentSdk = sdk - } - } - private val currentSdkPanel: SdkSettingsStep = buildSdkSettingsPanel() - - override val sdkSelectionPanel: JComponent = currentSdkPanel.component - - override val sdkSelectionLabel: JLabel? = JLabel(message("sam.init.project_sdk.label")) - - override fun validateAll(): List? { - if (!currentSdkPanel.validate()) { - throw ValidationException() - } - // okay to return null here since any ConfigurationError in the validate() call will propagate up to the ModuleWizardStep - // validation checker and do-the-right-thing for us - return null - } - - override fun getSdkSettings(): SdkSettings { - currentSdkPanel.updateDataModel() - - return when (runtimeGroup) { - RuntimeGroup.JAVA, RuntimeGroup.PYTHON -> SdkBasedSdkSettings(sdk = currentSdk) - RuntimeGroup.DOTNET -> object : SdkSettings {} - else -> throw RuntimeException("Unrecognized runtime group: $runtimeGroup") - } - } - - // don't validate on init of the SettingsStep or weird things will happen if the user has no SDK - private fun buildSdkSettingsPanel(): SdkSettingsStep = - SdkSettingsStep( - dummyContext, - builder, - { t: SdkTypeId? -> t == runtimeGroup.getIdeSdkType() }, - null - ) -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/PyCharmSdkSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/PyCharmSdkSelectionPanel.kt deleted file mode 100644 index b841a181b9..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/PyCharmSdkSelectionPanel.kt +++ /dev/null @@ -1,106 +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.ide.util.projectWizard.AbstractNewProjectStep -import com.intellij.openapi.projectRoots.Sdk -import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil -import com.intellij.openapi.ui.ValidationInfo -import com.intellij.openapi.util.io.FileUtil -import com.intellij.ui.DocumentAdapter -import com.intellij.util.ui.UIUtil -import com.jetbrains.python.newProject.PyNewProjectSettings -import com.jetbrains.python.newProject.PythonProjectGenerator -import com.jetbrains.python.newProject.steps.ProjectSpecificSettingsStep -import com.jetbrains.python.newProject.steps.PyAddExistingSdkPanel -import com.jetbrains.python.newProject.steps.PyAddNewEnvironmentPanel -import com.jetbrains.python.sdk.add.PyAddSdkGroupPanel -import icons.AwsIcons -import software.aws.toolkits.jetbrains.services.lambda.SdkBasedSdkSettings -import software.aws.toolkits.jetbrains.services.lambda.SdkSettings -import software.aws.toolkits.resources.message -import java.io.File -import javax.swing.Icon -import javax.swing.JLabel -import javax.swing.JPanel -import javax.swing.event.DocumentEvent -import javax.swing.event.DocumentListener - -class PyCharmSdkSelectionPanel(val step: SamProjectRuntimeSelectionStep) : SdkSelectionPanelBase() { - private var documentListener: DocumentListener? = null - - override val sdkSelectionPanel: PyAddSdkGroupPanel by lazy { newSdkPanel() } - - override val sdkSelectionLabel: JLabel? = null - - private fun newSdkPanel(): PyAddSdkGroupPanel = - // construct a py-specific settings step and grab its sdk panel instance - object : ProjectSpecificSettingsStep(object : PythonProjectGenerator() { - override fun getLogo(): Icon? = AwsIcons.Logos.AWS - - override fun getName(): String = message("sam.init.name") - }, AbstractNewProjectStep.AbstractCallback()) { - // shim validation back to the user UI... - override fun setErrorText(text: String?) { - step.setErrorText(text) - } - - override fun createPanel(): JPanel { - val panel = super.createPanel() - // patch the default create button that gets generated - myCreateButton.isVisible = false - // we only want this panel for its' sdk selector - myLocationField.isEnabled = false - // hide label and textbox - myLocationField.parent.isVisible = false - val myInterpreterPanel = UIUtil.findComponentOfType(panel, PyAddSdkGroupPanel::class.java) - - return myInterpreterPanel - ?: throw RuntimeException("Could not find PyAddSdkGroupPanel in UI Tree") - } - }.createPanel() as PyAddSdkGroupPanel - - override fun registerListeners() { - val document = step.getLocationField().textField.document - // cleanup because generators are re-used - if (documentListener != null) { - document.removeDocumentListener(documentListener) - } - - documentListener = object : DocumentAdapter() { - val locationField = step.getLocationField() - override fun textChanged(e: DocumentEvent) { - sdkSelectionPanel.newProjectPath = locationField.text.trim() - } - } - - document.addDocumentListener(documentListener) - - sdkSelectionPanel.addChangeListener(Runnable { - step.checkValid() - }) - - sdkSelectionPanel.newProjectPath = step.getLocationField().text.trim() - } - - override fun getSdkSettings(): SdkSettings = - getSdk()?.let { - SdkBasedSdkSettings(sdk = it) - } ?: throw RuntimeException(message("sam.init.python.bad_sdk")) - - private fun getSdk(): Sdk? = - when (val panel = sdkSelectionPanel.selectedPanel) { - // this list should be exhaustive - is PyAddNewEnvironmentPanel -> { - FileUtil.createDirectory(File(step.getLocationField().text.trim())) - panel.getOrCreateSdk()?.also { - SdkConfigurationUtil.addSdk(it) - } - } - is PyAddExistingSdkPanel -> panel.sdk - else -> null - } - - override fun validateAll(): List? = sdkSelectionPanel.validateAll() -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitProjectBuilderCommon.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitProjectBuilderCommon.kt deleted file mode 100644 index dec1cfce0c..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitProjectBuilderCommon.kt +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -@file:JvmName("SamInitProjectBuilderCommon") - -package software.aws.toolkits.jetbrains.ui.wizard - -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.options.ShowSettingsUtil -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.project.DefaultProjectFactory -import com.intellij.openapi.ui.ValidationInfo -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.executables.getExecutableIfPresent -import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable -import software.aws.toolkits.jetbrains.settings.AwsSettingsConfigurable -import software.aws.toolkits.resources.message -import javax.swing.JButton -import javax.swing.JComponent -import javax.swing.JTextField - -interface ValidatablePanel { - fun validate(): ValidationInfo? = null -} - -@JvmOverloads -fun setupSamSelectionElements(samExecutableField: JTextField, editButton: JButton, label: JComponent, postEditCallback: Runnable? = null) { - fun getSamExecutable(): ExecutableInstance.ExecutableWithPath? = - ExecutableManager.getInstance().getExecutableIfPresent().let { - if (it is ExecutableInstance.ExecutableWithPath) { - it - } else { - null - } - } - - fun updateUi(validSamPath: Boolean) { - runInEdt(ModalityState.any()) { - samExecutableField.isVisible = !validSamPath - editButton.isVisible = !validSamPath - label.isVisible = !validSamPath - } - } - - samExecutableField.text = getSamExecutable()?.executablePath?.toString() - - editButton.addActionListener { - ShowSettingsUtil.getInstance().showSettingsDialog(DefaultProjectFactory.getInstance().defaultProject, AwsSettingsConfigurable::class.java) - samExecutableField.text = getSamExecutable()?.executablePath?.toString() - postEditCallback?.run() - } - - val toolTipText = message("aws.settings.find.description", "SAM") - label.toolTipText = toolTipText - samExecutableField.toolTipText = toolTipText - editButton.toolTipText = toolTipText - - ProgressManager.getInstance().runProcessWithProgressSynchronously({ - try { - val validSamPath = when (ExecutableManager.getInstance().getExecutable().toCompletableFuture().get()) { - is ExecutableInstance.Executable -> true - else -> false - } - updateUi(validSamPath) - } catch (e: Throwable) { - updateUi(validSamPath = false) - } - }, message("lambda.run_configuration.sam.validating"), false, null) -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitRunner.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitRunner.kt deleted file mode 100644 index 38471fe52b..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitRunner.kt +++ /dev/null @@ -1,94 +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.fasterxml.jackson.databind.ObjectMapper -import com.intellij.execution.process.CapturingProcessHandler -import com.intellij.openapi.util.io.FileUtil -import com.intellij.openapi.vfs.VfsUtil -import com.intellij.openapi.vfs.VirtualFile -import software.amazon.awssdk.services.lambda.model.Runtime -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 -import software.aws.toolkits.jetbrains.services.lambda.TemplateParameters -import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable -import software.aws.toolkits.jetbrains.services.schemas.SchemaTemplateParameters -import software.aws.toolkits.resources.message - -object SamInitRunner { - private val LOG = getLogger() - private val mapper = ObjectMapper() - - fun execute( - name: String, - outputDir: VirtualFile, - runtime: Runtime, - templateParameters: TemplateParameters, - schemaParameters: SchemaTemplateParameters? - ) { - // set output to a temp dir - val tempDir = createTempDir() - - ExecutableManager.getInstance().getExecutable().thenApply { - val samExecutable = when (it) { - is ExecutableInstance.Executable -> it - else -> throw RuntimeException((it as? ExecutableInstance.BadExecutable)?.validationError) - } - val commandLine = samExecutable - .getCommandLine() - .withParameters("init") - .withParameters("--no-input") - .withParameters("--output-dir") - .withParameters(tempDir.path) - .withParameters("--no-interactive") - .apply { - when (templateParameters) { - is TemplateParameters.AppBasedTemplate -> { - this.withParameters("--name") - .withParameters(name) - .withParameters("--runtime") - .withParameters(runtime.toString()) - .withParameters("--dependency-manager") - .withParameters(templateParameters.dependencyManager) - .withParameters("--app-template") - .withParameters(templateParameters.appTemplate) - } - is TemplateParameters.LocationBasedTemplate -> { - this.withParameters("--location") - .withParameters(templateParameters.location) - } - } - - schemaParameters?.let { params -> - val extraContextAsJson = mapper.writeValueAsString(params.templateExtraContext) - - this.withParameters("--extra-context") - .withParameters(extraContextAsJson) - } - } - - LOG.info { "Running SAM command ${commandLine.commandLineString}" } - - val process = CapturingProcessHandler(commandLine).runProcess() - if (process.exitCode != 0) { - throw RuntimeException("${message("sam.init.execution_error")}: ${process.stderrLines}") - } else { - LOG.info { "SAM init output stdout:\n${process.stdout}" } - LOG.info { "SAM init output stderr:\n${process.stderr}" } - } - - val subFolders = tempDir.listFiles()?.toList() ?: emptyList() - - assert(subFolders.size == 1 && subFolders.first().isDirectory) { - message("sam.init.error.subfolder_not_one", tempDir.name) - } - - FileUtil.copyDirContent(subFolders.first(), VfsUtil.virtualToIoFile(outputDir)) - FileUtil.delete(tempDir) - }.toCompletableFuture().join() - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitSelectionPanel.form b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitSelectionPanel.form deleted file mode 100644 index 8d82947e1e..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitSelectionPanel.form +++ /dev/null @@ -1,87 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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 deleted file mode 100644 index c6f01be797..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamInitSelectionPanel.java +++ /dev/null @@ -1,403 +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.openapi.ui.ComboBox; -import com.intellij.openapi.ui.ValidationInfo; -import com.intellij.ui.ColoredListCellRenderer; -import com.intellij.ui.components.JBLabel; -import com.intellij.uiDesigner.core.GridConstraints; -import java.awt.Dimension; -import java.awt.event.ItemEvent; -import java.util.List; -import java.util.Set; -import java.util.function.Predicate; -import javax.swing.JButton; -import javax.swing.JComponent; -import javax.swing.JLabel; -import javax.swing.JList; -import javax.swing.JPanel; -import javax.swing.JTextField; -import kotlin.Pair; -import kotlin.Unit; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import software.amazon.awssdk.services.lambda.model.Runtime; -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.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; -import software.aws.toolkits.jetbrains.services.lambda.LambdaBuilder; -import software.aws.toolkits.jetbrains.services.lambda.RuntimeGroup; -import software.aws.toolkits.jetbrains.services.lambda.SamNewProjectSettings; -import software.aws.toolkits.jetbrains.services.lambda.SamProjectTemplate; -import software.aws.toolkits.jetbrains.services.lambda.sam.SamExecutable; - -public class SamInitSelectionPanel implements ValidatablePanel { - @NotNull - JPanel mainPanel; - @NotNull - private ComboBox runtimeComboBox; - @NotNull - private JTextField samExecutableField; - @NotNull - private JButton editSamExecutableButton; - @NotNull - private JBLabel samLabel; - @NotNull - private ComboBox templateComboBox; - - private SdkSelectionPanel sdkSelectionUi; - private JLabel currentSdkSelectorLabel; - private JComponent currentSdkSelector; - - private SchemaSelectionPanel schemaSelectionUi; - private JLabel currentSchemaSelectorLabel; - private JComponent currentSchemaSelector; - - private AwsConnectionSettingsPanel awsCredentialSelectionUi; - private JLabel currentAwsCredentialSelectorLabel; - private JComponent currentAwsCredentialSelector; - - private final SamProjectGenerator generator; - - private static final Predicate includeAllRuntimes = (s) -> true; - - SamInitSelectionPanel(SamProjectGenerator generator) { - this(generator, includeAllRuntimes); - } - - SamInitSelectionPanel(SamProjectGenerator generator, Predicate runtimeFilter) { - this.generator = generator; - this.currentSdkSelectorLabel = null; - this.currentSdkSelector = null; - this.currentSchemaSelectorLabel = null; - this.currentSchemaSelector = null; - this.currentAwsCredentialSelectorLabel = null; - this.currentAwsCredentialSelector = null; - - // TODO: Move this to Kotlin... - // Source all templates, find all the runtimes they support, then filter those by what the IDE supports - Set supportedRuntimeGroups = LambdaBuilder.Companion.getSupportedRuntimeGroups(); - SamProjectTemplate.SAM_TEMPLATES.stream() - .flatMap(template -> template.supportedRuntimes().stream()) - .sorted() - .filter(runtimeFilter) - .filter(r -> supportedRuntimeGroups.contains(RuntimeGroup.find(runtimeGroup -> runtimeGroup.getRuntimes().contains(r)))) - .distinct() - .forEach(y -> runtimeComboBox.addItem(y)); - - SamInitProjectBuilderCommon.setupSamSelectionElements(samExecutableField, editSamExecutableButton, samLabel); - - runtimeComboBox.addItemListener(l -> { - if (l.getStateChange() == ItemEvent.SELECTED) { - runtimeUpdate(); - sdkSelectionUi.registerListeners(); - } - }); - - templateComboBox.addItemListener(l -> { - if (l.getStateChange() == ItemEvent.SELECTED) { - templateUpdate(); - } - }); - - runtimeUpdate(); - - mainPanel.validate(); - } - - public void setRuntime(Runtime runtime) { - int itemCount = runtimeComboBox.getItemCount(); - - for (int itemIndex = 0; itemIndex < itemCount; itemIndex++) { - if (runtimeComboBox.getItemAt(itemIndex) == runtime) { - runtimeComboBox.setSelectedItem(runtime); - return; - } - } - } - - private void runtimeUpdate() { - Runtime selectedRuntime = (Runtime) runtimeComboBox.getSelectedItem(); - - templateComboBox.removeAllItems(); - - // if selected runtimeComboBox is null, we're on an unsupported platform - if (selectedRuntime == null) { - addNoOpConditionalPanels(); - return; - } - - SamProjectTemplate.SAM_TEMPLATES.stream() - .filter(template -> template.supportedRuntimes().contains(selectedRuntime)) - .forEach(template -> templateComboBox.addItem(template)); - templateComboBox.setRenderer(new ColoredListCellRenderer() { - @Override - protected void customizeCellRenderer(@NotNull JList list, SamProjectTemplate value, int index, boolean selected, boolean hasFocus) { - if (value == null) { - return; - } - setIcon(value.getIcon()); - append(value.getName()); - } - }); - - this.sdkSelectionUi = SdkSelectionPanel.create(selectedRuntime, generator); - addSdkPanel(sdkSelectionUi); - } - - private void templateUpdate() { - Runtime selectedRuntime = (Runtime) runtimeComboBox.getSelectedItem(); - if (selectedRuntime == null) { - addNoOpConditionalPanels(); - return; - } - - SamProjectTemplate selectedTemplate = (SamProjectTemplate) templateComboBox.getSelectedItem(); - if (selectedTemplate == null) { - addAwsConnectionSettingsPanel(new NoOpAwsConnectionSettingsPanel()); - addSchemaPanel(new NoOpSchemaSelectionPanel()); - return; - } - - this.awsCredentialSelectionUi = AwsConnectionSettingsPanel.create(selectedTemplate, generator, this::awsCredentialsUpdated); - addAwsConnectionSettingsPanel(awsCredentialSelectionUi); - - AwsConnectionManager accountSettingsManager = AwsConnectionManager.Companion.getInstance(generator.getDefaultSourceCreatingProject()); - if (accountSettingsManager.isValidConnectionSettings()) { - awsCredentialsUpdated(accountSettingsManager.getActiveRegion(), accountSettingsManager.getActiveCredentialProvider().getId()); - } else { - mainPanel.revalidate(); - } - } - - private Unit awsCredentialsUpdated(AwsRegion awsRegion, String credentialProviderId) { - if (awsRegion == null || credentialProviderId == null) { - return Unit.INSTANCE; - } - - CredentialManager credentialManager = CredentialManager.getInstance(); - CredentialIdentifier credentialIdentifier = credentialManager.getCredentialIdentifierById(credentialProviderId); - if (credentialIdentifier == null) { - throw new IllegalArgumentException("Unknown credential provider selected"); - } - - return awsCredentialsUpdated(awsRegion, credentialIdentifier); - } - - 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); - } - if (accountSettingsManager.getActiveRegion() != awsRegion) { - accountSettingsManager.changeRegion(awsRegion); - } - - return initSchemaSelectionPanel(awsRegion, credentialIdentifier); - } - - private Unit initSchemaSelectionPanel(AwsRegion awsRegion, CredentialIdentifier credentialIdentifier) { - Runtime selectedRuntime = (Runtime) runtimeComboBox.getSelectedItem(); - if (selectedRuntime == null) { - addNoOpConditionalPanels(); - return Unit.INSTANCE; - } - - SamProjectTemplate selectedTemplate = (SamProjectTemplate) templateComboBox.getSelectedItem(); - if (selectedTemplate == null) { - addAwsConnectionSettingsPanel(new NoOpAwsConnectionSettingsPanel()); - addSchemaPanel(new NoOpSchemaSelectionPanel()); - return Unit.INSTANCE; - } - - this.schemaSelectionUi = SchemaSelectionPanel.create(selectedRuntime, selectedTemplate, generator); - - addSchemaPanel(schemaSelectionUi); - - ToolkitCredentialsProvider credentialProvider = CredentialManager.getInstance().getAwsCredentialProvider(credentialIdentifier, awsRegion); - - this.schemaSelectionUi.reloadSchemas(new Pair<>(awsRegion, credentialProvider)); - - mainPanel.revalidate(); - - return Unit.INSTANCE; - } - - private void addNoOpConditionalPanels() { - addSdkPanel(new NoOpSdkSelectionPanel()); - addAwsConnectionSettingsPanel(new NoOpAwsConnectionSettingsPanel()); - addSchemaPanel(new NoOpSchemaSelectionPanel()); - } - - private void addSdkPanel(@NotNull SdkSelectionPanel sdkSelectionPanel) { - // glitchy behavior if we don't clean up any old panels - // Also, while it looks like addSdkPanel, addAwsConnectionSettingsPanel, and addSchemaPanel could all be refactored into one helper function - // that takes a currentLabel and a currentSelectorPanel, due to some Swing magic, it does not work, and things get, well, glitchy. - if (currentSdkSelectorLabel != null) { - mainPanel.remove(currentSdkSelectorLabel); - } - if (currentSdkSelector != null) { - mainPanel.remove(currentSdkSelector); - } - - JLabel newLabel = sdkSelectionPanel.getSdkSelectionLabel(); - JComponent newSelector = sdkSelectionPanel.getSdkSelectionPanel(); - addOptionalSelectorPanel(newLabel, - newSelector, - 3); - - currentSdkSelectorLabel = newLabel; - currentSdkSelector = newSelector; - } - - private void addAwsConnectionSettingsPanel(@NotNull AwsConnectionSettingsPanel awsConnectionSettingsPanel) { - // glitchy behavior if we don't clean up any old panels - // Also, while it looks like addSdkPanel, addAwsConnectionSettingsPanel, and addSchemaPanel could all be refactored into one helper function - // that takes a currentLabel and a currentSelectorPanel, due to some Swing magic, it does not work, and things get, well, glitchy. - if (currentAwsCredentialSelectorLabel != null) { - mainPanel.remove(currentAwsCredentialSelectorLabel); - } - if (currentAwsCredentialSelector != null) { - mainPanel.remove(currentAwsCredentialSelector); - } - - JLabel newLabel = awsConnectionSettingsPanel.getSelectionLabel(); - JComponent newSelector = awsConnectionSettingsPanel.getSelectionPanel(); - - addOptionalSelectorPanel(newLabel, - newSelector, - 4); - - currentAwsCredentialSelectorLabel = newLabel; - currentAwsCredentialSelector = newSelector; - } - - private void addSchemaPanel(@NotNull SchemaSelectionPanel schemaSelectionPanel) { - // glitchy behavior if we don't clean up any old panels - // Also, while it looks like addSdkPanel, addAwsConnectionSettingsPanel, and addSchemaPanel could all be refactored into one helper function - // that takes a currentLabel and a currentSelectorPanel, due to some Swing magic, it does not work, and things get, well, glitchy. - if (currentSchemaSelectorLabel != null) { - mainPanel.remove(currentSchemaSelectorLabel); - } - if (currentSchemaSelector != null) { - mainPanel.remove(currentSchemaSelector); - } - - JLabel newLabel = schemaSelectionPanel.getSchemaSelectionLabel(); - JComponent newSelector = schemaSelectionPanel.getSchemaSelectionPanel(); - - addOptionalSelectorPanel(newLabel, - newSelector, - 5); - - currentSchemaSelectorLabel = newLabel; - currentSchemaSelector = newSelector; - } - - private void addOptionalSelectorPanel(JLabel newLabel, JComponent newSelector, int row) { - // append selector group to main panel - // sdk selector will want to grow past bounds if width is set to -1 - newSelector.setMinimumSize(new Dimension(0, -1)); - // first add the panel - GridConstraints gridConstraints = new GridConstraints(); - gridConstraints.setRow(row); - // take up two columns if no label - if (newLabel == null) { - gridConstraints.setColumn(0); - gridConstraints.setColSpan(2); - } else { - gridConstraints.setColumn(1); - gridConstraints.setColSpan(1); - } - gridConstraints.setHSizePolicy(GridConstraints.SIZEPOLICY_CAN_GROW | GridConstraints.SIZEPOLICY_CAN_SHRINK); - gridConstraints.setFill(GridConstraints.FILL_HORIZONTAL); - gridConstraints.setAnchor(GridConstraints.ANCHOR_WEST); - mainPanel.add(newSelector, gridConstraints); - - // and then the label if available, and it doesn't already exist - if (newLabel != null) { - gridConstraints.setColumn(0); - gridConstraints.setColSpan(1); - mainPanel.add(newLabel, gridConstraints); - } - } - - @Nullable - @Override - public ValidationInfo validate() { - ExecutableInstance samExecutable = ExecutableManager.getInstance().getExecutableIfPresent(ExecutableType.getExecutable(SamExecutable.class)); - if (samExecutable instanceof ExecutableInstance.BadExecutable) { - return new ValidationInfo(((ExecutableInstance.BadExecutable) samExecutable).getValidationError(), samExecutableField); - } - - if (sdkSelectionUi == null) { - return null; - } - - // Validate SDK - List validationInfoList = sdkSelectionUi.validateAll(); - if (validationInfoList != null && !validationInfoList.isEmpty()) { - return validationInfoList.get(0); - } - - if (awsCredentialSelectionUi == null) { - return null; - } - - // Validate AWS Credentials - validationInfoList = awsCredentialSelectionUi.validateAll(); - if (validationInfoList != null && !validationInfoList.isEmpty()) { - return validationInfoList.get(0); - } - - if (schemaSelectionUi == null) { - return null; - } - - // Validate Schemas selection - validationInfoList = schemaSelectionUi.validateAll(); - if (validationInfoList == null || validationInfoList.isEmpty()) { - return null; - } else { - return validationInfoList.get(0); - } - } - - public void registerValidators() { - if (sdkSelectionUi != null) { - sdkSelectionUi.registerListeners(); - } - } - - public SamNewProjectSettings getNewProjectSettings() { - Runtime lambdaRuntime = (Runtime) runtimeComboBox.getSelectedItem(); - SamProjectTemplate samProjectTemplate = (SamProjectTemplate) templateComboBox.getSelectedItem(); - - if (lambdaRuntime == null) { - throw new RuntimeException("No Runtime is supported in this Platform."); - } - - if (samProjectTemplate == null) { - throw new RuntimeException("No SAM template is supported for this runtime: " + lambdaRuntime.toString()); - } - - if (sdkSelectionUi != null) { - return new SamNewProjectSettings( - lambdaRuntime, - schemaSelectionUi == null || !samProjectTemplate.supportsDynamicSchemas() ? null : schemaSelectionUi.buildSchemaTemplateParameters(), - samProjectTemplate, - sdkSelectionUi.getSdkSettings() - ); - } else { - throw new RuntimeException("SDK selection panel is not initialized."); - } - } -} 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 deleted file mode 100644 index bd3a4b96ec..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamProjectGenerator.kt +++ /dev/null @@ -1,155 +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.ide.util.projectWizard.AbstractNewProjectStep -import com.intellij.ide.util.projectWizard.CustomStepProjectGenerator -import com.intellij.ide.util.projectWizard.ModuleBuilder -import com.intellij.ide.util.projectWizard.ProjectSettingsStepBase -import com.intellij.ide.util.projectWizard.SettingsStep -import com.intellij.ide.util.projectWizard.WebProjectTemplate -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.application.runWriteAction -import com.intellij.openapi.module.Module -import com.intellij.openapi.project.DefaultProjectFactory -import com.intellij.openapi.project.Project -import com.intellij.openapi.roots.ModuleRootManager -import com.intellij.openapi.ui.TextFieldWithBrowseButton -import com.intellij.openapi.ui.ValidationInfo -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.openapi.wm.impl.welcomeScreen.AbstractActionWithPanel -import com.intellij.platform.DirectoryProjectGenerator -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.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 -import software.aws.toolkits.resources.message -import javax.swing.Icon -import javax.swing.JComponent - -// ref: https://github.com/JetBrains/intellij-plugins/blob/master/vuejs/src/org/jetbrains/vuejs/cli/VueCliProjectGenerator.kt -class SamProjectGenerator : ProjectTemplate, - WebProjectTemplate(), // pycharm hack - DirectoryProjectGenerator, - CustomStepProjectGenerator, - HideableProjectGenerator { - val builder = SamProjectBuilder(this) - val step = SamProjectRuntimeSelectionStep(this) - val peer = SamProjectGeneratorSettingsPeer(this) - - // Stable source-creating project for creating new SAM application and making API calls safely, - // as AWSToolkit assumes across the board it's operating with an active project - // Independent of lastUsedProject because it may not be set, - // or could be disposed if the user chooses to create a new project in the same window as their previous - val defaultSourceCreatingProject = createDefaultSourceCreatingProject() - - // Only show the generator if we have SAM templates to show - override fun isHidden(): Boolean = SamProjectTemplate.SAM_TEMPLATES.isEmpty() - - // steps are used by non-IntelliJ IDEs - override fun createStep( - projectGenerator: DirectoryProjectGenerator?, - callback: AbstractNewProjectStep.AbstractCallback? - ): AbstractActionWithPanel = step - - // non-IntelliJ project commit step - override fun generateProject( - project: Project, - baseDir: VirtualFile, - settings: SamNewProjectSettings, - module: Module - ) { - runInEdt { - val rootModel = ModuleRootManager.getInstance(module).modifiableModel - val builder = createModuleBuilder() - builder.contentEntryPath = baseDir.path - builder.setupRootModel(rootModel) - - runWriteAction { - rootModel.commit() - } - } - } - - private fun createDefaultSourceCreatingProject(): Project { - val newDefaultProject = DefaultProjectFactory.getInstance().defaultProject - - // Explicitly eager load ProjectAccountSettingsManager for the project to subscribe to credential change events - AwsConnectionManager.getInstance(newDefaultProject) - return newDefaultProject - } - - // the peer is in control of the first pane - override fun createPeer(): ProjectGeneratorPeer = peer - - // these overrides will give us a section for non-IntelliJ IDEs - override fun getName() = message("sam.init.name") - - override fun getDescription(): String? = message("sam.init.description") - - override fun getLogo(): Icon = AwsIcons.Resources.SERVERLESS_APP - - override fun getIcon(): Icon = logo - - override fun createModuleBuilder(): ModuleBuilder = builder - - // force the initial validation - override fun postponeValidation(): Boolean = false - - // validation is done in the peer - override fun validateSettings(): ValidationInfo? = null - - override fun getHelpId(): String? = HelpIds.NEW_SERVERLESS_PROJECT_DIALOG.id -} - -// non-IntelliJ step UI -class SamProjectRuntimeSelectionStep( - projectGenerator: SamProjectGenerator -) : ProjectSettingsStepBase( - projectGenerator, - AbstractNewProjectStep.AbstractCallback() -) { - fun getLocationField(): TextFieldWithBrowseButton = myLocationField - - override fun registerValidators() { - super.registerValidators() - (peer as SamProjectGeneratorSettingsPeer).registerValidators() - } -} - -class SamProjectGeneratorSettingsPeer(val generator: SamProjectGenerator) : ProjectGeneratorPeer { - private val samInitSelectionPanel by lazy { SamInitSelectionPanel(generator) } - - /** - * This hook is used in PyCharm and is called via {@link SamProjectBuilder#modifySettingsStep} for IntelliJ - */ - override fun validate(): ValidationInfo? = samInitSelectionPanel.validate() - - override fun getSettings(): SamNewProjectSettings = samInitSelectionPanel.newProjectSettings - - // "Deprecated" but required to implement. Not importing to avoid the import deprecation warning. - @Suppress("OverridingDeprecatedMember", "DEPRECATION") - override fun addSettingsStateListener(listener: com.intellij.platform.WebProjectGenerator.SettingsStateListener) {} - - // we sacrifice a lot of convenience so we can build the UI here... - override fun buildUI(settingsStep: SettingsStep) { - // delegate to another panel instead of trying to write UI as code - settingsStep.addSettingsComponent(component) - } - - // order matters! we build the peer UI before we build the step UI, - // so validators should be done after BOTH have been constructed - fun registerValidators() { - // register any IDE-specific behavior - samInitSelectionPanel.registerValidators() - } - - override fun isBackgroundJobRunning(): Boolean = false - - override fun getComponent(): JComponent = samInitSelectionPanel.mainPanel -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamProjectGeneratorIntelliJShims.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamProjectGeneratorIntelliJShims.kt deleted file mode 100644 index 6b8431884c..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SamProjectGeneratorIntelliJShims.kt +++ /dev/null @@ -1,139 +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.ide.util.projectWizard.ModuleBuilder -import com.intellij.ide.util.projectWizard.ModuleWizardStep -import com.intellij.ide.util.projectWizard.SettingsStep -import com.intellij.ide.util.projectWizard.WizardContext -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.module.ModuleType -import com.intellij.openapi.module.ModuleTypeManager -import com.intellij.openapi.options.ConfigurationException -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task -import com.intellij.openapi.roots.ContentEntry -import com.intellij.openapi.roots.ModifiableRootModel -import com.intellij.openapi.roots.ModuleRootModificationUtil -import com.intellij.openapi.startup.StartupManager -import com.intellij.openapi.vfs.VfsUtil -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.platform.ProjectTemplatesFactory -import icons.AwsIcons -import software.aws.toolkits.core.utils.error -import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup -import software.aws.toolkits.resources.message - -// Meshing of two worlds. IntelliJ wants validation errors to be thrown exceptions. Non-IntelliJ wants validation errors -// to be returned as a ValidationInfo object. We have a shim to convert thrown exceptions into objects, -// but then we lose the ability in IntelliJ to fail validation without showing an error. This is a workaround for that case. -class ValidationException : Exception() - -// IntelliJ shim requires a ModuleBuilder -// UI is centralized in generator and is passed in to have access to UI elements -class SamProjectBuilder(private val generator: SamProjectGenerator) : ModuleBuilder() { - // hide this from the new project menu - override fun isAvailable() = false - - // dummy type to fulfill the interface - override fun getModuleType() = AwsModuleType.INSTANCE - - // IntelliJ create commit step - override fun setupRootModel(rootModel: ModifiableRootModel) { - val settings = generator.peer.settings - - settings.template.setupSdk(rootModel, settings) - - // Set module type - val selectedRuntime = settings.runtime - val moduleType = selectedRuntime.runtimeGroup?.getModuleType() ?: ModuleType.EMPTY - rootModel.module.setModuleType(moduleType.id) - - val contentEntry: ContentEntry = doAddContentEntry(rootModel) ?: throw Exception(message("sam.init.error.no.project.basepath")) - val outputDir: VirtualFile = contentEntry.file ?: throw Exception(message("sam.init.error.no.virtual.file")) - - StartupManager.getInstance(rootModel.project).runWhenProjectIsInitialized { - ProgressManager.getInstance().run(object : Task.Backgroundable(rootModel.project, message("sam.init.generating.template"), false) { - override fun run(indicator: ProgressIndicator) { - ModuleRootModificationUtil.updateModel(rootModel.module) { model -> - val samTemplate = settings.template - samTemplate.build(project, selectedRuntime, settings.schemaParameters, outputDir) - VfsUtil.markDirtyAndRefresh(false, true, true, outputDir) - runInEdt { - try { - samTemplate.postCreationAction(settings, outputDir, model, generator.defaultSourceCreatingProject, indicator) - } catch (t: Throwable) { - LOG.error(t) { "Exception thrown during postCreationAction" } - model.dispose() - } - } - } - } - }) - } - } - - // add things - override fun modifySettingsStep(settingsStep: SettingsStep): ModuleWizardStep? { - generator.peer.buildUI(settingsStep) - - // need to return an object with validate() implemented for validation - return object : ModuleWizardStep() { - override fun getComponent() = null - - override fun updateDataModel() {} - - @Throws(ConfigurationException::class) - override fun validate(): Boolean { - try { - val info = generator.peer.validate() - if (info != null) throw ConfigurationException(info.message) - } catch (_: ValidationException) { - return false - } - - return true - } - } - } - - private companion object { - val LOG = getLogger() - } -} - -class NullBuilder : ModuleBuilder() { - // hide this from the new project menu - override fun isAvailable() = false - - override fun getModuleType(): ModuleType<*> = AwsModuleType.INSTANCE - - override fun setupRootModel(modifiableRootModel: ModifiableRootModel) {} -} - -class AwsModuleType : ModuleType(ID) { - override fun createModuleBuilder() = NullBuilder() - - override fun getName() = ID - - override fun getDescription() = message("aws.description") - - override fun getNodeIcon(isOpened: Boolean) = AwsIcons.Logos.AWS - - companion object { - const val ID = "AWS" - val INSTANCE: ModuleType<*> = ModuleTypeManager.getInstance().findByID(ID) - } -} - -class SamProjectGeneratorIntelliJAdapter : ProjectTemplatesFactory() { - // pull in AWS project types here - override fun createTemplates(group: String?, context: WizardContext?) = arrayOf(SamProjectGenerator()) - - override fun getGroupIcon(group: String?) = AwsIcons.Logos.AWS - - override fun getGroups() = arrayOf("AWS") -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaCodeGenUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaCodeGenUtils.kt deleted file mode 100644 index 6eeecb1170..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaCodeGenUtils.kt +++ /dev/null @@ -1,86 +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 - -// TODO: This is fragile. Very fragile. But it is necessary to get Schemas service launched, and we've evaluated all other trade offs -// This will be done on the server-side as soon as we can, but for now the client needs to do this -class SchemaCodeGenUtils { - companion object { - private const val SCHEMA_PACKAGE_PREFIX = "schema" - private const val AWS = "aws" - private const val PARTNER = "partner" - private const val AWS_PARTNER_PREFIX = "$AWS.$PARTNER-" // dash suffix because of 3p partner registry name format - private const val AWS_EVENTS_PREFIX = "$AWS." // . suffix because of 1p event registry schema format - - fun buildSchemaPackageName(schemaName: String): String { - val builder = CodeGenPackageBuilder() - builder.append(SCHEMA_PACKAGE_PREFIX) - buildPackageName(builder, schemaName) - return builder.toString() - } - - private fun buildPackageName(builder: CodeGenPackageBuilder, schemaName: String) { - if (isAwsPartnerEvent(schemaName)) { - buildPartnerEventPackageName(builder, schemaName) - } else if (isAwsEvent(schemaName)) { - buildAwsEventPackageName(builder, schemaName) - } else { - buildCustomPackageName(builder, schemaName) - } - } - - private fun isAwsPartnerEvent(schemaName: String): Boolean = schemaName.startsWith(AWS_PARTNER_PREFIX) - - private fun buildPartnerEventPackageName(builder: CodeGenPackageBuilder, schemaName: String) { - val partnerSchemaString = schemaName.substring(AWS_PARTNER_PREFIX.length) - - builder - .append(AWS) - .append(PARTNER) - .append(partnerSchemaString) - } - - private fun isAwsEvent(name: String): Boolean = name.startsWith(AWS_EVENTS_PREFIX) - - private fun buildAwsEventPackageName(builder: CodeGenPackageBuilder, schemaName: String) { - val awsEventSchemaParts = schemaName.split(".") - for (part in awsEventSchemaParts) { - builder.append(part) - } - } - - private fun buildCustomPackageName(builder: CodeGenPackageBuilder, schemaName: String) = builder.append(schemaName) - } - - class CodeGenPackageBuilder { - private val builder: StringBuilder = StringBuilder() - - fun append(segment: String): CodeGenPackageBuilder { - if (builder.isNotEmpty()) { - builder.append(IdentifierFormatter.PACKAGE_SEPARATOR) - } - builder.append(IdentifierFormatter.toValidIdentifier(segment.toLowerCase())) - return this - } - - override fun toString(): String = builder.toString() - } - - class IdentifierFormatter { - companion object { - private const val POTENTIAL_PACKAGE_SEPARATOR = "@" - - private const val NOT_VALID_IDENTIFIER_CHARACTER = "[^a-zA-Z0-9_$POTENTIAL_PACKAGE_SEPARATOR]" - private val NOT_VALID_IDENTIFIER_REGEX = Regex(NOT_VALID_IDENTIFIER_CHARACTER) - - const val PACKAGE_SEPARATOR = "." - - private const val UNDERSCORE = "_" - - fun toValidIdentifier(name: String): String = name - .replace(NOT_VALID_IDENTIFIER_REGEX, UNDERSCORE) - .replace(POTENTIAL_PACKAGE_SEPARATOR, PACKAGE_SEPARATOR) - } - } -} 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 deleted file mode 100644 index 9d530327b9..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaResourceSelectorSelectionPanel.kt +++ /dev/null @@ -1,100 +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.openapi.project.Project -import com.intellij.openapi.ui.ValidationInfo -import com.intellij.ui.ComboboxSpeedSearch -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 -import software.aws.toolkits.jetbrains.ui.ResourceSelector -import software.aws.toolkits.resources.message -import java.awt.BorderLayout -import javax.swing.JComponent -import javax.swing.JLabel -import javax.swing.JPanel - -class SchemaResourceSelectorSelectionPanel( - val builder: SamProjectBuilder, - val runtimeGroup: RuntimeGroup, - val project: Project, - // Subsequent parameters injectable for unit tests to enable mocking because ResourceSelector has inconsistent unit test behaviour - val resourceSelectorBuilder: ResourceSelector.ResourceBuilder = ResourceSelector.builder(project), - useSpeedSearch: Boolean = true, - rootPanelBuilder: () -> JPanel = { JPanel(BorderLayout()) } -) : - SchemaSelectionPanelBase(project) { - - override val schemaSelectionLabel: JLabel? = JLabel(message("sam.init.schema.label")) - - private val schemaPanel: JPanel - - private var currentAwsConnection: AwsConnection? - - private val schemasSelector: ResourceSelector - - init { - currentAwsConnection = initializeAwsConnection() - - schemasSelector = initializeSchemasSelector() - - // Configurable for unit tests because ComboboxSpeedSearch makes things dang near impossible to mock - if (useSpeedSearch) { - ComboboxSpeedSearch(schemasSelector) - } - - schemaPanel = rootPanelBuilder.invoke() - schemaPanel.add(schemasSelector, BorderLayout.CENTER) - schemaPanel.validate() - } - - override val schemaSelectionPanel: JComponent = schemaPanel - - private fun initializeAwsConnection(): AwsConnection? { - val settings = AwsConnectionManager.getInstance(project) - return if (settings.isValidConnectionSettings()) { - settings.activeRegion to settings.activeCredentialProvider - } else { - null - } - } - - private fun initializeSchemasSelector(): ResourceSelector = resourceSelectorBuilder - .resource(SchemasResources.LIST_REGISTRIES_AND_SCHEMAS) - .comboBoxModel(SchemaSelectionComboBoxModel()) - .customRenderer(SchemaSelectionListCellRenderer()) - .disableAutomaticLoading() - .disableAutomaticSorting() - .awsConnection { currentAwsConnection ?: throw IllegalStateException(message("credentials.profile.not_configured")) } // Must be inline function as it gets updated and re-evaluated - .build() - - override fun reloadSchemas(awsConnection: AwsConnection?) { - if (awsConnection != null) { - currentAwsConnection = awsConnection - } - schemasSelector.reload() - - schemasSelector.validate() - schemaPanel.validate() - } - - override fun validateAll(): List? { - if (schemasSelector.selected() == null || schemasSelector.selected() !is SchemaSelectionItem.SchemaItem) { - return listOf(ValidationInfo(message("sam.init.schema.pleaseSelect"), schemasSelector)) - } - return null - } - - override fun registryName(): String? = when (val selected = schemasSelector.selected()) { - is SchemaSelectionItem.SchemaItem -> selected.registryName - else -> null - } - - override fun schemaName(): String? = when (val selected = schemasSelector.selected()) { - is SchemaSelectionItem.SchemaItem -> selected.itemText - else -> null - } -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaSelectionPanel.kt deleted file mode 100644 index ac0346309f..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SchemaSelectionPanel.kt +++ /dev/null @@ -1,146 +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.fasterxml.jackson.databind.JsonNode -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.ValidationInfo -import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.jetbrains.services.lambda.SamProjectTemplate -import software.aws.toolkits.jetbrains.services.lambda.SamProjectWizard -import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup -import software.aws.toolkits.jetbrains.services.schemas.SchemaDownloader -import software.aws.toolkits.jetbrains.services.schemas.SchemaSummary -import software.aws.toolkits.jetbrains.services.schemas.SchemaTemplateExtraContext -import software.aws.toolkits.jetbrains.services.schemas.SchemaTemplateParameters -import software.aws.toolkits.jetbrains.ui.AwsConnection -import javax.swing.JComponent -import javax.swing.JLabel -import javax.swing.JPanel - -// UI for selecting a Schema -interface SchemaSelectionPanel { - val schemaSelectionPanel: JComponent - - val schemaSelectionLabel: JLabel? - - fun registryName(): String? = null - - fun schemaName(): String? = null - - fun reloadSchemas(awsConnection: AwsConnection? = null) {} - - fun buildSchemaTemplateParameters(): SchemaTemplateParameters? - - fun validateAll(): List? = null - - companion object { - - @JvmStatic - fun create( - runtime: Runtime, - selectedTemplate: SamProjectTemplate, - generator: SamProjectGenerator - ): SchemaSelectionPanel = - runtime.runtimeGroup?.let { runtimeGroup -> - if (selectedTemplate.supportsDynamicSchemas()) - SamProjectWizard.getInstanceOrThrow(runtimeGroup).createSchemaSelectionPanel(generator) - else - NoOpSchemaSelectionPanel() - } ?: NoOpSchemaSelectionPanel() - } -} - -// UI-agnostic schema selection panel -abstract class SchemaSelectionPanelBase(private val project: Project) : - SchemaSelectionPanel { - - private val schemaDownloader = SchemaDownloader() - - override fun buildSchemaTemplateParameters(): SchemaTemplateParameters? { - val schemaName = schemaName() - val registryName = registryName() - - if (schemaName == null || registryName == null) { - return null - } - - val schemaSummary = SchemaSummary(schemaName, registryName) - - val describeSchemaResponse = schemaDownloader.getSchemaContent(registryName, schemaName, project = project).toCompletableFuture().get() - val latestSchemaVersion = describeSchemaResponse.schemaVersion() - - val schemaNode = schemaDownloader.getSchemaContentAsJson(describeSchemaResponse) - val awsEventNode = getAwsEventNode(schemaNode) - - // Derive source from custom OpenAPI metadata provided by Schemas service - val source = awsEventNode.path(X_AMAZON_EVENT_SOURCE).textValue() ?: DEFAULT_EVENT_SOURCE - - // Derive detail type from custom OpenAPI metadata provided by Schemas service - val detailType = awsEventNode.path(X_AMAZON_EVENT_DETAIL_TYPE).textValue() ?: DEFAULT_EVENT_DETAIL_TYPE - - // Generate schema root/package from the scheme name - // In the near future, this will be returned as part of a Schemas Service API call - val schemaPackageHierarchy = buildSchemaPackageHierarchy(schemaName) - - // Derive root schema event name from OpenAPI metadata, or if ambiguous, use the last post-character section of a schema name - val rootSchemaEventName = buildRootSchemaEventName(schemaNode, awsEventNode) ?: schemaSummary.title() - - return SchemaTemplateParameters( - schemaSummary, - latestSchemaVersion, - SchemaTemplateExtraContext( - registryName, - rootSchemaEventName, - schemaPackageHierarchy, - source, - detailType - ) - ) - } - - private fun getAwsEventNode(schemaNode: JsonNode): JsonNode = - // Standard OpenAPI specification - schemaNode.path(COMPONENTS).path(SCHEMAS).path(AWS_EVENT) - - private fun buildSchemaPackageHierarchy(schemaName: String): String = SchemaCodeGenUtils.buildSchemaPackageName(schemaName) - - private fun buildRootSchemaEventName(schemaNode: JsonNode, awsEvent: JsonNode): String? { - val awsEventDetailRef = awsEvent.path(PROPERTIES).path(DETAIL).path(REF).textValue()?.substringAfter(COMPONENTS_SCHEMAS_PATH) - if (!awsEventDetailRef.isNullOrEmpty()) { - return SchemaCodeGenUtils.IdentifierFormatter.toValidIdentifier(awsEventDetailRef) - } - - val schemaRoots = schemaNode.path(COMPONENTS).path(SCHEMAS).fieldNames().asSequence().toList() - if (schemaRoots.isNotEmpty()) { - return SchemaCodeGenUtils.IdentifierFormatter.toValidIdentifier(schemaRoots[0]) - } - - return null - } - - companion object { - const val X_AMAZON_EVENT_SOURCE = "x-amazon-events-source" - const val X_AMAZON_EVENT_DETAIL_TYPE = "x-amazon-events-detail-type" - - const val COMPONENTS = "components" - const val SCHEMAS = "schemas" - const val COMPONENTS_SCHEMAS_PATH = "#/components/schemas/" - const val AWS_EVENT = "AWSEvent" - const val PROPERTIES = "properties" - const val DETAIL = "detail" - const val REF = "${'$'}ref" - - const val DEFAULT_EVENT_SOURCE = "INSERT-YOUR-EVENT-SOURCE" - const val DEFAULT_EVENT_DETAIL_TYPE = "INSERT-YOUR-DETAIL-TYPE" - } -} - -class NoOpSchemaSelectionPanel : SchemaSelectionPanel { - override fun buildSchemaTemplateParameters(): SchemaTemplateParameters? = null - - override val schemaSelectionPanel: JComponent = JPanel() - - override val schemaSelectionLabel: JLabel? = null -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SdkSelectionPanel.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SdkSelectionPanel.kt deleted file mode 100644 index c450d88cab..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/ui/wizard/SdkSelectionPanel.kt +++ /dev/null @@ -1,49 +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.openapi.ui.ValidationInfo -import software.amazon.awssdk.services.lambda.model.Runtime -import software.aws.toolkits.jetbrains.services.lambda.SamProjectWizard -import software.aws.toolkits.jetbrains.services.lambda.SdkSettings -import software.aws.toolkits.jetbrains.services.lambda.runtimeGroup -import javax.swing.JComponent -import javax.swing.JLabel -import javax.swing.JPanel - -// UI for selecting target SDK of a Runtime -interface SdkSelectionPanel { - val sdkSelectionPanel: JComponent - - val sdkSelectionLabel: JLabel? - - fun registerListeners() - - fun getSdkSettings(): SdkSettings - - // Validate the SDK selection panel, return a list of violations if any, otherwise null - fun validateAll(): List? - - companion object { - @JvmStatic - fun create(runtime: Runtime, generator: SamProjectGenerator): SdkSelectionPanel = - runtime.runtimeGroup?.let { - SamProjectWizard.getInstanceOrThrow(it).createSdkSelectionPanel(generator) - } ?: NoOpSdkSelectionPanel() - } -} - -abstract class SdkSelectionPanelBase : SdkSelectionPanel { - override fun registerListeners() {} - - override fun getSdkSettings(): SdkSettings = object : SdkSettings {} - - override fun validateAll(): List? = null -} - -class NoOpSdkSelectionPanel : SdkSelectionPanelBase() { - override val sdkSelectionPanel: JComponent = JPanel() - - override val sdkSelectionLabel: JLabel? = null -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ApplicationThreadPoolScope.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ApplicationThreadPoolScope.kt deleted file mode 100644 index ab664fe297..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ApplicationThreadPoolScope.kt +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.utils - -import com.intellij.application.ApplicationThreadPool -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob - -/** - * A supervisor coroutine scope that runs everything on the application thread pool. - * - * see: [com.intellij.openapi.application.Application.executeOnPooledThread] - */ -class ApplicationThreadPoolScope(coroutineName: String) : CoroutineScope { - // Dispatchers.ApplicationThreadPool Requires MIN 193.1822. However we cannot set our IDE min to that because not all JB IDEs use the same build numbers - override val coroutineContext = SupervisorJob() + Dispatchers.ApplicationThreadPool + CoroutineName(coroutineName) -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CachingAsyncEvaluator.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CachingAsyncEvaluator.kt index aa15954ddb..cc641c1c1b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CachingAsyncEvaluator.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CachingAsyncEvaluator.kt @@ -77,22 +77,6 @@ abstract class CachingAsyncEvaluator { return promise.blockingGet(blockingTime, blockingUnit)!! } - @Suppress("unused") - fun containsEntry(entry: TEntry): Boolean = - synchronized(lock) { - requests.containsKey(entry) - } - - @Suppress("unused") - fun cancelRequests() { - synchronized(lock) { - requests.forEach { request -> - (request.value as? AsyncPromise)?.cancel() - } - requests.clear() - } - } - fun clearRequests() { synchronized(lock) { requests.clear() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CompatibilityUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CompatibilityUtils.kt deleted file mode 100644 index 99918bdf48..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CompatibilityUtils.kt +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.utils - -/** - * A set of functions that attempt to abstract API differences that are incompatible between IDEA versions. - * - * This can act as a central place where said logic can be removed as min-version increases - */ -@Suppress("unused") -object CompatibilityUtils diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CoroutineUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CoroutineUtils.kt deleted file mode 100644 index 897a19ad00..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/CoroutineUtils.kt +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.utils - -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.AppUIExecutor -import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.impl.coroutineDispatchingContext - -fun getCoroutineUiContext( - modalityState: ModalityState = ModalityState.defaultModalityState(), - disposable: Disposable? = null -) = AppUIExecutor.onUiThread(modalityState).let { - if (disposable == null) { - it - } else { - // This is not actually scheduled for removal in 2019.3 - it.expireWith(disposable) - } -}.coroutineDispatchingContext() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/FeatureAvailabilityUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/FeatureAvailabilityUtils.kt index 6f234a33b8..210b994b17 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/FeatureAvailabilityUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/FeatureAvailabilityUtils.kt @@ -13,5 +13,3 @@ private fun availableInClassic(activeRegion: AwsRegion): Boolean = activeRegion. // technically available in govcloud but the api/console is broken fun lambdaTracingConfigIsAvailable(activeRegion: AwsRegion) = availableInClassic(activeRegion) && AwsRegionProvider.getInstance().isServiceSupported(activeRegion, "xray") - -fun cloudDebugIsAvailable(activeRegion: AwsRegion) = availableInClassic(activeRegion) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/FileInfoCache.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/FileInfoCache.kt index f59e78fa92..f00399cabd 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/FileInfoCache.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/FileInfoCache.kt @@ -18,8 +18,9 @@ import java.time.Instant abstract class FileInfoCache : CachingAsyncEvaluator>() { override fun getValue(entry: String): InfoResult { - if (!FileUtil.exists(entry)) + if (!FileUtil.exists(entry)) { throw IllegalStateException(message("general.file_not_found", entry)) + } return InfoResult(getFileInfo(entry), getLastModificationDate(entry)) } 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 ebf27db98d..7f97bed7a2 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt @@ -13,12 +13,13 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogBuilder +import com.intellij.openapi.util.text.StringUtil 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.credentials.ChangeSettingsMode +import software.aws.toolkits.jetbrains.core.credentials.ConfigureAwsConnectionAction import software.aws.toolkits.jetbrains.core.help.HelpIds import software.aws.toolkits.jetbrains.settings.AwsSettingsConfigurable import software.aws.toolkits.resources.message @@ -26,10 +27,12 @@ import javax.swing.JLabel import javax.swing.JTextArea private const val GROUP_DISPLAY_ID = "AWS Toolkit" +private const val GROUP_DISPLAY_ID_STICKY = "aws.toolkit_sticky" + private val LOG = getLogger() -fun Throwable.notifyError(title: String = "", project: Project? = null) { - val message = this.message ?: "${this::class.java.name}${this.stackTrace?.joinToString("\n", prefix = "\n")}" +fun Throwable.notifyError(title: String = "", project: Project? = null, stripHtml: Boolean = true) { + val message = getCleanedContent(this.message ?: "${this::class.java.name}${this.stackTrace?.joinToString("\n", prefix = "\n")}", stripHtml) LOG.warn(this) { title.takeIf { it.isNotBlank() }?.let { "$it ($message)" } ?: message } notify( Notification( @@ -37,7 +40,8 @@ fun Throwable.notifyError(title: String = "", project: Project? = null) { title, message, NotificationType.ERROR - ), project + ), + project ) } @@ -49,23 +53,63 @@ private fun notify(type: NotificationType, title: String, content: String = "", notify(notification, project) } -fun notifyInfo(title: String, content: String = "", project: Project? = null, listener: NotificationListener? = null) = - notify(Notification(GROUP_DISPLAY_ID, title, content, NotificationType.INFORMATION, listener), project) - -fun notifyInfo(title: String, content: String = "", project: Project? = null, notificationActions: Collection) = - notify(NotificationType.INFORMATION, title, content, project, notificationActions) - -fun notifyWarn(title: String, content: String = "", project: Project? = null, notificationActions: Collection) = - notify(NotificationType.WARNING, title, content, project, notificationActions) - -fun notifyWarn(title: String, content: String = "", project: Project? = null, listener: NotificationListener? = null) = - notify(Notification(GROUP_DISPLAY_ID, title, content, NotificationType.WARNING, listener), project) - -fun notifyError(title: String, content: String = "", project: Project? = null, action: AnAction) = - notify(NotificationType.ERROR, title, content, project, listOf(action)) +private fun notifySticky(type: NotificationType, title: String, content: String = "", project: Project? = null, notificationActions: Collection) { + val notification = Notification(GROUP_DISPLAY_ID_STICKY, title, content, type) + notificationActions.forEach { + notification.addAction(if (it !is NotificationAction) createNotificationExpiringAction(it) else it) + } + notify(notification, project) +} -fun notifyError(title: String = message("aws.notification.title"), content: String = "", project: Project? = null, listener: NotificationListener? = null) = - notify(Notification(GROUP_DISPLAY_ID, title, content, NotificationType.ERROR, listener), project) +fun notifyStickyInfo( + title: String, + content: String = "", + project: Project? = null, + notificationActions: Collection = listOf(), + stripHtml: Boolean = true +) = notifySticky(NotificationType.INFORMATION, title, getCleanedContent(content, stripHtml), project, notificationActions) + +fun notifyStickyWarn( + title: String, + content: String = "", + project: Project? = null, + notificationActions: Collection = listOf(), + stripHtml: Boolean = true +) = notifySticky(NotificationType.WARNING, title, getCleanedContent(content, stripHtml), project, notificationActions) + +fun notifyStickyError( + title: String, + content: String = "", + project: Project? = null, + notificationActions: Collection = listOf(), + stripHtml: Boolean = true +) = notifySticky(NotificationType.ERROR, title, getCleanedContent(content, stripHtml), project, notificationActions) + +fun notifyInfo(title: String, content: String = "", project: Project? = null, listener: NotificationListener? = null, stripHtml: Boolean = true) = + notify(Notification(GROUP_DISPLAY_ID, title, getCleanedContent(content, stripHtml), NotificationType.INFORMATION, listener), project) + +fun notifyInfo(title: String, content: String = "", project: Project? = null, notificationActions: Collection, stripHtml: Boolean = true) = + notify(NotificationType.INFORMATION, title, getCleanedContent(content, stripHtml), project, notificationActions) + +fun notifyWarn(title: String, content: String = "", project: Project? = null, notificationActions: Collection, stripHtml: Boolean = true) = + notify(NotificationType.WARNING, title, getCleanedContent(content, stripHtml), project, notificationActions) + +fun notifyWarn(title: String, content: String = "", project: Project? = null, listener: NotificationListener? = null, stripHtml: Boolean = true) = + notify(Notification(GROUP_DISPLAY_ID, title, getCleanedContent(content, stripHtml), NotificationType.WARNING, listener), project) + +fun notifyError(title: String, content: String = "", project: Project? = null, action: AnAction, stripHtml: Boolean = true) = + notify(NotificationType.ERROR, title, getCleanedContent(content, stripHtml), project, listOf(action)) + +fun notifyError(title: String, content: String = "", project: Project? = null, notificationActions: Collection, stripHtml: Boolean = true) = + notify(NotificationType.ERROR, title, getCleanedContent(content, stripHtml), project, notificationActions) + +fun notifyError( + title: String = message("aws.notification.title"), + content: String = "", + project: Project? = null, + listener: NotificationListener? = null, + stripHtml: Boolean = true +) = notify(Notification(GROUP_DISPLAY_ID, title, getCleanedContent(content, stripHtml), NotificationType.ERROR, listener), project) /** * Notify error that AWS credentials are not configured. @@ -79,7 +123,7 @@ fun notifyNoActiveCredentialsError( title = title, content = content, project = project, - action = SettingsSelectorAction(ChangeAccountSettingsMode.CREDENTIALS) + action = ConfigureAwsConnectionAction(ChangeSettingsMode.CREDENTIALS) ) } @@ -98,7 +142,8 @@ fun notifySamCliNotValidError( listener = NotificationListener { notification, _ -> ShowSettingsUtil.getInstance().showSettingsDialog(project, AwsSettingsConfigurable::class.java) notification.expire() - } + }, + stripHtml = false ) } @@ -147,3 +192,5 @@ fun createShowMoreInfoDialogAction(actionName: String?, title: String?, message: dialogBuilder.show() } } + +private fun getCleanedContent(content: String, stripHtml: Boolean): String = if (stripHtml) StringUtil.stripHtml(content, true) else content diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ProcessUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ProcessUtils.kt new file mode 100644 index 0000000000..7c61ee9a5b --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ProcessUtils.kt @@ -0,0 +1,20 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.utils + +import com.intellij.execution.process.ProcessOutput +import org.slf4j.Logger +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.info + +fun ProcessOutput.checkSuccess(logger: Logger): Boolean { + val code = exitCode + if (code == 0 && !isTimeout) { + return true + } + logger.info { if (isTimeout) "Timed out" else "Exit code $code" } + logger.debug { stderr.takeIf { it.isNotEmpty() } ?: stdout } + + return false +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/PsiUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/PsiUtils.kt new file mode 100644 index 0000000000..b1e1811c84 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/PsiUtils.kt @@ -0,0 +1,27 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.utils + +import com.intellij.injected.editor.VirtualFileWindow +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.psi.PsiElement + +fun PsiElement.isTestOrInjectedText(): Boolean { + val project = this.project + val virtualFile = this.containingFile.virtualFile ?: return false + if (this.isInjectedText() || ProjectRootManager.getInstance(project).fileIndex.isInTestSourceContent(virtualFile)) { + return true + } + + return false +} + +fun PsiElement.isInjectedText(): Boolean { + val virtualFile = this.containingFile.virtualFile ?: return false + if (virtualFile is VirtualFileWindow) { + return true + } + + return false +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/RemoteEnvUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/RemoteEnvUtils.kt new file mode 100644 index 0000000000..ed50d71b71 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/RemoteEnvUtils.kt @@ -0,0 +1,14 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.utils + +import com.intellij.openapi.extensions.ExtensionNotApplicableException + +fun isRunningOnRemoteBackend() = System.getenv("REMOTE_DEV_LAUNCHER_NAME_FOR_USAGE") != null + +fun disableExtensionIfRemoteBackend() { + if (isRunningOnRemoteBackend()) { + throw ExtensionNotApplicableException.create() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ResourceOperationAgainstCodePipeline.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ResourceOperationAgainstCodePipeline.kt deleted file mode 100644 index 6a398cab30..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ResourceOperationAgainstCodePipeline.kt +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.utils - -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.runInEdt -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.Messages -import com.intellij.openapi.ui.showYesNoDialog -import software.amazon.awssdk.services.resourcegroupstaggingapi.ResourceGroupsTaggingApiClient -import software.amazon.awssdk.services.resourcegroupstaggingapi.model.GetResourcesRequest -import software.amazon.awssdk.services.resourcegroupstaggingapi.model.TagFilter -import software.aws.toolkits.core.utils.tryOrNull -import software.aws.toolkits.jetbrains.core.awsClient -import software.aws.toolkits.resources.message - -const val CODEPIPELINE_SYSTEM_TAG_KEY = "aws:codepipeline:pipelineArn" - -/** - * @property tagFilter used by ResourceGroupsTaggingApi to filter response by ResourceType (resourceTypeFilter) - */ -enum class TaggingResourceType(val value: String, val tagFilter: String) { - LAMBDA_FUNCTION(message("codepipeline.lambda.resource_type"), "lambda:function"), - CLOUDFORMATION_STACK(message("codepipeline.stack.resource_type"), "cloudformation:stack"), - S3_BUCKET(message("codepipeline.bucket.resource_type"), "s3"), - CLOUDWATCHLOGS_GROUP(message("codepipeline.cloudwatch_group.resource_type"), "logs:log-group"); - - override fun toString() = value -} - -enum class Operation(val value: String) { - UPDATE(message("codepipeline.resource.operation.update")), - DELETE(message("codepipeline.resource.operation.delete")), - DEPLOY(message("codepipeline.resource.operation.deploy")); - - override fun toString() = value -} - -/** - * Warn user against this operation if the resource is part of an AWS CodePipeline. - * Run callback only if user chooses to continue with this operation from the warning dialog. - * (Run network call off UI thread and callback on UI thread) - */ -fun warnResourceOperationAgainstCodePipeline( - project: Project, - resourceName: String, - resourceArn: String, - resourceType: TaggingResourceType, - operation: Operation, - callback: () -> Unit -) { - ApplicationManager.getApplication().executeOnPooledThread { - val codePipelineArn = getCodePipelineArnForResource(project, resourceArn, resourceType.tagFilter) - - runInEdt { - var shouldCallbackRun = true - if (codePipelineArn != null) { - val title = message("codepipeline.resource.update.warning.title") - val message = message("codepipeline.resource.update.warning.message", resourceType, resourceName, codePipelineArn, operation) - val noText = message("codepipeline.resource.update.warning.no_text") - val yesText = message("codepipeline.resource.update.warning.yes_text") - shouldCallbackRun = !showYesNoDialog(title, message, project, noText, yesText, Messages.getWarningIcon()) - } - if (shouldCallbackRun) { - callback() - } - } - } -} - -fun getCodePipelineArnForResource(project: Project, resourceArn: String, resourceTypeFilter: String): String? { - val client: ResourceGroupsTaggingApiClient = project.awsClient() - - val tagFilter = TagFilter.builder().key(CODEPIPELINE_SYSTEM_TAG_KEY).build() - val request = GetResourcesRequest.builder().tagFilters(tagFilter).resourceTypeFilters(resourceTypeFilter).build() - - return tryOrNull { - client.getResourcesPaginator(request).resourceTagMappingList().filterNotNull() - .filter { it.resourceARN() == resourceArn } - .mapNotNull { it.tags().filterNotNull().find { it.key() == CODEPIPELINE_SYSTEM_TAG_KEY }?.value() } - .firstOrNull() - } -} 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 00b7a79d80..8e4effb154 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/SpinUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/SpinUtils.kt @@ -6,32 +6,37 @@ package software.aws.toolkits.jetbrains.utils import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.time.Duration +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicReference /** * Keeps checking the condition until the max duration as been reached. Checks every 100ms */ -fun spinUntil(duration: Duration, condition: () -> Boolean) { +fun spinUntil(duration: Duration, interval: Duration = Duration.ofMillis(100), condition: () -> Boolean) { val start = System.nanoTime() runBlocking { while (!condition()) { - if (System.nanoTime() - start > duration.toNanos()) - throw IllegalStateException("Condition not reached within $duration") - delay(100) + if (System.nanoTime() - start > duration.toNanos()) { + throw TimeoutException("Condition not reached within $duration") + } + delay(interval.toMillis()) } } } /** - * Keeps running the function until it returns a non-null value. Checks every 100ms + * Keeps checking the block until the max duration as been reached or a non-null value has been returned. 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 +fun spinUntilValue(duration: Duration, interval: Duration = Duration.ofMillis(100), block: () -> T?): T { + val ref = AtomicReference() + spinUntil(duration, interval) { + val value = block() + if (value == null) { + return@spinUntil false + } else { + ref.set(value) + return@spinUntil true } - - delay(100) } - throw IllegalStateException("Function did not return value within $duration") + return ref.get() } diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/TextUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/TextUtils.kt index 5ce4a858f8..598937824f 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/TextUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/TextUtils.kt @@ -5,10 +5,19 @@ package software.aws.toolkits.jetbrains.utils import com.intellij.lang.Language import com.intellij.openapi.command.CommandProcessor +import com.intellij.openapi.diff.impl.patch.PatchReader +import com.intellij.openapi.diff.impl.patch.TextFilePatch +import com.intellij.openapi.diff.impl.patch.apply.PlainSimplePatchApplier import com.intellij.openapi.project.Project import com.intellij.openapi.util.text.StringUtil import com.intellij.psi.PsiFileFactory import com.intellij.psi.codeStyle.CodeStyleManager +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.Node +import org.commonmark.parser.Parser +import org.commonmark.renderer.NodeRenderer +import org.commonmark.renderer.html.HtmlRenderer +import org.commonmark.renderer.html.HtmlWriter fun formatText(project: Project, language: Language, content: String): String { var result = content @@ -22,8 +31,62 @@ fun formatText(project: Project, language: Language, content: String): String { return result } +fun convertMarkdownToHTML(markdown: String): String { + val parser: Parser = Parser.builder().build() + val document: Node = parser.parse(markdown) + val htmlRenderer: HtmlRenderer = HtmlRenderer.builder().nodeRendererFactory { CodeBlockRenderer(it.writer) }.build() + return htmlRenderer.render(document) +} + /** * Designed to convert underscore separated words (e.g. UPDATE_COMPLETE) into title cased human readable text * (e.g. Update Complete) */ fun String.toHumanReadable() = StringUtil.toTitleCase(toLowerCase().replace('_', ' ')) + +class CodeBlockRenderer(private val html: HtmlWriter) : NodeRenderer { + override fun getNodeTypes(): Set> = setOf(FencedCodeBlock::class.java) + override fun render(node: Node?) { + val codeBlock = node as FencedCodeBlock + val language = codeBlock.info + + html.line() + html.tag("div", mapOf("class" to "code-block")) + + if (language == "diff") { + codeBlock.literal.lines().forEach { + when { + it.startsWith("-") -> html.tag("div", mapOf("class" to "deletion")) + it.startsWith("+") -> html.tag("div", mapOf("class" to "addition")) + it.startsWith("@@") -> html.tag("div", mapOf("class" to "meta")) + else -> html.tag("div") + } + html.tag("pre") + html.text(it) + html.tag("/pre") + html.tag("/div") + } + } else { + html.tag("pre") + html.tag("code", mapOf("class" to "language-$language")) + html.text(codeBlock.literal) + html.tag("/code") + html.tag("/pre") + } + + html.tag("/div") + html.line() + } +} + +fun generateUnifiedPatch(patch: String, filePath: String): TextFilePatch { + val unifiedPatch = "--- $filePath\n+++ $filePath\n$patch" + val patchReader = PatchReader(unifiedPatch) + val patches = patchReader.readTextPatches() + return patches[0] +} + +fun applyPatch(patch: String, fileContent: String, filePath: String): String? { + val unifiedPatch = generateUnifiedPatch(patch, filePath) + return PlainSimplePatchApplier.apply(fileContent, unifiedPatch.hunks) +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ThreadingUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ThreadingUtils.kt new file mode 100644 index 0000000000..e69f31400e --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ThreadingUtils.kt @@ -0,0 +1,74 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.utils + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.util.ProgressIndicatorUtils +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Ref +import com.intellij.openapi.util.ThrowableComputable +import com.intellij.util.ExceptionUtil +import com.intellij.util.concurrency.AppExecutorUtil +import com.intellij.util.concurrency.Semaphore +import java.time.Duration +import java.util.concurrent.TimeUnit + +// There is a new/experimental API in IJ SDK, but replicate a simpler one here till we can use it +fun assertIsNonDispatchThread() { + if (!ApplicationManager.getApplication().isDispatchThread) return + throw RuntimeException("Access from event dispatch thread is not allowed.") +} + +fun runUnderProgressIfNeeded(project: Project?, title: String, cancelable: Boolean, task: () -> T): T = + if (ApplicationManager.getApplication().isDispatchThread) { + ProgressManager.getInstance().runProcessWithProgressSynchronously(ThrowableComputable { task.invoke() }, title, cancelable, project) + } else { + task.invoke() + } + +fun computeOnEdt(modalityState: ModalityState = ModalityState.any(), supplier: () -> T): T { + val application = ApplicationManager.getApplication() + if (application.isDispatchThread) { + return supplier.invoke() + } + val indicator = ProgressManager.getInstance().progressIndicator + val semaphore = Semaphore(1) + val result = Ref.create() + val error = Ref.create() + val runnable = Runnable { + try { + if (indicator == null || !indicator.isCanceled) { + result.set(supplier.invoke()) + } + } catch (ex: Throwable) { + error.set(ex) + } finally { + semaphore.up() + } + } + + ApplicationManager.getApplication().invokeLater(runnable, modalityState) + + ProgressIndicatorUtils.awaitWithCheckCanceled(semaphore, indicator) + ExceptionUtil.rethrowAllAsUnchecked(error.get()) + + return result.get() +} + +fun sleepWithCancellation(sleepAmount: Duration, indicator: ProgressIndicator?) { + val semaphore = Semaphore(1) + val future = AppExecutorUtil.getAppScheduledExecutorService().schedule( + { semaphore.up() }, + sleepAmount.toMillis(), + TimeUnit.MILLISECONDS + ) + try { + ProgressIndicatorUtils.awaitWithCheckCanceled(semaphore, indicator) + } finally { + future.cancel(true) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/YamlWriter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/YamlWriter.kt index 9389c4c86c..820647c6d1 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/YamlWriter.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/YamlWriter.kt @@ -20,12 +20,16 @@ class YamlWriter internal constructor() { appendIndent().append("$key: $value\n") } + fun listValue(value: String) { + appendIndent().append("- $value\n") + } + private fun appendIndent() = stringBuilder.append(" ".repeat(indentLevel)) override fun toString() = stringBuilder.toString().trimEnd() } -fun yamlWriter(init: YamlWriter.() -> Unit): String { +fun yaml(init: YamlWriter.() -> Unit): String { val yaml = YamlWriter() yaml.init() return yaml.toString() diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ZipDecompressor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ZipDecompressor.kt index 3f2c239ce9..2e1858dc7e 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ZipDecompressor.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ZipDecompressor.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.io.FileUtil import org.apache.commons.compress.archivers.zip.ZipArchiveEntry import org.apache.commons.compress.archivers.zip.ZipFile +import org.apache.commons.compress.utils.SeekableInMemoryByteChannel import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -14,8 +15,8 @@ import java.nio.file.Files import java.nio.file.attribute.PosixFilePermission // TODO: Write tests -class ZipDecompressor(sourceFile: File) : AutoCloseable { - private val zipFile = ZipFile(sourceFile) +class ZipDecompressor(sourceBytes: ByteArray) : AutoCloseable { + private val zipFile = ZipFile(SeekableInMemoryByteChannel(sourceBytes)) private val zipEntries = zipFile.entries.toList() private val directorySplitRegex = Regex.fromLiteral("""[/\\]""") diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/actions/ComputableActionGroup.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/actions/ComputableActionGroup.kt deleted file mode 100644 index 8e3304febe..0000000000 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/actions/ComputableActionGroup.kt +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.utils.actions - -import com.intellij.openapi.actionSystem.ActionGroup -import com.intellij.openapi.actionSystem.ActionManager -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.psi.util.CachedValue -import com.intellij.psi.util.CachedValueProvider -import com.intellij.util.CachedValueImpl - -abstract class ComputableActionGroup : ActionGroup { - constructor() - constructor(shortName: String, popup: Boolean) : super(shortName, popup) {} - - private lateinit var children: CachedValue> - - override fun getChildren(e: AnActionEvent?): Array { - if (!this::children.isInitialized) { - children = CachedValueImpl(createChildrenProvider(e?.actionManager)) - } - return children.value - } - - protected abstract fun createChildrenProvider(actionManager: ActionManager?): CachedValueProvider> -} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/BuildViewStepEmitter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/BuildViewStepEmitter.kt new file mode 100644 index 0000000000..4b0e2ec8f3 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/BuildViewStepEmitter.kt @@ -0,0 +1,198 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.utils.execution.steps + +import com.intellij.build.BuildProgressListener +import com.intellij.build.BuildViewManager +import com.intellij.build.DefaultBuildDescriptor +import com.intellij.build.events.impl.FailureResultImpl +import com.intellij.build.events.impl.FinishBuildEventImpl +import com.intellij.build.events.impl.FinishEventImpl +import com.intellij.build.events.impl.OutputBuildEventImpl +import com.intellij.build.events.impl.SkippedResultImpl +import com.intellij.build.events.impl.StartBuildEventImpl +import com.intellij.build.events.impl.StartEventImpl +import com.intellij.build.events.impl.SuccessResultImpl +import com.intellij.openapi.components.service +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.project.Project +import com.intellij.util.ExceptionUtil +import software.aws.toolkits.resources.message + +class BuildViewWorkflowEmitter private constructor( + private val buildListener: BuildProgressListener, + private val workflowTitle: String, + private val workflowId: String +) : WorkflowEmitter { + override fun createStepEmitter(): StepEmitter = BuildViewStepEmitter.createRoot(buildListener, workflowId) + + override fun workflowStarted() { + val descriptor = DefaultBuildDescriptor( + workflowId, + workflowTitle, + "/unused/working/directory", + System.currentTimeMillis() + ).apply { + isActivateToolWindowWhenAdded = true + withProcessHandler(processHandler, null) + } + + buildListener.onEvent(workflowId, StartBuildEventImpl(descriptor, message("general.execution.running"))) + } + + override fun workflowFailed(e: Throwable) { + val message = if (e is ProcessCanceledException) { + message("general.execution.canceled") + } else { + message("general.execution.failed") + } + + buildListener.onEvent( + workflowId, + FinishBuildEventImpl( + workflowId, + null, + System.currentTimeMillis(), + message, + if (e is ProcessCanceledException) SkippedResultImpl() else FailureResultImpl() + ) + ) + } + + override fun workflowCompleted() { + buildListener.onEvent( + workflowId, + FinishBuildEventImpl( + workflowId, + null, + System.currentTimeMillis(), + message("general.execution.success"), + SuccessResultImpl() + ) + ) + } + + companion object { + fun createEmitter(buildListener: BuildProgressListener, workflowTitle: String, workflowId: String) = + BuildViewWorkflowEmitter(buildListener, workflowTitle, workflowId) + + fun createEmitter(project: Project, workflowTitle: String, workflowId: String) = + BuildViewWorkflowEmitter(project.service(), workflowTitle, workflowId) + } +} + +class BuildViewStepEmitter private constructor( + private val buildListener: BuildProgressListener, + private val rootObject: Any, + private val parentId: String, + private val stepName: String, + private val hidden: Boolean, + private val parent: StepEmitter? +) : StepEmitter { + override fun createChildEmitter(stepName: String, hidden: Boolean): StepEmitter { + val (childParent, childStepName) = if (hidden) { + parentId to this.stepName + } else { + this.stepName to stepName + } + return BuildViewStepEmitter(buildListener, rootObject, childParent, childStepName, hidden, this) + } + + override fun stepStarted() { + if (hidden) return + + buildListener.onEvent( + rootObject, + StartEventImpl( + stepName, + parentId, + System.currentTimeMillis(), + stepName + ) + ) + } + + override fun stepSkipped() { + if (hidden) return + + buildListener.onEvent( + rootObject, + FinishEventImpl( + stepName, + parentId, + System.currentTimeMillis(), + stepName, + SkippedResultImpl() + ) + ) + } + + override fun stepFinishSuccessfully() { + if (hidden) return + + buildListener.onEvent( + rootObject, + FinishEventImpl( + stepName, + parentId, + System.currentTimeMillis(), + stepName, + SuccessResultImpl() + ) + ) + } + + override fun stepFinishExceptionally(e: Throwable) { + if (e is ProcessCanceledException) { + emitMessage(message("general.step.canceled", stepName), true) + } else { + emitMessage( + message( + "general.step.failed", + stepName, + ExceptionUtil.getNonEmptyMessage(e, ExceptionUtil.getThrowableText(e)) + ), + true + ) + } + if (hidden) return + + buildListener.onEvent( + rootObject, + FinishEventImpl( + stepName, + parentId, + System.currentTimeMillis(), + stepName, + if (e is ProcessCanceledException) SkippedResultImpl() else FailureResultImpl() + ) + ) + } + + override fun emitMessage(message: String, isError: Boolean) { + parent?.emitMessage(message, isError) + if (hidden) return + buildListener.onEvent( + rootObject, + OutputBuildEventImpl( + stepName, + message, + !isError + ) + ) + } + + companion object { + // TODO: Decouple step name from the build ID + fun createRoot(buildListener: BuildProgressListener, rootStepName: String): StepEmitter = + BuildViewStepEmitter( + buildListener, + rootStepName, + rootStepName, + rootStepName, + hidden = false, + parent = null + ) + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/CliBasedStep.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/CliBasedStep.kt new file mode 100644 index 0000000000..9f58b79595 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/CliBasedStep.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.utils.execution.steps + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessAdapter +import com.intellij.execution.process.KillableColoredProcessHandler +import com.intellij.execution.process.ProcessAdapter +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessListener +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.util.Key +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Result +import java.time.Instant + +abstract class CliBasedStep : Step() { + protected abstract fun constructCommandLine(context: Context): GeneralCommandLine? + protected open fun recordTelemetry(context: Context, startTime: Instant, result: Result) {} + protected open fun onProcessStart(context: Context, processHandler: ProcessHandler) {} + protected open fun createProcessEmitter(stepEmitter: StepEmitter): ProcessListener = CliOutputEmitter(stepEmitter) + + protected open fun handleSuccessResult(output: String, stepEmitter: StepEmitter, context: Context) {} + + /** + * Processes the command's stdout and throws an exception after the CLI exits with failure. + * @return null if the failure should be ignored. You're probably doing something wrong if you want this. + */ + protected open fun handleErrorResult(exitCode: Int, output: String, stepEmitter: StepEmitter) { + throw IllegalStateException(message("general.execution.cli_error", exitCode)) + } + + final override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) { + val startTime = Instant.now() + var result = Result.Succeeded + try { + val commandLine = constructCommandLine(context) + if (commandLine == null) { + LOG.debug { "Command line not built, skipping step" } + throw SkipStepException() + } + + LOG.debug { "Built command line: ${commandLine.commandLineString}" } + + val processHandler = createProcessHandler(commandLine, context) + val processCapture = CapturingProcessAdapter() + processHandler.addProcessListener(processCapture) + processHandler.addProcessListener(createProcessEmitter(stepEmitter)) + stepEmitter.attachProcess(processHandler) + processHandler.startNotify() + + monitorProcess(processHandler, context, ignoreCancellation) + + if (!ignoreCancellation) { + context.throwIfCancelled() + } + + if (processHandler.exitCode == 0) { + handleSuccessResult(processCapture.output.stdout, stepEmitter, context) + return + } + + handleErrorResult(processCapture.output.exitCode, processCapture.output.stdout, stepEmitter) + } catch (e: SkipStepException) { + LOG.debug(e) { """Step "$stepName" skipped!""" } + } catch (e: ProcessCanceledException) { + LOG.debug(e) { """Step "$stepName" cancelled!""" } + result = Result.Cancelled + } catch (e: Exception) { + result = Result.Failed + throw e + } finally { + recordTelemetry(context, startTime, result) + } + } + + protected open fun createProcessHandler(commandLine: GeneralCommandLine, context: Context): ProcessHandler = + object : KillableColoredProcessHandler(commandLine) { + override fun startNotify() { + super.startNotify() + onProcessStart(context, this) + } + + override fun doDestroyProcess() { + // send signal only if user explicitly requests termination + if (this.getUserData(ProcessHandler.TERMINATION_REQUESTED) == true) { + super.doDestroyProcess() + } else { + detachProcess() + } + } + } + + protected class CliOutputEmitter(private val messageEmitter: StepEmitter, private val printStdOut: Boolean = true) : ProcessAdapter() { + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + LOG.debug { + val prefix = if (outputType == ProcessOutputTypes.STDERR) { + "[STDERR]" + } else { + "[STDOUT]" + } + + "$prefix ${event.text.trim()}" + } + + if (outputType == ProcessOutputTypes.STDOUT) { + if (printStdOut) { + messageEmitter.emitMessage(event.text, isError = false) + } + } else { + messageEmitter.emitMessage(event.text, outputType == ProcessOutputTypes.STDERR) + } + } + } + + private fun monitorProcess(processHandler: ProcessHandler, context: Context, ignoreCancellation: Boolean) { + while (!processHandler.waitFor(WAIT_INTERVAL_MILLIS)) { + if (!ignoreCancellation && context.isCancelled()) { + if (!processHandler.isProcessTerminating && !processHandler.isProcessTerminated) { + processHandler.putUserData(ProcessHandler.TERMINATION_REQUESTED, true) + processHandler.destroyProcess() + } + } + } + } + + private companion object { + const val WAIT_INTERVAL_MILLIS = 100L + private val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/Context.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/Context.kt new file mode 100644 index 0000000000..994a9ae2cc --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/Context.kt @@ -0,0 +1,104 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.utils.execution.steps + +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import software.aws.toolkits.core.utils.AttributeBag +import software.aws.toolkits.core.utils.AttributeBagKey +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Cross step context that exists for the life of a step workflow execution. Keeps track of the global execution state and allows passing data between steps. + */ +class Context { + interface Listener { + fun onCancel() {} + fun onComplete() {} + } + + val workflowToken = UUID.randomUUID().toString() + private val attributeMap = AttributeBag() + private val isCancelled = AtomicBoolean(false) + private val isCompleted = AtomicBoolean(false) + private val listeners = mutableListOf() + + fun cancel() { + if (isCompleted()) { + return + } + + isCancelled.set(true) + isCompleted.set(true) + + listeners.forEach { + LOG.tryOrNull("Context callback failed") { + it.onCancel() + } + } + } + + fun complete() { + if (isCompleted()) { + return + } + + isCompleted.set(true) + + listeners.forEach { + LOG.tryOrNull("Context callback failed") { + it.onComplete() + } + } + } + + fun addListener(listener: Listener) { + listeners.add(listener) + } + + fun isCancelled() = isCancelled.get() + fun isCompleted() = isCompleted.get() + + fun throwIfCancelled() { + if (isCancelled()) { + throw ProcessCanceledException() + } + } + + fun getAttribute(key: AttributeBagKey): T? = attributeMap.get(key) + + /** + * Try to get attribute for timeout milliseconds, throws a kotlinx.coroutines.TimeoutCancellationException on failure + * @param key The key to try to get + * @param timeout The timeout in milliseconds + */ + suspend fun pollingGet(key: AttributeBagKey, timeout: Long = 10000): T = withTimeout(timeout) { + while (!isCancelled()) { + val item = attributeMap.get(key) + if (item != null) { + return@withTimeout item + } + delay(100) + } + throw CancellationException("getAttributeOrWait cancelled") + } + + fun getRequiredAttribute(key: AttributeBagKey): T = attributeMap.getOrThrow(key) + + fun putAttribute(key: AttributeBagKey, data: T) { + attributeMap.putData(key, data) + } + + companion object { + private val LOG = getLogger() + + val PROJECT_ATTRIBUTE = AttributeBagKey.create("project") + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/MessageEmitter.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/MessageEmitter.kt new file mode 100644 index 0000000000..3b0eb26676 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/MessageEmitter.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.jetbrains.utils.execution.steps + +import com.intellij.execution.process.ProcessHandler + +interface WorkflowEmitter { + fun createStepEmitter(): StepEmitter + fun workflowStarted() {} + fun workflowCompleted() {} + fun workflowFailed(e: Throwable) {} +} + +interface StepEmitter { + fun createChildEmitter(stepName: String, hidden: Boolean): StepEmitter + fun stepStarted() {} + fun stepSkipped() {} + fun stepFinishSuccessfully() {} + fun stepFinishExceptionally(e: Throwable) {} + fun emitMessage(message: String, isError: Boolean) {} + fun emitMessageLine(message: String, isError: Boolean) = emitMessage("$message\n", isError) + fun attachProcess(handler: ProcessHandler) {} +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/ParallelStep.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/ParallelStep.kt new file mode 100644 index 0000000000..a79adf6c38 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/ParallelStep.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.utils.execution.steps + +import com.intellij.openapi.application.ApplicationManager +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException + +/** + * [Step] that creates multiple child steps and runs them in parallel waiting on the result. + * + * It can optionally hide itself from the tree. If hidden, it acts as just a logical parent. + * If shown, it shows itself as a parent node in the tree to its children. + */ +abstract class ParallelStep : Step() { + private inner class ChildStep(val future: CompletableFuture<*>) + + private val listOfChildTasks = mutableListOf() + + override val hidden = true + + protected abstract fun buildChildSteps(context: Context): List + + final override fun execute( + context: Context, + messageEmitter: StepEmitter, + ignoreCancellation: Boolean + ) { + buildChildSteps(context).forEach { + val stepFuture = CompletableFuture() + listOfChildTasks.add(ChildStep(stepFuture)) + + ApplicationManager.getApplication().executeOnPooledThread { + try { + it.run(context, messageEmitter, ignoreCancellation) + stepFuture.complete(null) + } catch (e: Throwable) { + stepFuture.completeExceptionally(e) + } + } + } + + try { + CompletableFuture.allOf(*listOfChildTasks.map { it.future }.toTypedArray()).join() + } catch (e: CompletionException) { + throw e.cause ?: e + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/Step.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/Step.kt new file mode 100644 index 0000000000..cfa77352b5 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/Step.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.utils.execution.steps + +import com.intellij.openapi.progress.ProcessCanceledException +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info + +abstract class Step { + public abstract val stepName: String + protected open val hidden: Boolean = false + + fun run(context: Context, parentEmitter: StepEmitter, ignoreCancellation: Boolean = false) { + if (!ignoreCancellation) { + context.throwIfCancelled() + } + + // If we are not hidden, we will create a new factory so that the parent node is correct, else pass the current factory so in effect + // this node does not exist in the hierarchy + val stepEmitter = parentEmitter.createChildEmitter(stepName, hidden) + stepEmitter.stepStarted() + try { + execute(context, stepEmitter, ignoreCancellation) + + stepEmitter.stepFinishSuccessfully() + } catch (e: SkipStepException) { + stepEmitter.stepSkipped() + } catch (e: Throwable) { + LOG.info(e) { "Step $stepName failed" } + stepEmitter.stepFinishExceptionally(e) + throw e + } + } + + protected abstract fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) + + /** + * Exception used to abort out of a step and mark it as skipped. This may be used in the case that a step has to make a decision before it decides it + * wants to run in the workflow. This differs from throwing a [ProcessCanceledException] which will terminate the workflow. + */ + protected class SkipStepException : RuntimeException() + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/StepExecutor.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/StepExecutor.kt new file mode 100644 index 0000000000..b4ed0ae886 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/StepExecutor.kt @@ -0,0 +1,154 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.utils.execution.steps + +import com.intellij.execution.process.ProcessHandler +import com.intellij.execution.process.ProcessOutputType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.AttributeBagKey +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.tryOrNull +import java.io.OutputStream + +/** + * Executor for a [StepWorkflow] + */ +class StepExecutor( + project: Project?, + private val workflow: StepWorkflow, + private val messageEmitter: WorkflowEmitter +) { + var onSuccess: ((Context) -> Unit)? = null + var onError: ((Throwable) -> Unit)? = null + + private val context = Context() + private val processHandler = StepExecutorProcessHandler(context) + + init { + project?.let { context.putAttribute(Context.PROJECT_ATTRIBUTE, project) } + } + + /** + * Returns a pseudo process handler that coincides with the life cycle of the StepExecutors workflow + * + * Can also be used to give the ownership of the lifecycle over to a 3rd party such as a Run Config + */ + fun getProcessHandler(): ProcessHandler = processHandler + + /** + * Starts the workflow if not already started + */ + fun startExecution(): ProcessHandler { + if (!processHandler.isStartNotified) { + processHandler.startNotify() + } + + return processHandler + } + + fun addContext(key: AttributeBagKey, value: T) = context.putAttribute(key, value) + + private fun startWorkflow() { + ApplicationManager.getApplication().executeOnPooledThread { + execute(context, WorkflowEmitterWrapper(messageEmitter), processHandler) + } + } + + private fun execute(context: Context, messageEmitter: WorkflowEmitter, processHandler: StepExecutorProcessHandler) { + try { + executionStarted() + workflow.run(context, messageEmitter.createStepEmitter()) + + // If the dummy process was cancelled (or any step got cancelled), we need to rethrow the cancel + context.throwIfCancelled() + + onSuccess?.invoke(context) + context.complete() + executionFinishedSuccessfully(processHandler) + } catch (e: Throwable) { + context.cancel() + LOG.tryOrNull("Failed to invoke error callback") { + onError?.invoke(e) + } + executionFinishedExceptionally(processHandler, e) + } + } + + private fun executionStarted() { + LOG.tryOrNull("Failed to invoke error workflowStarted") { + messageEmitter.workflowStarted() + } + } + + private fun executionFinishedSuccessfully(processHandler: StepExecutorProcessHandler) { + LOG.tryOrNull("Failed to invoke error workflowCompleted") { + messageEmitter.workflowCompleted() + } + + processHandler.notifyProcessTerminated(0) + } + + private fun executionFinishedExceptionally(processHandler: StepExecutorProcessHandler, e: Throwable) { + LOG.tryOrNull("Failed to invoke error workflowFailed") { + messageEmitter.workflowFailed(e) + } + + processHandler.notifyProcessTerminated(1) + } + + /** + * Wraps the [WorkflowEmitter] so we can pass messages back to the [ProcessHandler] + */ + private inner class WorkflowEmitterWrapper(private val delegate: WorkflowEmitter) : WorkflowEmitter by delegate { + override fun createStepEmitter(): StepEmitter = StepEmitterWrapper(delegate.createStepEmitter()) + } + + /** + * Wraps the [StepEmitter] so we can pass messages back to the [ProcessHandler] + */ + private inner class StepEmitterWrapper(private val delegate: StepEmitter) : StepEmitter by delegate { + override fun createChildEmitter(stepName: String, hidden: Boolean): StepEmitter = StepEmitterWrapper(delegate.createChildEmitter(stepName, hidden)) + + override fun emitMessage(message: String, isError: Boolean) { + if (ApplicationManager.getApplication().isUnitTestMode) { + print(message) + } + + delegate.emitMessage(message, isError) + processHandler.notifyTextAvailable(message, if (isError) ProcessOutputType.STDERR else ProcessOutputType.STDOUT) + } + } + + /** + * Fake process handle that acts as the "wrapper" for all steps. Any cancelled step will "kill" this process and vice versa. + * For example, in a run config this process handle can be tied to the red Stop button thus stopping it cancels the step execution flow. + */ + private inner class StepExecutorProcessHandler(private val context: Context) : ProcessHandler() { + override fun startNotify() { + startWorkflow() + super.startNotify() + } + + override fun getProcessInput(): OutputStream? = null + + override fun detachIsDefault() = false + + override fun detachProcessImpl() { + destroyProcessImpl() + } + + override fun destroyProcessImpl() { + context.cancel() + } + + public override fun notifyProcessTerminated(exitCode: Int) { + super.notifyProcessTerminated(exitCode) + } + } + + private companion object { + val LOG = getLogger() + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/StepWorkflow.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/StepWorkflow.kt new file mode 100644 index 0000000000..2c4f10eba4 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/execution/steps/StepWorkflow.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.utils.execution.steps + +/** + * This is the hidden step that is the root of the tree of [Step] in the workflow. The children [topLevelSteps] are ran sequentially. + */ +open class StepWorkflow(protected val topLevelSteps: List) : Step() { + constructor(vararg topLevelSteps: Step) : this(topLevelSteps.toList()) + + override val stepName = "StepWorkflow" + override val hidden = true + + override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) { + topLevelSteps.forEach { + it.run(context, stepEmitter) + } + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/ResizingColumnRenderer.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/ResizingColumnRenderer.kt new file mode 100644 index 0000000000..b9468289f9 --- /dev/null +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/ResizingColumnRenderer.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.utils.ui + +import com.intellij.ui.SimpleColoredRenderer +import java.awt.BorderLayout +import java.awt.Component +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JTable +import javax.swing.border.CompoundBorder +import javax.swing.table.DefaultTableCellRenderer +import javax.swing.table.TableCellRenderer + +abstract class ResizingColumnRenderer : TableCellRenderer, SimpleColoredRenderer() { + private val defaultRenderer = DefaultTableCellRenderer() + abstract fun getText(value: Any?): String? + + override fun getTableCellRendererComponent(table: JTable?, value: Any?, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { + // This wrapper will let us force the component to be at the top instead of in the middle for linewraps + val wrapper = JPanel(BorderLayout()) + + // this is basically what ColoredTableCellRenderer is doing, but we can't override getTableCellRendererComponent on that class + cellState.updateRenderer(defaultRenderer) + + val defaultComponent = defaultRenderer.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + if (table == null) { + return defaultComponent + } + val component = defaultComponent as? JLabel ?: return defaultComponent + + // This will set the component text accordingly + component.text = getText(value) + + if (component.preferredSize.width > table.columnModel.getColumn(column).preferredWidth) { + // add 3 pixels of padding. No padding makes it go into ... mode cutting off the end + table.columnModel.getColumn(column).preferredWidth = component.preferredSize.width + 3 + table.columnModel.getColumn(column).maxWidth = component.preferredSize.width + 3 + } + wrapper.add(component, BorderLayout.NORTH) + // Make sure the background matches for selection + wrapper.background = component.background + // if a component is selected, it puts a border on it, move the border to the wrapper instead + if (isSelected) { + // this border has an outside and inside border, take only the outside border + wrapper.border = (component.border as? CompoundBorder)?.outsideBorder + } + component.border = null + return wrapper + } +} diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/ScrollBarUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/ScrollBarUtils.kt index 3c94c8a485..c792645d74 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/ScrollBarUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/ScrollBarUtils.kt @@ -9,33 +9,37 @@ import javax.swing.JScrollBar import javax.swing.JScrollPane fun JScrollPane.topReached(block: () -> Unit) { - verticalScrollBar.addAdjustmentListener(object : AdjustmentListener { - var lastAdjustment = verticalScrollBar.minimum - override fun adjustmentValueChanged(e: AdjustmentEvent?) { - if (e == null || e.value == lastAdjustment) { - return - } - lastAdjustment = e.value - if (verticalScrollBar.isAtTop()) { - block() + verticalScrollBar.addAdjustmentListener( + object : AdjustmentListener { + var lastAdjustment = verticalScrollBar.minimum + override fun adjustmentValueChanged(e: AdjustmentEvent?) { + if (e == null || e.value == lastAdjustment) { + return + } + lastAdjustment = e.value + if (verticalScrollBar.isAtTop()) { + block() + } } } - }) + ) } fun JScrollPane.bottomReached(block: () -> Unit) { - verticalScrollBar.addAdjustmentListener(object : AdjustmentListener { - var lastAdjustment = verticalScrollBar.minimum - override fun adjustmentValueChanged(e: AdjustmentEvent?) { - if (e == null || e.value == lastAdjustment) { - return - } - lastAdjustment = e.value - if (verticalScrollBar.isAtBottom()) { - block() + verticalScrollBar.addAdjustmentListener( + object : AdjustmentListener { + var lastAdjustment = verticalScrollBar.minimum + override fun adjustmentValueChanged(e: AdjustmentEvent?) { + if (e == null || e.value == lastAdjustment) { + return + } + lastAdjustment = e.value + if (verticalScrollBar.isAtBottom()) { + block() + } } } - }) + ) } private fun JScrollBar.isAtBottom(): Boolean = value == (maximum - visibleAmount) diff --git a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/SearchFieldUtils.kt b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/SearchFieldUtils.kt index 6710b9f2f9..8e5ec4479b 100644 --- a/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/SearchFieldUtils.kt +++ b/jetbrains-core/src/software/aws/toolkits/jetbrains/utils/ui/SearchFieldUtils.kt @@ -10,31 +10,35 @@ import java.beans.PropertyChangeEvent import java.beans.PropertyChangeListener fun SearchTextField.onEmpty(block: () -> Unit) { - textEditor.addPropertyChangeListener(object : PropertyChangeListener { - private var lastText = "" - override fun propertyChange(evt: PropertyChangeEvent?) { - val searchFieldText = text.trim() - if (searchFieldText == lastText) { - return - } - lastText = searchFieldText - if (text.isEmpty()) { - block() + textEditor.addPropertyChangeListener( + object : PropertyChangeListener { + private var lastText = "" + override fun propertyChange(evt: PropertyChangeEvent?) { + val searchFieldText = text.trim() + if (searchFieldText == lastText) { + return + } + lastText = searchFieldText + if (text.isEmpty()) { + block() + } } } - }) + ) } fun SearchTextField.onEnter(block: () -> Unit) { - textEditor.addActionListener(object : ActionListener { - private var lastText = "" - override fun actionPerformed(e: ActionEvent?) { - val searchFieldText = text.trim() - if (searchFieldText == lastText) { - return + textEditor.addActionListener( + object : ActionListener { + private var lastText = "" + override fun actionPerformed(e: ActionEvent?) { + val searchFieldText = text.trim() + if (searchFieldText == lastText) { + return + } + lastText = searchFieldText + block() } - lastText = searchFieldText - block() } - }) + ) } 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 497d33f2a9..15e43554ab 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 @@ -7,19 +7,27 @@ package software.aws.toolkits.jetbrains.utils.ui import com.intellij.lang.Language import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.command.CommandProcessor +import com.intellij.openapi.ui.DialogPanel import com.intellij.openapi.ui.GraphicsConfig import com.intellij.openapi.ui.ValidationInfo +import com.intellij.ui.CellRendererPanel import com.intellij.ui.ClickListener import com.intellij.ui.EditorTextField +import com.intellij.ui.ExperimentalUI 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.dsl.builder.Cell +import com.intellij.ui.dsl.builder.MutableProperty import com.intellij.ui.paint.LinePainter2D import com.intellij.ui.speedSearch.SpeedSearchSupply +import com.intellij.util.text.DateFormatUtil +import com.intellij.util.text.SyncDateFormat import com.intellij.util.ui.GraphicsUtil +import com.intellij.util.ui.JBInsets import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil +import software.aws.toolkits.jetbrains.ui.KeyValueTextField import software.aws.toolkits.jetbrains.utils.formatText import java.awt.AlphaComposite import java.awt.Color @@ -29,15 +37,17 @@ import java.awt.Graphics2D import java.awt.Shape import java.awt.event.MouseEvent import java.awt.geom.RoundRectangle2D +import java.text.SimpleDateFormat import javax.swing.AbstractButton +import javax.swing.BorderFactory 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.border.Border +import javax.swing.table.TableCellRenderer import javax.swing.text.Highlighter import javax.swing.text.JTextComponent @@ -169,31 +179,107 @@ private fun JTextArea.speedSearchHighlighter(speedSearchEnabledComponent: JCompo } } -class WrappingCellRenderer(private val wrapOnSelection: Boolean, private val toggleableWrap: Boolean) : DefaultTableCellRenderer() { +class WrappingCellRenderer( + private val wrapOnSelection: Boolean = false, + private val wrapOnToggle: Boolean = false, + private val truncateAfterChars: Int? = null +) : CellRendererPanel(), TableCellRenderer { 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 + private val textArea = JBTextArea() + + init { + textArea.font = UIUtil.getLabelFont() + textArea.wrapStyleWord = true + + add(textArea) + } 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) + if (table == null) { + return this + } + + textArea.lineWrap = (wrapOnSelection && isSelected) || (wrapOnToggle && wrap) + val text = (value as? String) ?: "" + textArea.text = if (truncateAfterChars != null) { + text.take(truncateAfterChars) + } else { + text + } + textArea.setSelectionHighlighting(table, isSelected) + + setSize(table.columnModel.getColumn(column).width, preferredSize.height) + if (table.getRowHeight(row) != preferredSize.height) { + table.setRowHeight(row, preferredSize.height) } - component.speedSearchHighlighter(table) + textArea.speedSearchHighlighter(table) - return component + return this } } + +// TODO: figure out why this has weird hysteresis during rendering causing no text +class ResizingDateColumnRenderer(showSeconds: Boolean) : ResizingColumnRenderer() { + private val formatter: SyncDateFormat = if (showSeconds) { + SyncDateFormat(SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")) + } else { + DateFormatUtil.getDateTimeFormat() + } + + override fun getText(value: Any?): String? = (value as? String)?.toLongOrNull()?.let { formatter.format(it) } +} + +class ResizingTextColumnRenderer : ResizingColumnRenderer() { + override fun getText(value: Any?): String? = (value as? String)?.trim() +} + +/** + * When a panel is made of more than one panel, apply and the validation callbacks do not work as expected for + * the child panels. This function makes it so the validation callbacks and apply are actually called. + * @param applies An additional function that allows control based on visibility of other components or other factors + */ + +fun Cell.installOnParent(applies: () -> Boolean = { true }): Cell { + validationOnApply { + validate(applies, it) + } + return this +} + +private inline fun validate(applies: () -> Boolean, component: DialogPanel): ValidationInfo? = + if (!applies()) { + null + } else { + val errors = component.validateCallbacks.mapNotNull { it() } + if (errors.isEmpty()) { + component.apply() + } + errors.firstOrNull() + } + +/** + * Add a contextual help icon component + */ + +fun Cell.withBinding(binding: MutableProperty>) = + this.bind( + componentGet = { component -> component.envVars }, + componentSet = { component, value -> component.envVars = value }, + binding + ) + +fun editorNotificationCompoundBorder(outsideBorder: Border) = BorderFactory.createCompoundBorder( + // outside border + outsideBorder, + // inside border + // helper util not available in JBUI until 232 + // https://github.com/JetBrains/intellij-community/blob/222/platform/platform-api/src/com/intellij/ui/EditorNotificationPanel.java#L135-L136 + JBUI.Borders.empty( + JBUI.insets( + "Editor.Notification.borderInsets", + if (ExperimentalUI.isNewUI()) JBInsets.create(9, 16) else JBInsets.create(5, 10) + ) + ) +) diff --git a/jetbrains-core/tst-resources/codemodernizer/diff.patch b/jetbrains-core/tst-resources/codemodernizer/diff.patch new file mode 100644 index 0000000000..c1f1b4e353 --- /dev/null +++ b/jetbrains-core/tst-resources/codemodernizer/diff.patch @@ -0,0 +1,27 @@ +diff --git a/src/Main.java b/src/Main.java +--- a/src/Main.java (revision 8cb3e8a5d5833441227fcf319bbb72fac8dc047e) ++++ b/src/Main.java (date 1696422021225) +@@ -1,10 +1,20 @@ + + + public class Main { ++ // CodeWhisperers Modernizer extracted these Strings when performing the upgrade from JDK 8 to JDK 17 because XYZ ++ private static final String welcomeMessage = "Hello and welcome but this should be edited!"; + public static void main(String[] args) { +- System.out.printf("Hello and welcome!"); ++ runMain(); ++ } ++ ++ /** ++ * CodeWhisperers Modernizer extracted this function when performing the upgrade from JDK 8 to JDK 17 because XYZ ++ */ ++ public static void runMain(){ ++ System.out.printf(welcomeMessage); ++ // Press ⌃R or click the green arrow button in the gutter to run the code. + for (int i = 1; i <= 5; i++) { +- System.out.println("i = " + i); ++ System.out.println(welcomeMessage + i); + } + } + } +\ No newline at end of file diff --git a/jetbrains-core/tst-resources/codemodernizer/expectedFile b/jetbrains-core/tst-resources/codemodernizer/expectedFile new file mode 100644 index 0000000000..df16324490 --- /dev/null +++ b/jetbrains-core/tst-resources/codemodernizer/expectedFile @@ -0,0 +1 @@ +expected diff --git a/jetbrains-core/tst-resources/codemodernizer/overwrittenFile b/jetbrains-core/tst-resources/codemodernizer/overwrittenFile new file mode 100644 index 0000000000..7123871347 --- /dev/null +++ b/jetbrains-core/tst-resources/codemodernizer/overwrittenFile @@ -0,0 +1 @@ +overwritten diff --git a/jetbrains-core/tst-resources/codemodernizer/simple.zip b/jetbrains-core/tst-resources/codemodernizer/simple.zip new file mode 100644 index 0000000000..88f3ae3961 Binary files /dev/null and b/jetbrains-core/tst-resources/codemodernizer/simple.zip differ diff --git a/jetbrains-core/tst-resources/codemodernizer/test.txt b/jetbrains-core/tst-resources/codemodernizer/test.txt new file mode 100644 index 0000000000..d9eb513c13 --- /dev/null +++ b/jetbrains-core/tst-resources/codemodernizer/test.txt @@ -0,0 +1 @@ +Morning diff --git a/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/all-Regions-Works-Falls-Back-To-Bundled-Resource.json b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/all-Regions-Works-Falls-Back-To-Bundled-Resource.json new file mode 100644 index 0000000000..c16618fd7d --- /dev/null +++ b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/all-Regions-Works-Falls-Back-To-Bundled-Resource.json @@ -0,0 +1,3 @@ +{ + "version": 3 +} diff --git a/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/no-default-region-no-us-east-1-endpoints.json b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/no-default-region-no-us-east-1-endpoints.json new file mode 100644 index 0000000000..e8cff3b64e --- /dev/null +++ b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/no-default-region-no-us-east-1-endpoints.json @@ -0,0 +1,22 @@ +{ + "partitions": [ + { + "defaults": { + "hostname": "{service}.{region}.{dnsSuffix}", + "protocols": ["https"], + "signatureVersions": ["v4"] + }, + "dnsSuffix": "amazonaws.com", + "partition": "aws", + "partitionName": "AWS Standard", + "regionRegex": "^(us|eu|ap|sa|ca|me)\\-\\w+\\-\\d+$", + "regions": { + "us-region-1": { + "description": "Blah" + } + }, + "services": {} + } + ], + "version": 3 +} diff --git a/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/no-default-region-us-east-1-fallback-endpoints.json b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/no-default-region-us-east-1-fallback-endpoints.json new file mode 100644 index 0000000000..e928ea3329 --- /dev/null +++ b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/no-default-region-us-east-1-fallback-endpoints.json @@ -0,0 +1,25 @@ +{ + "partitions": [ + { + "defaults": { + "hostname": "{service}.{region}.{dnsSuffix}", + "protocols": ["https"], + "signatureVersions": ["v4"] + }, + "dnsSuffix": "amazonaws.com", + "partition": "aws", + "partitionName": "AWS Standard", + "regionRegex": "^(us|eu|ap|sa|ca|me)\\-\\w+\\-\\d+$", + "regions": { + "us-west-1": { + "description": "US West (California)" + }, + "us-east-1": { + "description": "US East (N. Virginia)" + } + }, + "services": {} + } + ], + "version": 3 +} diff --git a/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/no-regions-endpoints.json b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/no-regions-endpoints.json new file mode 100644 index 0000000000..2069a8ba62 --- /dev/null +++ b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/no-regions-endpoints.json @@ -0,0 +1,18 @@ +{ + "partitions": [ + { + "defaults": { + "hostname": "{service}.{region}.{dnsSuffix}", + "protocols": ["https"], + "signatureVersions": ["v4"] + }, + "dnsSuffix": "amazonaws.com", + "partition": "aws", + "partitionName": "AWS Standard", + "regionRegex": "^(us|eu|ap|sa|ca|me)\\-\\w+\\-\\d+$", + "regions": {}, + "services": {} + } + ], + "version": 3 +} diff --git a/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/simplified-multi-partition-endpoint.json b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/simplified-multi-partition-endpoint.json new file mode 100644 index 0000000000..59b4d85d49 --- /dev/null +++ b/jetbrains-core/tst-resources/software/aws/toolkits/jetbrains/core/region/simplified-multi-partition-endpoint.json @@ -0,0 +1,83 @@ +{ + "partitions": [ + { + "defaults": { + "hostname": "{service}.{region}.{dnsSuffix}", + "protocols": ["https"], + "signatureVersions": ["v4"] + }, + "dnsSuffix": "amazonaws.com", + "partition": "aws", + "partitionName": "AWS Standard", + "regionRegex": "^(us|eu|ap|sa|ca|me)\\-\\w+\\-\\d+$", + "regions": { + "us-east-1": { + "description": "US East (N. Virginia)" + }, + "us-west-2001": { + "description": "US West (Cascadia)" + } + }, + "services": { + "single-region-service": { + "endpoints": { + "us-west-2001": {} + } + }, + "service-with-fips-endpoint": { + "defaults": { + "protocols": [ + "http", + "https" + ] + }, + "endpoints": { + "us-east-1": {}, + "us-east-1-fips": { + "credentialScope": { + "region": "us-east-1" + }, + "hostname": "fipsyservice-fips.us-east-1.amazonaws.com" + } + } + }, + "global-service": { + "endpoints": { + "aws-global": { + "credentialScope": { + "region": "us-east-1" + }, + "hostname": "global-service.amazonaws.com" + } + }, + "isRegionalized": false, + "partitionEndpoint": "aws-global" + } + } + }, + { + "defaults": { + "hostname": "{service}.{region}.{dnsSuffix}", + "protocols": ["https"], + "signatureVersions": ["v4"] + }, + "dnsSuffix": "moon.amazonaws.com", + "partition": "moon", + "partitionName": "AWS Moon", + "regionRegex": "^(moon)\\-\\w+\\-\\d+${'$'}", + "regions": { + "moon-east-2001": { + "description": "Moon East (Tranquillitatis)" + } + }, + "services": { + "moon-partition": { + "endpoints": { + "moon-east-2001": {} + } + } + } + } + ], + "version": 3 +} diff --git a/jetbrains-core/tst-resources/toolkit-test-log.properties b/jetbrains-core/tst-resources/toolkit-test-log.properties new file mode 100644 index 0000000000..0df6e0dc65 --- /dev/null +++ b/jetbrains-core/tst-resources/toolkit-test-log.properties @@ -0,0 +1 @@ +software.aws.toolkits.level=FINE 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 b512c45f17..8befef941f 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsClientManagerTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsClientManagerTest.kt @@ -3,133 +3,182 @@ package software.aws.toolkits.jetbrains.core +import com.github.tomakehurst.wiremock.client.WireMock.aResponse +import com.github.tomakehurst.wiremock.client.WireMock.any +import com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.anyUrl +import com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching +import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import com.github.tomakehurst.wiremock.junit.WireMockRule +import com.github.tomakehurst.wiremock.matching.ContainsPattern import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.project.Project -import com.intellij.openapi.project.ex.ProjectManagerEx -import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.use +import com.intellij.testFramework.DisposableRule +import com.intellij.testFramework.ExtensionTestUtil import com.intellij.testFramework.ProjectRule -import com.intellij.testFramework.runInEdtAndWait +import com.intellij.testFramework.RuleChain 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 import org.junit.Test import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider import software.amazon.awssdk.awscore.client.builder.AwsClientBuilder import software.amazon.awssdk.awscore.client.builder.AwsDefaultClientBuilder import software.amazon.awssdk.core.SdkClient +import software.amazon.awssdk.core.client.builder.SdkAsyncClientBuilder +import software.amazon.awssdk.core.client.builder.SdkSyncClientBuilder import software.amazon.awssdk.core.client.config.SdkAdvancedClientOption import software.amazon.awssdk.core.client.config.SdkClientOption import software.amazon.awssdk.core.signer.Signer import software.amazon.awssdk.http.SdkHttpClient -import software.aws.toolkits.core.region.AwsRegion +import software.amazon.awssdk.http.async.SdkAsyncHttpClient +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.lambda.LambdaClient +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.ToolkitClientCustomizer +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProviderDelegate import software.aws.toolkits.core.region.Endpoint import software.aws.toolkits.core.region.Service +import software.aws.toolkits.core.region.anAwsRegion +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.AwsClientManager.Companion.CUSTOMIZER_EP import software.aws.toolkits.jetbrains.core.credentials.CredentialManager -import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager -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.core.credentials.MockAwsConnectionManager.ProjectAccountSettingsManagerRule +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerRule +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule +import java.net.URI import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.jvm.isAccessible class AwsClientManagerTest { + private val projectRule = ProjectRule() + private val disposableRule = DisposableRule() + private val temporaryDirectory = TemporaryFolder() + private val regionProvider = MockRegionProviderRule() + private val credentialManager = MockCredentialManagerRule() + private val projectSettingsRule = ProjectAccountSettingsManagerRule(projectRule) @Rule @JvmField - val projectRule = ProjectRule() + val wireMockRule = WireMockRule(WireMockConfiguration.wireMockConfig().dynamicPort()) @Rule @JvmField - val temporaryDirectory = TemporaryFolder() - - private lateinit var mockCredentialManager: MockCredentialsManager - - @Before - fun setUp() { - mockCredentialManager = MockCredentialsManager.getInstance() - mockCredentialManager.reset() - MockRegionProvider.getInstance().reset() - MockAwsConnectionManager.getInstance(projectRule.project).reset() - } - - @After - fun tearDown() { - MockAwsConnectionManager.getInstance(projectRule.project).reset() - MockRegionProvider.getInstance().reset() - mockCredentialManager.reset() - } + val ruleChain = RuleChain( + projectRule, + temporaryDirectory, + credentialManager, + regionProvider, + projectSettingsRule, + disposableRule + ) @Test fun canGetAnInstanceOfAClient() { val sut = getClientManager() - val client = sut.getClient() + val client = sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) assertThat(client.serviceName()).isEqualTo("dummyClient") } @Test fun clientsAreCached() { val sut = getClientManager() - val fooClient = sut.getClient() - val barClient = sut.getClient() + val credProvider = credentialManager.createCredentialProvider() + val region = regionProvider.createAwsRegion() + + val fooClient = sut.getClient(credProvider, region) + val barClient = sut.getClient(credProvider, region) assertThat(fooClient).isSameAs(barClient) } @Test - fun oldClientsAreRemovedWhenProfilesAreRemoved() { + fun oldClientsAreRemovedWhenCredentialsAreRemoved() { val sut = getClientManager() - val credentialsIdentifier = mockCredentialManager.addCredentials("profile:admin") - val credentialProvider = mockCredentialManager.getAwsCredentialProvider(credentialsIdentifier, MockRegionProvider.getInstance().defaultRegion()) + val credentialsIdentifier = credentialManager.addCredentials("profile:admin") + val credentialProvider = credentialManager.getAwsCredentialProvider(credentialsIdentifier, regionProvider.defaultRegion()) - sut.getClient(credentialProvider) + sut.getClient(credentialProvider, anAwsRegion()) assertThat(sut.cachedClients().keys).anySatisfy { - it.credentialProviderId == "profile:admin" + assertThat(it.providerId).isEqualTo("profile:admin") } ApplicationManager.getApplication().messageBus.syncPublisher(CredentialManager.CREDENTIALS_CHANGED).providerRemoved(credentialsIdentifier) assertThat(sut.cachedClients().keys).noneSatisfy { - it.credentialProviderId == "profile:admin" + assertThat(it.providerId).isEqualTo("profile:admin") } } @Test - fun clientsAreClosedWhenProjectIsDisposed() { - val project = HeavyPlatformTestCase.createProject(temporaryDirectory.newFolder().toPath()) - val projectManager = ProjectManagerEx.getInstanceEx() + fun clientsAreClosedWhenParentIsDisposed() { + val sut = getClientManager() + val client = Disposer.newDisposable().use { parent -> + Disposer.register(parent, sut) - runInEdtAndWait { - projectManager.openProject(project) + sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()).also { + assertThat(it.closed).isFalse() + } } - val sut = getClientManager(project) - val client = sut.getClient() + assertThat(client.closed).isTrue + assertThat(sut.cachedClients()).isEmpty() + } - runInEdtAndWait { - projectManager.closeAndDispose(project) - } + @Test + fun clientsAreClosedWhenCredentialProviderIsRemoved() { + val sut = getClientManager() + val credentialProviderId = credentialManager.addCredentials() + val credentialProvider = credentialManager.getAwsCredentialProvider(credentialProviderId, anAwsRegion()) + val client = sut.getClient(credentialProvider, regionProvider.createAwsRegion()) + + assertThat(client.closed).isFalse + credentialManager.removeCredentials(credentialProviderId) + assertThat(client.closed).isTrue - assertThat(client.closed).isTrue() + assertThat(sut.cachedClients()).isEmpty() } @Test - fun httpClientIsSharedAcrossClients() { + fun `http client is shared across sync clients`() { val sut = getClientManager() - val dummy = sut.getClient() - val secondDummy = sut.getClient() + val dummy = sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) + val secondDummy = sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) assertThat(dummy.httpClient.delegate).isSameAs(secondDummy.httpClient.delegate) } + @Test + fun `async http clients is not shared across sync http clients`() { + val sut = getClientManager() + val dummy = sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) + val asyncDummy = sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) + + assertThat(dummy.httpClient.delegate).isNotSameAs(asyncDummy.httpClient) + } + + @Test + fun `http client is not shared across async clients`() { + val sut = getClientManager() + val dummy = sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) + val secondDummy = sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) + + assertThat(dummy.httpClient).isNotSameAs(secondDummy.httpClient) + } + @Test fun clientWithoutBuilderFailsDescriptively() { val sut = getClientManager() - assertThatThrownBy { sut.getClient() } + assertThatThrownBy { sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) } .isInstanceOf(IllegalArgumentException::class.java) .hasMessageContaining("builder()") } @@ -138,43 +187,146 @@ class AwsClientManagerTest { fun clientInterfaceWithoutNameFieldFailsDescriptively() { val sut = getClientManager() - assertThatThrownBy { sut.getClient() } + assertThatThrownBy { sut.getClient(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) } .isInstanceOf(NoSuchFieldException::class.java) - .hasMessageContaining("SERVICE_NAME") + .hasMessageContaining("SERVICE_METADATA_ID") } @Test - fun newClientCreatedWhenRegionChanges() { + fun `tokenProvider can be passed to bearer clients`() { val sut = getClientManager() - val first = sut.getClient() + val client = sut.getClient(TokenConnectionSettings(mockTokenProvider(), regionProvider.createAwsRegion())) - val testSettings = MockAwsConnectionManager.getInstance(projectRule.project) - testSettings.changeRegionAndWait(AwsRegion("us-west-2", "us-west-2", "aws")) + assertThat(client.withTokenProvider).isTrue() + } - testSettings.waitUntilConnectionStateIsStable() + @Test + fun `no error thrown when tokenProvider is passed to incompatible client`() { + val sut = getClientManager() + sut.getClient(TokenConnectionSettings(mockTokenProvider(), regionProvider.createAwsRegion())) + } + + @Test + fun clientsAreScopedToRegion() { + val sut = getClientManager() + val credProvider = credentialManager.createCredentialProvider() - val afterRegionUpdate = sut.getClient() + val firstRegion = sut.getClient(credProvider, regionProvider.createAwsRegion()) + val secondRegion = sut.getClient(credProvider, regionProvider.createAwsRegion()) - assertThat(afterRegionUpdate).isNotSameAs(first) + assertThat(secondRegion).isNotSameAs(firstRegion) } @Test fun globalServicesCanBeGivenAnyRegion() { val sut = getClientManager() - MockRegionProvider.getInstance().addService("DummyService", Service( - endpoints = mapOf("global" to Endpoint()), - isRegionalized = false, - partitionEndpoint = "global" - )) - val first = sut.getClient(regionOverride = AwsRegion("us-east-1", "us-east-1", "aws")) - val second = sut.getClient(regionOverride = AwsRegion("us-west-2", "us-west-2", "aws")) + regionProvider.addService( + "DummyService", + Service( + endpoints = mapOf("global" to Endpoint()), + isRegionalized = false, + partitionEndpoint = "global" + ) + ) + val credProvider = credentialManager.createCredentialProvider() + + val first = sut.getClient(credProvider, regionProvider.createAwsRegion(partitionId = "test")) + val second = sut.getClient(credProvider, regionProvider.createAwsRegion(partitionId = "test")) assertThat(first.serviceName()).isEqualTo("dummyClient") assertThat(second).isSameAs(first) } + @Test + fun clientCustomizationIsAppliedToManagedClients() { + wireMockRule.stubFor(any(anyUrl()).willReturn(aResponse().withStatus(200))) + + val aConnection = ConnectionSettings(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) + val customizer = ToolkitClientCustomizer { credentialProvider, _, region, builder, _ -> + assertThat(credentialProvider).isEqualTo(aConnection.credentials) + assertThat(region).isEqualTo(aConnection.region.id) + + builder.endpointOverride(URI.create(wireMockRule.baseUrl())) + } + ExtensionTestUtil.maskExtensions(CUSTOMIZER_EP, listOf(customizer), disposableRule.disposable) + + getClientManager().getClient(LambdaClient::class, aConnection).use { + it.listFunctions() + } + + wireMockRule.verify(anyRequestedFor(anyUrl())) + } + + @Test + fun clientCustomizationIsAppliedToUnmanagedClients() { + wireMockRule.stubFor(any(anyUrl()).willReturn(aResponse().withStatus(200))) + + val aConnection = ConnectionSettings(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) + val customizer = ToolkitClientCustomizer { credentialProvider, _, region, builder, _ -> + assertThat(credentialProvider).isEqualTo(aConnection.credentials) + assertThat(region).isEqualTo(aConnection.region.id) + + builder.endpointOverride(URI.create(wireMockRule.baseUrl())) + } + ExtensionTestUtil.maskExtensions(CUSTOMIZER_EP, listOf(customizer), disposableRule.disposable) + + getClientManager().createUnmanagedClient(aConnection.credentials, Region.of(aConnection.region.id)).use { + it.listFunctions() + } + + wireMockRule.verify(anyRequestedFor(anyUrl())) + } + + @Test + fun userAgentIsPassedForManagedClients() { + wireMockRule.stubFor(any(anyUrl()).willReturn(aResponse().withStatus(200))) + + val aConnection = ConnectionSettings(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) + val customizer = ToolkitClientCustomizer { _, _, _, builder, _ -> + builder.endpointOverride(URI.create(wireMockRule.baseUrl())) + } + ExtensionTestUtil.maskExtensions(CUSTOMIZER_EP, listOf(customizer), disposableRule.disposable) + + getClientManager().getClient(aConnection).use { + it.listFunctions() + } + + wireMockRule.verify(anyRequestedFor(urlPathMatching("(.*)/functions/")).withHeader("User-Agent", ContainsPattern("AWS-Toolkit-For-JetBrains/"))) + } + + @Test + fun userAgentIsPassedForUnmanagedClients() { + wireMockRule.stubFor(any(anyUrl()).willReturn(aResponse().withStatus(200))) + + val aConnection = ConnectionSettings(credentialManager.createCredentialProvider(), regionProvider.createAwsRegion()) + val customizer = ToolkitClientCustomizer { _, _, _, builder, _ -> + builder.endpointOverride(URI.create(wireMockRule.baseUrl())) + } + ExtensionTestUtil.maskExtensions(CUSTOMIZER_EP, listOf(customizer), disposableRule.disposable) + + getClientManager().createUnmanagedClient(aConnection.credentials, Region.of(aConnection.region.id)).use { + it.listFunctions() + } + + wireMockRule.verify(anyRequestedFor(urlPathMatching("(.*)/functions/")).withHeader("User-Agent", ContainsPattern("AWS-Toolkit-For-JetBrains/"))) + } + + private fun mockTokenProvider() = mock().let { + whenever(it.id).thenReturn(aString()) + + ToolkitBearerTokenProvider(it) + } + // Test against real version so bypass ServiceManager for the client manager - private fun getClientManager(project: Project = projectRule.project) = AwsClientManager(project) + private fun getClientManager() = AwsClientManager() + + class DummyServiceAsyncClient(val httpClient: SdkAsyncHttpClient) : TestClient() { + companion object { + @Suppress("unused") + @JvmStatic + fun builder() = DummyServiceAsyncClientBuilder() + } + } class DummyServiceClient(val httpClient: SdkHttpClient) : TestClient() { companion object { @@ -184,7 +336,15 @@ class AwsClientManagerTest { } } - class DummyServiceClientBuilder : TestClientBuilder() { + class DummyServiceAsyncClientBuilder : TestAsyncClientBuilder() { + override fun serviceName(): String = "DummyService" + + override fun signingName(): String = serviceName() + + override fun buildClient() = DummyServiceAsyncClient(asyncClientConfiguration().option(SdkClientOption.ASYNC_HTTP_CLIENT)) + } + + class DummyServiceClientBuilder : TestSyncClientBuilder() { override fun serviceName(): String = "DummyService" override fun signingName(): String = serviceName() @@ -192,6 +352,30 @@ class AwsClientManagerTest { override fun buildClient() = DummyServiceClient(syncClientConfiguration().option(SdkClientOption.SYNC_HTTP_CLIENT)) } + class DummyBearerServiceClient(val httpClient: SdkHttpClient, val withTokenProvider: Boolean = false) : TestClient() { + companion object { + @Suppress("unused") + @JvmStatic + fun builder() = DummyBearerServiceClientBuilder() + } + } + + class DummyBearerServiceClientBuilder : TestClientBuilder() { + private var tokenProviderMethodInvoked = false + + fun tokenProvider(ignored: SdkTokenProvider): DummyBearerServiceClientBuilder { + tokenProviderMethodInvoked = true + + return this + } + + override fun serviceName(): String = "DummyBearerService" + + override fun signingName(): String = serviceName() + + override fun buildClient() = DummyBearerServiceClient(syncClientConfiguration().option(SdkClientOption.SYNC_HTTP_CLIENT), tokenProviderMethodInvoked) + } + class SecondDummyServiceClient(val httpClient: SdkHttpClient) : TestClient() { companion object { @Suppress("unused") @@ -201,7 +385,7 @@ class AwsClientManagerTest { } class SecondDummyServiceClientBuilder : - TestClientBuilder() { + TestSyncClientBuilder() { override fun serviceName(): String = "SecondDummyService" override fun signingName(): String = serviceName() @@ -231,12 +415,18 @@ class AwsClientManagerTest { } companion object { - @Suppress("unused") + @Suppress("unused", "MayBeConstant") @JvmField - val SERVICE_NAME = "DummyService" + val SERVICE_METADATA_ID = "DummyService" } } + abstract class TestAsyncClientBuilder : TestClientBuilder(), SdkAsyncClientBuilder + where B : AwsClientBuilder, B : SdkAsyncClientBuilder + + abstract class TestSyncClientBuilder : TestClientBuilder(), SdkSyncClientBuilder + where B : AwsClientBuilder, B : SdkSyncClientBuilder + abstract class TestClientBuilder, C> : AwsDefaultClientBuilder() { init { overrideConfiguration { 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 4cba261389..994d7e104a 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsResourceCacheTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsResourceCacheTest.kt @@ -4,32 +4,40 @@ package software.aws.toolkits.jetbrains.core import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.project.Project import com.intellij.testFramework.ProjectRule -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.atLeastOnce -import com.nhaarman.mockitokotlin2.doAnswer -import com.nhaarman.mockitokotlin2.doThrow -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.reset -import com.nhaarman.mockitokotlin2.times -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions -import com.nhaarman.mockitokotlin2.whenever +import com.intellij.testFramework.RuleChain +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest 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 import org.junit.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.ConnectionSettings 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 -import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerRule +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule +import software.aws.toolkits.jetbrains.core.utils.buildList +import software.aws.toolkits.jetbrains.utils.hasCauseWithMessage import software.aws.toolkits.jetbrains.utils.hasException import software.aws.toolkits.jetbrains.utils.hasValue import software.aws.toolkits.jetbrains.utils.value @@ -39,37 +47,52 @@ import java.time.Duration import java.time.Instant import java.util.concurrent.CompletableFuture import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutionException import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicInteger +@ExperimentalCoroutinesApi class AwsResourceCacheTest { + private val projectRule = ProjectRule() + private val credentialsManager = MockCredentialManagerRule() + private val regionProvider = MockRegionProviderRule() + + // If we don't control the order manually, regionProvider can run before projectRule which causes a NPE @Rule @JvmField - val projectRule = ProjectRule() + val ruleChain = RuleChain( + projectRule, + credentialsManager, + regionProvider + ) private val mockClock = mock() private val mockResource = mock>() private lateinit var sut: AwsResourceCache + private lateinit var cred1Identifier: CredentialIdentifier private lateinit var cred1Provider: ToolkitCredentialsProvider private lateinit var cred2Identifier: CredentialIdentifier private lateinit var cred2Provider: ToolkitCredentialsProvider + private lateinit var connectionSettings: ConnectionSettings @Before fun setUp() { - val credentialsManager = MockCredentialsManager.getInstance() - credentialsManager.reset() - cred1Identifier = credentialsManager.addCredentials("Cred1") - cred1Provider = credentialsManager.getAwsCredentialProvider(cred1Identifier, MockRegionProvider.getInstance().defaultRegion()) + cred1Provider = credentialsManager.getAwsCredentialProvider(cred1Identifier, regionProvider.defaultRegion()) cred2Identifier = credentialsManager.addCredentials("Cred2") - cred2Provider = credentialsManager.getAwsCredentialProvider(cred2Identifier, MockRegionProvider.getInstance().defaultRegion()) + cred2Provider = credentialsManager.getAwsCredentialProvider(cred2Identifier, regionProvider.defaultRegion()) + + connectionSettings = ConnectionSettings(credentialsManager.createCredentialProvider(), regionProvider.createAwsRegion()) - sut = DefaultAwsResourceCache(projectRule.project, mockClock, 1000, Duration.ofMinutes(1)) - sut.clear() + if (this::sut.isInitialized) { + (sut as DefaultAwsResourceCache).dispose() + } + sut = DefaultAwsResourceCache(mockClock, 1000, Duration.ofMinutes(1)) reset(mockClock, mockResource) whenever(mockResource.expiry()).thenReturn(DEFAULT_EXPIRY) @@ -77,59 +100,55 @@ class AwsResourceCacheTest { whenever(mockClock.instant()).thenReturn(Instant.now()) } - @After - fun tearDown() { - MockCredentialsManager.getInstance().reset() - } - @Test fun basicCachingWorks() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") + whenever(mockResource.fetch(any())).thenReturn("hello") + + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") - assertThat(sut.getResource(mockResource)).hasValue("hello") - assertThat(sut.getResource(mockResource)).hasValue("hello") verifyResourceCalled(times = 1) } @Test fun expirationWorks() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") + whenever(mockResource.fetch(any())).thenReturn("hello") - assertThat(sut.getResource(mockResource)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") whenever(mockClock.instant()).thenReturn(Instant.now().plus(DEFAULT_EXPIRY)) - assertThat(sut.getResource(mockResource)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") + verifyResourceCalled(times = 2) } @Test fun exceptionsAreBubbledWhenNoEntry() { - doAnswer { throw Throwable("Bang!") }.whenever(mockResource).fetch(any(), any(), any()) - assertThat(sut.getResource(mockResource)).hasException.withFailMessage("Bang!") + doAnswer { throw Throwable("Bang!") }.whenever(mockResource).fetch(any()) + assertThatThrownBy { sut.getResource(mockResource, connectionSettings).value }.hasCauseWithMessage("Bang!") } @Test fun exceptionsAreLoggedButStaleEntryReturnedByDefault() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello").doThrow(RuntimeException("BOOM")) + whenever(mockResource.fetch(any())).thenReturn("hello").doThrow(RuntimeException("BOOM")) - assertThat(sut.getResource(mockResource)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") whenever(mockClock.instant()).thenReturn(Instant.now().plus(DEFAULT_EXPIRY)) - assertThat(sut.getResource(mockResource)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") } @Test fun exceptionsAreBubbledWhenExistingEntryExpiredAndUseStaleIsFalse() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello").doThrow(RuntimeException("BOOM")) + whenever(mockResource.fetch(any())).thenReturn("hello").doThrow(RuntimeException("BOOM")) - assertThat(sut.getResource(mockResource)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") whenever(mockClock.instant()).thenReturn(Instant.now().plus(DEFAULT_EXPIRY)) - assertThat(sut.getResource(mockResource, useStale = false)).hasException + assertThat(sut.getResource(mockResource, connectionSettings, useStale = false)).hasException } @Test fun cacheEntriesAreSeparatedByRegionAndCredentials() { - whenever(mockResource.fetch(any(), any(), any())).thenAnswer { - val region = it.getArgument(1) - val cred = it.getArgument(2) + whenever(mockResource.fetch(any())).thenAnswer { + val (cred, region) = it.getArgument(0) "${region.id}-${cred.id}" } @@ -144,31 +163,39 @@ class AwsResourceCacheTest { @Test fun cacheCanBeCleared() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello").thenReturn("goodbye") + whenever(mockResource.fetch(any())).thenReturn("hello").thenReturn("goodbye") + + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") + + runBlocking { sut.clear() } + + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("goodbye") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("goodbye") - assertThat(sut.getResource(mockResource)).hasValue("hello") - assertThat(sut.getResource(mockResource)).hasValue("hello") - sut.clear() - assertThat(sut.getResource(mockResource)).hasValue("goodbye") - assertThat(sut.getResource(mockResource)).hasValue("goodbye") verifyResourceCalled(times = 2) } @Test fun cacheCanBeClearedByKey() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello").thenReturn("goodbye") - assertThat(sut.getResource(mockResource)).hasValue("hello") - sut.clear(mockResource) - assertThat(sut.getResource(mockResource)).hasValue("goodbye") + whenever(mockResource.fetch(any())).thenReturn("hello").thenReturn("goodbye") + + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") + + sut.clear(mockResource, connectionSettings) + + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("goodbye") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("goodbye") + verifyResourceCalled(times = 2) } @Test fun cacheCanBeClearedByKeyAndConnection() { val incrementer = AtomicInteger(0) - whenever(mockResource.fetch(any(), any(), any())).thenAnswer { - val region = it.getArgument(1) - val cred = it.getArgument(2) + whenever(mockResource.fetch(any())).thenAnswer { + val (cred, region) = it.getArgument(0) "${region.id}-${cred.id}-${incrementer.getAndIncrement()}" } @@ -177,7 +204,7 @@ class AwsResourceCacheTest { val usw2Cred1 = sut.getResource(mockResource, US_WEST_2, cred1Provider).value val usw2Cred2 = sut.getResource(mockResource, US_WEST_2, cred2Provider).value - sut.clear(mockResource, region = US_WEST_1, credentialProvider = cred1Provider) + sut.clear(mockResource, ConnectionSettings(cred1Provider, US_WEST_1)) assertThat(sut.getResource(mockResource, US_WEST_1, cred1Provider)).wait().isCompletedWithValueMatching { it != usw1Cred1 } assertThat(sut.getResource(mockResource, US_WEST_1, cred2Provider)).hasValue(usw1Cred2) @@ -188,10 +215,12 @@ class AwsResourceCacheTest { @Test fun canForceCacheRefresh() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello").thenReturn("goodbye") - assertThat(sut.getResource(mockResource)).hasValue("hello") - assertThat(sut.getResource(mockResource, forceFetch = true)).hasValue("goodbye") - assertThat(sut.getResource(mockResource)).hasValue("goodbye") + whenever(mockResource.fetch(any())).thenReturn("hello").thenReturn("goodbye") + + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings, forceFetch = true)).hasValue("goodbye") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("goodbye") + verifyResourceCalled(times = 2) } @@ -204,78 +233,92 @@ class AwsResourceCacheTest { @Test fun viewsCanBeCreatedOnTopOfOtherCachedItems() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") - val viewResource = Resource.View(mockResource) { toList() } + whenever(mockResource.fetch(any())).thenReturn("hello") + val viewResource = Resource.view(mockResource) { toList() } - assertThat(sut.getResource(mockResource)).hasValue("hello") - assertThat(sut.getResource(viewResource)).hasValue(listOf('h', 'e', 'l', 'l', 'o')) + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") + assertThat(sut.getResource(viewResource, connectionSettings)).hasValue(listOf('h', 'e', 'l', 'l', 'o')) verifyResourceCalled(times = 1) } @Test fun mapFilterAndFindExtensionsToEasilyCreateViews() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") - val viewResource = Resource.View(mockResource) { toList() } + whenever(mockResource.fetch(any())).thenReturn("hello") + val viewResource = Resource.view(mockResource) { toList() } val filteredAndMapped = viewResource.filter { it != 'l' }.map { it.toUpperCase() } - assertThat(sut.getResource(filteredAndMapped)).hasValue(listOf('H', 'E', 'O')) + assertThat(sut.getResource(filteredAndMapped, connectionSettings)).hasValue(listOf('H', 'E', 'O')) val find = viewResource.find { it == 'l' } - assertThat(sut.getResource(find)).hasValue('l') + assertThat(sut.getResource(find, connectionSettings)).hasValue('l') } @Test fun clearingViewsClearTheUnderlyingCachedResource() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") - val viewResource = Resource.View(mockResource) { toList() } - sut.getResource(viewResource).value - sut.clear(viewResource) - sut.getResource(viewResource).value + whenever(mockResource.fetch(any())).thenReturn("hello") + val viewResource = Resource.view(mockResource) { toList() } + sut.getResource(viewResource, connectionSettings).value + sut.clear(viewResource, connectionSettings) + sut.getResource(viewResource, connectionSettings).value verifyResourceCalled(times = 2) } + @Test + fun viewsCanBeRegionAware() { + whenever(mockResource.fetch(any())).thenReturn("hello") + val viewResource = Resource.View(mockResource) { _, region -> region } + + assertThat(sut.getResource(viewResource, connectionSettings)).hasValue(connectionSettings.region) + } + @Test fun cacheIsRegularlyPrunedToEnsureItDoesntGrowTooLarge() { - val localSut = DefaultAwsResourceCache(projectRule.project, mockClock, 5, Duration.ofMillis(50)) + val localSut = DefaultAwsResourceCache(mockClock, 5, Duration.ofMillis(50)) val now = Instant.now() whenever(mockClock.instant()).thenReturn(now) - localSut.getResource(StringResource("1")).value + + localSut.getResource(StringResource("1"), connectionSettings).value + whenever(mockClock.instant()).thenReturn(now.plusMillis(10)) - localSut.getResource(StringResource("2")).value - localSut.getResource(StringResource("3")).value - localSut.getResource(StringResource("4")).value - localSut.getResource(StringResource("5")).value - localSut.getResource(StringResource("6")).value + + localSut.getResource(StringResource("2"), connectionSettings).value + localSut.getResource(StringResource("3"), connectionSettings).value + localSut.getResource(StringResource("4"), connectionSettings).value + localSut.getResource(StringResource("5"), connectionSettings).value + localSut.getResource(StringResource("6"), connectionSettings).value retryableAssert { - assertThat(localSut.getResourceIfPresent(StringResource("1"))).isNull() + assertThat(localSut.getResourceIfPresent(StringResource("1"), connectionSettings)).isNull() } } @Test fun pruningConsidersCollectionEntriesBasedOnTheirSize() { - val localSut = DefaultAwsResourceCache(projectRule.project, mockClock, 5, Duration.ofMillis(50)) + val localSut = DefaultAwsResourceCache(mockClock, 5, Duration.ofMillis(50)) val listResource = DummyResource("list", listOf("a", "b", "c", "d")) val now = Instant.now() whenever(mockClock.instant()).thenReturn(now) - localSut.getResource(listResource).value + + localSut.getResource(listResource, connectionSettings).value + whenever(mockClock.instant()).thenReturn(now.plusMillis(10)) - localSut.getResource(StringResource("1")).value - localSut.getResource(StringResource("2")).value + + localSut.getResource(StringResource("1"), connectionSettings).value + localSut.getResource(StringResource("2"), connectionSettings).value retryableAssert { - assertThat(localSut.getResourceIfPresent(listResource)).isNull() - assertThat(localSut.getResourceIfPresent(StringResource("1"))).isNotEmpty() - assertThat(localSut.getResourceIfPresent(StringResource("2"))).isNotEmpty() + assertThat(localSut.getResourceIfPresent(listResource, connectionSettings)).isNull() + assertThat(localSut.getResourceIfPresent(StringResource("1"), connectionSettings)).isNotEmpty() + assertThat(localSut.getResourceIfPresent(StringResource("2"), connectionSettings)).isNotEmpty() } } @Test fun multipleCallsInDifferentThreadsStillOnlyCallTheUnderlyingResourceOnce() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") + whenever(mockResource.fetch(any())).thenReturn("hello") val concurrency = 200 val latch = CountDownLatch(1) @@ -285,7 +328,7 @@ class AwsResourceCacheTest { val future = CompletableFuture() executor.submit { latch.await() - sut.getResource(mockResource).whenComplete { result, error -> + sut.getResource(mockResource, connectionSettings).whenComplete { result, error -> when { result != null -> future.complete(result) error != null -> future.completeExceptionally(error) @@ -295,7 +338,7 @@ class AwsResourceCacheTest { future }.toTypedArray() latch.countDown() - CompletableFuture.allOf(*futures).value + CompletableFuture.allOf(*futures).get(10, TimeUnit.SECONDS) } finally { executor.shutdown() } @@ -304,79 +347,104 @@ class AwsResourceCacheTest { } @Test - fun cachingShouldBeBasedOnId() { + fun multipleCallsWhileFetchPendingCallTheUnderlyingResourceOnce() { + val latch = CountDownLatch(1) + whenever(mockResource.fetch(any())).thenAnswer { + // don't allow the task to finish until all futures have been created + latch.await() + return@thenAnswer "hello" + } + + val futures = buildList> { + repeat(20) { + // simulate multiple calls to the same resource + add(sut.getResource(mockResource, connectionSettings).toCompletableFuture()) + } + }.toTypedArray() + latch.countDown() + + // all futures should complete + CompletableFuture.allOf(*futures).value + + // and we should have reused the same task for all of the requests + verifyResourceCalled(times = 1) + } + + @Test + fun cachingShouldBeBasedOnResourceId() { val first = StringResource("first") val anotherFirst = StringResource("first") - sut.getResource(first).value - sut.getResource(anotherFirst).value + sut.getResource(first, connectionSettings).value + sut.getResource(anotherFirst, connectionSettings).value + assertThat(first.callCount).hasValue(1) assertThat(anotherFirst.callCount).hasValue(0) } @Test fun whenACredentialIdIsRemovedItsEntriesAreRemovedFromTheCache() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") + whenever(mockResource.fetch(any())).thenReturn("hello") getAllRegionAndCredPermutations() ApplicationManager.getApplication().messageBus.syncPublisher(CredentialManager.CREDENTIALS_CHANGED).providerRemoved(cred1Identifier) getAllRegionAndCredPermutations() - verify(mockResource, times(2)).fetch(projectRule.project, US_WEST_1, cred1Provider) - verify(mockResource, times(2)).fetch(projectRule.project, US_WEST_2, cred1Provider) - verify(mockResource, times(1)).fetch(projectRule.project, US_WEST_1, cred2Provider) - verify(mockResource, times(1)).fetch(projectRule.project, US_WEST_2, cred2Provider) + verify(mockResource, times(2)).fetch(ConnectionSettings(cred1Provider, US_WEST_1)) + verify(mockResource, times(2)).fetch(ConnectionSettings(cred1Provider, US_WEST_2)) + verify(mockResource, times(1)).fetch(ConnectionSettings(cred2Provider, US_WEST_1)) + verify(mockResource, times(1)).fetch(ConnectionSettings(cred2Provider, US_WEST_2)) } @Test fun whenACredentialIdIsModifiedItsEntriesAreRemovedFromTheCache() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") + whenever(mockResource.fetch(any())).thenReturn("hello") getAllRegionAndCredPermutations() ApplicationManager.getApplication().messageBus.syncPublisher(CredentialManager.CREDENTIALS_CHANGED).providerModified(cred1Identifier) getAllRegionAndCredPermutations() - verify(mockResource, times(2)).fetch(projectRule.project, US_WEST_1, cred1Provider) - verify(mockResource, times(2)).fetch(projectRule.project, US_WEST_2, cred1Provider) - verify(mockResource, times(1)).fetch(projectRule.project, US_WEST_1, cred2Provider) - verify(mockResource, times(1)).fetch(projectRule.project, US_WEST_2, cred2Provider) + verify(mockResource, times(2)).fetch(ConnectionSettings(cred1Provider, US_WEST_1)) + verify(mockResource, times(2)).fetch(ConnectionSettings(cred1Provider, US_WEST_2)) + verify(mockResource, times(1)).fetch(ConnectionSettings(cred2Provider, US_WEST_1)) + verify(mockResource, times(1)).fetch(ConnectionSettings(cred2Provider, US_WEST_2)) } @Test fun cacheExposesBlockingApi() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") - assertThat(sut.getResourceNow(mockResource)).isEqualTo("hello") + whenever(mockResource.fetch(any())).thenReturn("hello") + assertThat(sut.getResourceNow(mockResource, connectionSettings)).isEqualTo("hello") } @Test fun cacheExposesBlockingApiWithRegionAndCred() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") + whenever(mockResource.fetch(any())).thenReturn("hello") assertThat(sut.getResourceNow(mockResource, US_WEST_1, cred1Provider)).isEqualTo("hello") - verify(mockResource).fetch(projectRule.project, US_WEST_1, cred1Provider) + verify(mockResource).fetch(ConnectionSettings(cred1Provider, US_WEST_1)) } @Test fun cacheExposesBlockingApiWhereExecutionExceptionIsUnwrapped() { - whenever(mockResource.fetch(any(), any(), any())).thenThrow(RuntimeException("boom")) - assertThatThrownBy { sut.getResourceNow(mockResource, timeout = Duration.ofMillis(5)) } + whenever(mockResource.fetch(any())).thenThrow(RuntimeException("boom")) + assertThatThrownBy { sut.getResourceNow(mockResource, connectionSettings, timeout = Duration.ofSeconds(1)) } .isInstanceOf(RuntimeException::class.java) - .withFailMessage("boom") + .hasMessage("boom") } @Test fun cacheExposesBlockingApiWithTimeout() { - whenever(mockResource.fetch(any(), any(), any())).thenAnswer { + whenever(mockResource.fetch(any())).thenAnswer { Thread.sleep(50) "hello" } - assertThatThrownBy { sut.getResourceNow(mockResource, timeout = Duration.ofMillis(5)) }.isInstanceOf(TimeoutException::class.java) + assertThatThrownBy { sut.getResourceNow(mockResource, connectionSettings, timeout = Duration.ofMillis(5)) }.isInstanceOf(TimeoutException::class.java) } @Test fun canConditionallyFetchOnlyIfAvailableInCache() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") + whenever(mockResource.fetch(any())).thenReturn("hello") assertThat(sut.getResourceIfPresent(mockResource, US_WEST_1, cred1Provider)).isNull() sut.getResource(mockResource, US_WEST_1, cred1Provider).value @@ -385,7 +453,7 @@ class AwsResourceCacheTest { @Test fun canConditionallyFetchOnlyIfAvailableInCacheAndRespectExpiry() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") + whenever(mockResource.fetch(any())).thenReturn("hello") val now = Instant.now() whenever(mockClock.instant()).thenReturn(now) @@ -397,8 +465,8 @@ class AwsResourceCacheTest { @Test fun canConditionallyFetchViewOnlyIfAvailableInCache() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") - val viewResource = Resource.View(mockResource) { reversed() } + whenever(mockResource.fetch(any())).thenReturn("hello") + val viewResource = Resource.view(mockResource) { reversed() } assertThat(sut.getResourceIfPresent(viewResource, US_WEST_1, cred1Provider)).isNull() sut.getResource(viewResource, US_WEST_1, cred1Provider).value @@ -407,10 +475,55 @@ class AwsResourceCacheTest { @Test fun canConditionallyFetchOnlyIfAvailableWithoutExplicitCredentialsRegion() { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello") - sut.getResource(mockResource).value + whenever(mockResource.fetch(any())).thenReturn("hello") + sut.getResource(mockResource, connectionSettings).value - assertThat(sut.getResourceIfPresent(mockResource)).isEqualTo("hello") + assertThat(sut.getResourceIfPresent(mockResource, connectionSettings)).isEqualTo("hello") + } + + @Test + fun concurrentlyRunningExceptionalResourcesGetTheSameException() { + val latch = CountDownLatch(1) + whenever(mockResource.fetch(any())).then { + latch.await() + // exception gets thrown fast enough where the second fetchIfNeeded check occurs after the first call throws + runBlockingTest { + delay(500) + } + throw RuntimeException("Boom") + } + + val first = sut.getResource(mockResource, connectionSettings) + val second = sut.getResource(mockResource, connectionSettings) + latch.countDown() + assertThatThrownBy { first.value }.hasCauseWithMessage("Boom") + assertThatThrownBy { second.value }.hasCauseWithMessage("Boom") + verifyResourceCalled(1, mockResource) + } + + @Test + fun canRecoverFromACachedException() { + whenever(mockResource.fetch(any())).thenThrow(RuntimeException("Boom")).thenReturn("Success!") + assertThrows { sut.getResource(mockResource, connectionSettings).value } + assertThat(sut.getResource(mockResource, connectionSettings).value).isEqualTo("Success!") + } + + @Test + fun retriesReturnTheMostRecentException() { + whenever(mockResource.fetch(any())).thenThrow(RuntimeException("Boom"), RuntimeException("Ouch")) + assertThatThrownBy { sut.getResource(mockResource, connectionSettings).value }.hasCauseWithMessage("Boom") + assertThatThrownBy { sut.getResource(mockResource, connectionSettings).value }.hasCauseWithMessage("Ouch") + } + + @Test + fun exceptionalEntriesAreRemovedFromTheCache() { + whenever(mockResource.fetch(any())).thenThrow(RuntimeException("Boom")) + assertThrows { sut.getResource(mockResource, connectionSettings).value } + + with(sut as DefaultAwsResourceCache) { + doRunCacheMaintenance() + assertThat(hasCacheEntry(mockResource.id)).isFalse + } } private fun getAllRegionAndCredPermutations() { @@ -421,24 +534,24 @@ class AwsResourceCacheTest { } private fun assertExpectedExpiryFunctions(expiryFunction: Instant.() -> Instant, shouldExpire: Boolean) { - whenever(mockResource.fetch(any(), any(), any())).thenReturn("hello", "goodbye") + whenever(mockResource.fetch(any())).thenReturn("hello", "goodbye") whenever(mockResource.expiry()).thenReturn(Duration.ofSeconds(1)) val now = Instant.now() val expiry = now.plus(Duration.ofSeconds(1)) whenever(mockClock.instant()).thenReturn(now) - assertThat(sut.getResource(mockResource)).hasValue("hello") + assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") whenever(mockClock.instant()).thenReturn(expiryFunction(expiry)) when (shouldExpire) { - true -> assertThat(sut.getResource(mockResource)).hasValue("goodbye") - false -> assertThat(sut.getResource(mockResource)).hasValue("hello") + true -> assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("goodbye") + false -> assertThat(sut.getResource(mockResource, connectionSettings)).hasValue("hello") } - sut.clear() + runBlocking { sut.clear() } } private fun verifyResourceCalled(times: Int, resource: Resource.Cached<*> = mockResource) { - verify(resource, times(times)).fetch(any(), any(), any()) + verify(resource, times(times)).fetch(any()) verify(resource, times(times)).expiry() verify(resource, atLeastOnce()).id verifyNoMoreInteractions(resource) @@ -453,7 +566,7 @@ class AwsResourceCacheTest { private open class DummyResource(override val id: String, private val value: T) : Resource.Cached() { val callCount = AtomicInteger(0) - override fun fetch(project: Project, region: AwsRegion, credentials: ToolkitCredentialsProvider): T { + override fun fetch(connectionSettings: ClientConnectionSettings<*>): T { callCount.getAndIncrement() return value } @@ -464,3 +577,9 @@ class AwsResourceCacheTest { fun dummyResource(value: String = aString()): Resource.Cached = StringResource(value) } } + +val Resource<*>.id: String + get() = when (this) { + is Resource.Cached -> this.id + is Resource.View<*, *> -> this.underlying.id + } diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsSdkClientTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsSdkClientTest.kt index ac8e188571..97c2175c1e 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsSdkClientTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/AwsSdkClientTest.kt @@ -50,7 +50,7 @@ class AwsSdkClientTest { val awsSdkClient = AwsSdkClient() val response = try { - awsSdkClient.sdkHttpClient.prepareRequest( + awsSdkClient.sharedSdkClient().prepareRequest( HttpExecuteRequest.builder().request(request).build() ).call() } finally { @@ -78,6 +78,7 @@ class AwsSdkClientTest { .dynamicHttpsPort() .keystorePath(selfSignedJks.toString()) .keystorePassword("changeit") + .keyManagerPassword("changeit") .keystoreType("jks") ) } diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/EditorUtils.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/EditorUtils.kt new file mode 100644 index 0000000000..7c3439951c --- /dev/null +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/EditorUtils.kt @@ -0,0 +1,32 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core + +import com.intellij.openapi.application.impl.NonBlockingReadActionImpl +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.EditorNotificationProvider +import com.intellij.ui.EditorNotifications +import com.intellij.ui.EditorNotificationsImpl +import javax.swing.JComponent + +@Suppress("UNUSED_PARAMETER") +fun getEditorNotifications( + project: Project, + editor: FileEditor, + provider: Class, + key: Key +): JComponent? { + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + NonBlockingReadActionImpl.waitForAsyncTaskCompletion() + + val editorNotifications = EditorNotifications.getInstance(project) as EditorNotificationsImpl + editorNotifications.completeAsyncTasks() + + @Suppress("USELESS_CAST") + return editorNotifications.getNotificationPanels(editor)[provider as Class<*>] +} 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 c24cf5266a..d62bd15697 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/ExecutableBackedCacheResourceTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/ExecutableBackedCacheResourceTest.kt @@ -9,17 +9,17 @@ import com.intellij.testFramework.ExtensionTestUtil import com.intellij.testFramework.ProjectRule 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 import org.junit.Test import org.junit.rules.TemporaryFolder +import software.aws.toolkits.core.ConnectionSettings import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider -import software.aws.toolkits.jetbrains.core.credentials.MockCredentialsManager +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerRule import software.aws.toolkits.jetbrains.core.executables.ExecutableManager import software.aws.toolkits.jetbrains.core.executables.ExecutableType import software.aws.toolkits.jetbrains.core.executables.Validatable -import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.jetbrains.core.region.getDefaultRegion import java.nio.file.Path import java.util.concurrent.TimeUnit @@ -36,16 +36,15 @@ class ExecutableBackedCacheResourceTest { @JvmField val disposableRule = DisposableRule() + @Rule + @JvmField + val credentialManager = MockCredentialManagerRule() + @Before fun setUp() { ExtensionTestUtil.maskExtensions(ExecutableType.EP_NAME, listOf(MockExecutable), disposableRule.disposable) } - @After - fun testDown() { - MockCredentialsManager.getInstance().reset() - } - @Test fun testExecutableIsNotInstalledCausesException() { createMockExecutable("invalidBinary") @@ -78,7 +77,7 @@ class ExecutableBackedCacheResourceTest { createMockExecutable("validBinary") executeCacheResource { - assertThat(this.environment).containsKey("AWS_ACCESS_KEY").containsKey("AWS_SECRET_KEY") + assertThat(this.environment).containsKey("AWS_ACCESS_KEY_ID").containsKey("AWS_SECRET_ACCESS_KEY") } } @@ -103,13 +102,11 @@ class ExecutableBackedCacheResourceTest { assertionBlock.invoke(this) } - return cacheResource.fetch(projectRule.project, MockRegionProvider.getInstance().defaultRegion(), mockCredentials()) + return cacheResource.fetch(ConnectionSettings(mockCredentials(), getDefaultRegion())) } - private fun mockCredentials(): ToolkitCredentialsProvider { - val credentialsManager = MockCredentialsManager.getInstance() - return credentialsManager.getAwsCredentialProvider(credentialsManager.addCredentials("Cred2"), MockRegionProvider.getInstance().defaultRegion()) - } + private fun mockCredentials(): ToolkitCredentialsProvider = + credentialManager.getAwsCredentialProvider(credentialManager.addCredentials("Cred2"), getDefaultRegion()) private object MockExecutable : ExecutableType, Validatable { override val id: String = "Mock" 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 6412bedb33..8a0c56adc9 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockClientManager.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockClientManager.kt @@ -3,36 +3,53 @@ package software.aws.toolkits.jetbrains.core -import com.intellij.openapi.components.ServiceManager -import com.intellij.openapi.project.Project -import com.intellij.testFramework.ProjectRule +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.testFramework.common.ThreadLeakTracker +import com.intellij.testFramework.replaceService +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext import org.junit.rules.ExternalResource +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider import software.amazon.awssdk.core.SdkClient +import software.amazon.awssdk.regions.Region +import software.aws.toolkits.core.ToolkitClientCustomizer import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.clients.SdkClientProvider 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.delegateMock -import software.aws.toolkits.jetbrains.utils.rules.CodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider import kotlin.reflect.KClass -class MockClientManager(project: Project) : AwsClientManager(project) { +class MockClientManager : AwsClientManager() { private data class Key( val clazz: KClass, - val region: AwsRegion? = null, - val credProviderId: String? = null + val regionId: String? = null, + val credProviderId: AwsCredentialsProvider? = null ) private val mockClients = mutableMapOf() @Suppress("UNCHECKED_CAST") - override fun createNewClient(key: AwsClientKey, region: AwsRegion, credProvider: ToolkitCredentialsProvider): T = - mockClients[Key(key.serviceClass, region, credProvider.id)] as? T - ?: mockClients[Key(key.serviceClass)] as? T - ?: throw IllegalStateException("No mock registered for $key") + override fun constructAwsClient( + sdkClass: KClass, + credProvider: AwsCredentialsProvider?, + tokenProvider: SdkTokenProvider?, + region: Region, + endpointOverride: String?, + clientCustomizer: ToolkitClientCustomizer? + ): T = mockClients[Key(sdkClass, region.id(), credProvider)] as? T + ?: mockClients[Key(sdkClass)] as? T + ?: throw IllegalStateException("No mock registered for $sdkClass") override fun dispose() { super.dispose() - reset() + mockClients.clear() } // Note: You must pass KClass of the interface, since we do not do instanceof checks, but == on the classes @@ -44,41 +61,51 @@ class MockClientManager(project: Project) : AwsClientManager(project) { @Deprecated("Do not use, use MockClientManagerRule") fun register(clazz: KClass, sdkClient: T, region: AwsRegion, credProvider: ToolkitCredentialsProvider) { - mockClients[Key(clazz, region, credProvider.id)] = sdkClient + mockClients[Key(clazz, region.id, credProvider)] = sdkClient } - fun reset() { - super.clear() - mockClients.clear() + companion object { + /** + * Replaces all required test services with the real version for the life of the [Disposable] to allow calls to AWS to succeed + */ + fun useRealImplementations(disposable: Disposable) { + val clientManager = AwsClientManager() + ApplicationManager.getApplication().replaceService(ToolkitClientManager::class.java, clientManager, disposable) + + // Need to use real region provider to know about global services + val regionProvider = AwsRegionProvider() + ApplicationManager.getApplication().replaceService(ToolkitRegionProvider::class.java, regionProvider, disposable) + + // Make a new http client that is scoped to the disposable and replace the global one with it, otherwise the apache connection reaper thread + // is detected as leaking threads and fails the tests + // TODO: We aren't closing cred providers and sdks when they are removed, we need to see what ramifications that has + ThreadLeakTracker.longRunningThreadCreated(disposable, "idle-connection-reaper") + + val httpClient = AwsSdkClient() + ApplicationManager.getApplication().replaceService(SdkClientProvider::class.java, httpClient, disposable) + } } } // Scoped to this file only, users should be using MockClientManagerRule to enforce cleanup correctly -private fun getMockInstance(project: Project): MockClientManager = ServiceManager.getService(project, ToolkitClientManager::class.java) as MockClientManager - -class MockClientManagerRule(private val project: () -> Project) : ExternalResource() { - constructor(projectRule: ProjectRule) : this({ projectRule.project }) - constructor(projectRule: CodeInsightTestFixtureRule) : this({ projectRule.project }) +private fun getMockInstance(): MockClientManager = service() as MockClientManager +sealed class MockClientManagerBase : ExternalResource() { private lateinit var mockClientManager: MockClientManager override fun before() { - mockClientManager = getMockInstance(project()) + mockClientManager = getMockInstance() } override fun after() { - mockClientManager.reset() + mockClientManager.dispose() } @PublishedApi @Deprecated("Do not use, visible for inline") internal fun manager() = mockClientManager - fun reset() { - mockClientManager.reset() - } - - inline fun create(): T = delegateMock().also { + inline fun create(): T = delegateMock(verboseLogging = true).also { @Suppress("DEPRECATION") manager().register(T::class, it) } @@ -87,4 +114,21 @@ class MockClientManagerRule(private val project: () -> Project) : ExternalResour @Suppress("DEPRECATION") manager().register(T::class, it, region, credProvider) } + + inline fun register(mock: T): T = mock.also { + @Suppress("DEPRECATION") + manager().register(T::class, it) + } +} + +class MockClientManagerRule : MockClientManagerBase() + +class MockClientManagerExtension : MockClientManagerBase(), BeforeEachCallback, AfterEachCallback { + override fun beforeEach(context: ExtensionContext?) { + before() + } + + override fun afterEach(context: ExtensionContext?) { + after() + } } 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 723be654af..010cfbec2d 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockResourceCache.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/MockResourceCache.kt @@ -4,27 +4,28 @@ package software.aws.toolkits.jetbrains.core import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import com.intellij.testFramework.ProjectRule -import org.junit.rules.ExternalResource +import com.intellij.testFramework.ApplicationRule +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.runner.Description +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.ConnectionSettings +import software.aws.toolkits.core.credentials.ToolkitAuthenticationProvider +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider import software.aws.toolkits.core.credentials.ToolkitCredentialsProvider import software.aws.toolkits.core.region.AwsRegion 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 @Suppress("UNCHECKED_CAST") -class MockResourceCache(private val project: Project) : AwsResourceCache { - +class MockResourceCache : AwsResourceCache { private val map = ConcurrentHashMap() - private val accountSettings by lazy { AwsConnectionManager.getInstance(project) } - - override fun getResourceIfPresent(resource: Resource, useStale: Boolean): T? = - getResourceIfPresent(resource, accountSettings.activeRegion, accountSettings.activeCredentialProvider, useStale) override fun getResourceIfPresent( resource: Resource, @@ -32,13 +33,10 @@ class MockResourceCache(private val project: Project) : AwsResourceCache { credentialProvider: ToolkitCredentialsProvider, useStale: Boolean ): T? = when (resource) { - is Resource.View<*, T> -> getResourceIfPresent(resource.underlying, region, credentialProvider)?.let { resource.doMap(it) } + is Resource.View<*, T> -> getResourceIfPresent(resource.underlying, region, credentialProvider)?.let { resource.doMap(it, region) } is Resource.Cached -> mockResourceIfPresent(resource, region, credentialProvider) } - override fun getResource(resource: Resource, useStale: Boolean, forceFetch: Boolean): CompletionStage = - getResource(resource, accountSettings.activeRegion, accountSettings.activeCredentialProvider, useStale, forceFetch) - override fun getResource( resource: Resource, region: AwsRegion, @@ -46,16 +44,43 @@ class MockResourceCache(private val project: Project) : AwsResourceCache { useStale: Boolean, forceFetch: Boolean ): CompletionStage = when (resource) { - is Resource.View<*, T> -> getResource(resource.underlying, region, credentialProvider, useStale, forceFetch).thenApply { resource.doMap(it as Any) } + is Resource.View<*, T> -> getResource(resource.underlying, region, credentialProvider, useStale, forceFetch).thenApply { + resource.doMap(it as Any, region) + } is Resource.Cached -> { mockResource(resource, region, credentialProvider) } } + override fun getResourceIfPresent( + resource: Resource, + region: AwsRegion, + tokenProvider: ToolkitBearerTokenProvider, + useStale: Boolean + ): T? = when (resource) { + is Resource.View<*, T> -> getResourceIfPresent(resource.underlying, region, tokenProvider)?.let { resource.doMap(it, region) } + is Resource.Cached -> mockResourceIfPresent(resource, region, tokenProvider) + } + + override fun getResource( + resource: Resource, + region: AwsRegion, + tokenProvider: ToolkitBearerTokenProvider, + useStale: Boolean, + forceFetch: Boolean + ): CompletionStage = when (resource) { + is Resource.View<*, T> -> getResource(resource.underlying, region, tokenProvider, useStale, forceFetch).thenApply { + resource.doMap(it as Any, region) + } + is Resource.Cached -> { + mockResource(resource, region, tokenProvider) + } + } + private fun mockResourceIfPresent( resource: Resource.Cached, region: AwsRegion, - credentials: ToolkitCredentialsProvider + credentials: ToolkitAuthenticationProvider ): T? = when (val value = map[CacheKey(resource.id, region.id, credentials.id)]) { is CompletableFuture<*> -> if (value.isDone) value.get() as T else null else -> value as? T? @@ -64,7 +89,7 @@ class MockResourceCache(private val project: Project) : AwsResourceCache { private fun mockResource( resource: Resource.Cached, region: AwsRegion, - credentials: ToolkitCredentialsProvider + credentials: ToolkitAuthenticationProvider ) = when (val value = map[CacheKey(resource.id, region.id, credentials.id)]) { is CompletableFuture<*> -> value as CompletionStage else -> { @@ -77,70 +102,103 @@ class MockResourceCache(private val project: Project) : AwsResourceCache { } } - override fun clear(resource: Resource<*>) { - clear(resource, accountSettings.activeRegion, accountSettings.activeCredentialProvider) - } - - override fun clear(resource: Resource<*>, region: AwsRegion, credentialProvider: ToolkitCredentialsProvider) { + override fun clear(resource: Resource<*>, connectionSettings: ClientConnectionSettings<*>) { when (resource) { - is Resource.Cached<*> -> map.remove(CacheKey(resource.id, region.id, credentialProvider.id)) - is Resource.View<*, *> -> clear(resource.underlying, region, credentialProvider) + is Resource.Cached<*> -> map.remove(CacheKey(resource.id, connectionSettings.region.id, connectionSettings.providerId)) + is Resource.View<*, *> -> clear(resource.underlying, connectionSettings) } } - override fun clear() { + override fun clear(connectionSettings: ClientConnectionSettings<*>) { + map.keys.removeIf { it.credentialsId == connectionSettings.providerId && it.regionId == connectionSettings.region.id } + } + + override suspend fun clear() { map.clear() } fun entryCount() = map.size - fun addEntry(resource: Resource.Cached, value: T) = - addEntry(resource, accountSettings.activeRegion.id, accountSettings.activeCredentialProvider.id, value) + fun addEntry(resourceId: String, regionId: String, credentialsId: String, value: Any) { + map[CacheKey(resourceId, regionId, credentialsId)] = value + } - fun addEntry(resource: Resource.Cached, value: CompletableFuture) = - addEntry(resource, accountSettings.activeRegion.id, accountSettings.activeCredentialProvider.id, value) + companion object { + @JvmStatic + fun getInstance(): MockResourceCache = service() as MockResourceCache - fun addEntry(resource: Resource.Cached, regionId: String, credentialsId: String, value: T) { - map[CacheKey(resource.id, regionId, credentialsId)] = value as Any + private data class CacheKey(val resourceId: String, val regionId: String, val credentialsId: String) } +} - fun addEntry(resource: Resource.Cached, regionId: String, credentialsId: String, value: CompletableFuture) { - map[CacheKey(resource.id, regionId, credentialsId)] = value as Any +class MockResourceCacheRule : ApplicationRule(), MockResourceCacheInterface by MockResourceCacheInterface.delegate() { + public override fun before(description: Description) { + super.before(description) } - fun addValidAwsCredential(regionId: String, credentialsId: String, awsAccountId: String) { - map[CacheKey(StsResources.ACCOUNT.id, regionId, credentialsId)] = awsAccountId as Any + public override fun after() { + runBlocking { cache.clear() } } +} - fun addInvalidAwsCredential(regionId: String, credentialsId: String) { - val future = CompletableFuture() - ApplicationManager.getApplication().executeOnPooledThread { - future.completeExceptionally(IllegalStateException("Invalid AWS credentials $credentialsId")) - } - map[CacheKey(StsResources.ACCOUNT.id, regionId, credentialsId)] = future - } +class MockResourceCacheExtension : BeforeEachCallback, AfterEachCallback, MockResourceCacheInterface by MockResourceCacheInterface.delegate() { + private val rule = MockResourceCacheRule() - companion object { - @JvmStatic - fun getInstance(project: Project): MockResourceCache = ServiceManager.getService(project, AwsResourceCache::class.java) as MockResourceCache + override fun beforeEach(context: ExtensionContext) { + rule.before(Description.EMPTY) + } - private data class CacheKey(val resourceId: String, val regionId: String, val credentialsId: String) + override fun afterEach(context: ExtensionContext) { + rule.after() } } -class MockResourceCacheRule(private val project: () -> Project) : ExternalResource() { - constructor(projectRule: ProjectRule) : this({ projectRule.project }) - constructor(projectRule: CodeInsightTestFixtureRule) : this({ projectRule.project }) +interface MockResourceCacheInterface { + val cache: MockResourceCache + + fun addEntry(resourceId: String, regionId: String, credentialsId: String, value: Any) { + cache.addEntry(resourceId, regionId, credentialsId, value) + } - private lateinit var cache: MockResourceCache + fun addEntry(project: Project, resourceId: String, value: Any) { + val connectionManager = AwsConnectionManager.getInstance(project) + addEntry(resourceId, connectionManager.selectedRegion!!.id, connectionManager.selectedCredentialIdentifier!!.id, value) + } + + fun addEntry(project: Project, resource: Resource.Cached, value: T) { + addEntry(project, resource.id, value as Any) + } - override fun before() { - cache = MockResourceCache.getInstance(project()) + fun addEntry(project: Project, resourceId: String, value: CompletableFuture) { + val connectionManager = AwsConnectionManager.getInstance(project) + addEntry(resourceId, connectionManager.selectedRegion!!.id, connectionManager.selectedCredentialIdentifier!!.id, value) } - override fun after() { - cache.clear() + fun addEntry(project: Project, resource: Resource.Cached, value: CompletableFuture) { + addEntry(project, resource.id, value) } - fun get() = cache + fun addEntry(project: Project, resourceId: String, throws: Exception) { + addEntry(project, resourceId, CompletableFuture.failedFuture(throws)) + } + + fun addEntry(connectionSettings: ConnectionSettings, resource: Resource.Cached, value: CompletableFuture) { + addEntry(resource, connectionSettings.region.id, connectionSettings.credentials.id, value) + } + + fun addEntry(resource: Resource.Cached, regionId: String, credentialsId: String, value: T) { + addEntry(resource.id, regionId, credentialsId, value as Any) + } + + fun addEntry(resource: Resource.Cached, regionId: String, credentialsId: String, value: CompletableFuture) { + addEntry(resource.id, regionId, credentialsId, value) + } + + fun size() = cache.entryCount() + + companion object { + fun delegate() = object : MockResourceCacheInterface { + override val cache by lazy { MockResourceCache.getInstance() } + } + } } diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/PlatformAliases.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/PlatformAliases.kt new file mode 100644 index 0000000000..19def8e62c --- /dev/null +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/PlatformAliases.kt @@ -0,0 +1,7 @@ +// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core + +typealias ToolWindowHeadlessManagerImpl = com.intellij.toolWindow.ToolWindowHeadlessManagerImpl +typealias MockToolWindow = com.intellij.toolWindow.ToolWindowHeadlessManagerImpl.MockToolWindow diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/TestUtils.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/TestUtils.kt index e46b3fca77..103f735ded 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/TestUtils.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/TestUtils.kt @@ -3,35 +3,149 @@ package software.aws.toolkits.jetbrains.core -import com.nhaarman.mockitokotlin2.mock +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.compiler.CompileContext +import com.intellij.openapi.compiler.CompileStatusNotification +import com.intellij.openapi.compiler.CompilerManager +import com.intellij.openapi.compiler.CompilerMessageCategory +import com.intellij.openapi.project.Project +import org.mockito.kotlin.mock +import software.amazon.awssdk.services.apprunner.model.ServiceStatus +import software.amazon.awssdk.services.apprunner.model.ServiceSummary +import software.amazon.awssdk.services.cloudformation.model.StackStatus +import software.amazon.awssdk.services.cloudformation.model.StackSummary +import software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup +import software.amazon.awssdk.services.lambda.model.FunctionConfiguration +import software.amazon.awssdk.services.lambda.model.Runtime +import software.amazon.awssdk.services.lambda.model.TracingConfigResponse +import software.amazon.awssdk.services.lambda.model.TracingMode import software.amazon.awssdk.services.s3.model.Bucket +import software.amazon.awssdk.services.schemas.model.RegistrySummary import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.jetbrains.services.apprunner.resources.AppRunnerResources +import software.aws.toolkits.jetbrains.services.cloudformation.resources.CloudFormationResources +import software.aws.toolkits.jetbrains.services.cloudwatch.logs.resources.CloudWatchResources +import software.aws.toolkits.jetbrains.services.dynamic.CloudControlApiResources +import software.aws.toolkits.jetbrains.services.ecr.resources.EcrResources +import software.aws.toolkits.jetbrains.services.ecr.resources.Repository import software.aws.toolkits.jetbrains.services.ecs.resources.EcsResources +import software.aws.toolkits.jetbrains.services.lambda.resources.LambdaResources import software.aws.toolkits.jetbrains.services.s3.resources.S3Resources +import software.aws.toolkits.jetbrains.services.schemas.resources.SchemasResources +import software.aws.toolkits.jetbrains.services.sqs.resources.SqsResources +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit -fun fillResourceCache(resourceCache: MockResourceCache) { - resourceCache.addEntry( +fun MockResourceCacheRule.fillResourceCache(project: Project) { + this.addEntry( + project, EcsResources.LIST_CLUSTER_ARNS, listOf("arn2", "arn3") ) - resourceCache.addEntry( + this.addEntry( + project, S3Resources.LIST_REGIONALIZED_BUCKETS, listOf(S3Resources.RegionalizedBucket(Bucket.builder().name("abc").build(), AwsRegion.GLOBAL)) ) - resourceCache.addEntry( + this.addEntry( + project, makeMockList("arn2"), listOf("service1", "service2") ) - resourceCache.addEntry( + this.addEntry( + project, makeMockList("arn3"), listOf("service1", "service2") ) + + this.addEntry( + project, + CloudControlApiResources.listTypes(), + CompletableFuture.completedFuture(listOf("Aws::Sample::Resource")) + ) + + this.addEntry( + project, + AppRunnerResources.LIST_SERVICES, + listOf(ServiceSummary.builder().serviceName("sample-service").status(ServiceStatus.OPERATION_IN_PROGRESS).build()) + ) + + this.addEntry( + project, + CloudFormationResources.ACTIVE_STACKS, + listOf(StackSummary.builder().stackName("sample-stack").stackId("sample-stack-ID").stackStatus(StackStatus.CREATE_COMPLETE).build()) + ) + + this.addEntry( + project, + CloudWatchResources.LIST_LOG_GROUPS, + listOf(LogGroup.builder().arn("sample-arn").logGroupName("sample-lg-name").build()) + ) + + this.addEntry( + project, + EcrResources.LIST_REPOS, + listOf(Repository("sample-repo-name", "sample-repo-arn", "sample-repo-uri")) + ) + + this.addEntry( + project, + LambdaResources.LIST_FUNCTIONS, + listOf( + FunctionConfiguration.builder() + .functionName("sample-function") + .functionArn("arn:aws:lambda:us-west-2:0123456789:function:sample-function") + .lastModified("A ways back") + .handler("blah:blah") + .runtime(Runtime.JAVA8) + .role("SomeRoleArn") + .environment { it.variables(emptyMap()) } + .timeout(60) + .memorySize(128) + .tracingConfig(TracingConfigResponse.builder().mode(TracingMode.PASS_THROUGH).build()) + .build() + ) + ) + + this.addEntry( + project, + SchemasResources.LIST_REGISTRIES, + listOf(RegistrySummary.builder().registryName("sample-registry-name").build()) + ) + + this.addEntry( + project, + SqsResources.LIST_QUEUE_URLS, + listOf("https://sqs.us-east-1.amazonaws.com/123456789012/test1") + ) +} + +fun makeMockList(clusterArn: String): Resource.Cached> = mock { + on { id }.thenReturn("ecs.list_services.$clusterArn") } -fun makeMockList(clusterArn: String): Resource.Cached> = - mock { - on { id }.thenReturn("ecs.list_services.$clusterArn") +fun compileProjectAndWait(project: Project) { + val compileFuture = CompletableFuture() + ApplicationManager.getApplication().invokeAndWait { + @Suppress("ObjectLiteralToLambda") + CompilerManager.getInstance(project).rebuild( + object : CompileStatusNotification { + override fun finished(aborted: Boolean, errors: Int, warnings: Int, compileContext: CompileContext) { + if (!aborted && errors == 0) { + compileFuture.complete(compileContext) + } else { + compileFuture.completeExceptionally( + RuntimeException( + "Compilation error: ${compileContext.getMessages(CompilerMessageCategory.ERROR).map { it.message }}" + ) + ) + } + } + } + ) } + compileFuture.get(30, TimeUnit.SECONDS) +} diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/coroutines/CoroutineUtilsTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/coroutines/CoroutineUtilsTest.kt new file mode 100644 index 0000000000..3a1318118a --- /dev/null +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/coroutines/CoroutineUtilsTest.kt @@ -0,0 +1,46 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.coroutines + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.ApplicationRule +import com.intellij.testFramework.DisposableRule +import com.intellij.testFramework.runInEdtAndWait +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test + +class CoroutineUtilsTest { + @Rule + @JvmField + val application = ApplicationRule() + + @Rule + @JvmField + val disposableRule = DisposableRule() + + @Test + fun `getCoroutineUiContext context runs on UI thread`() { + runBlocking { + assertThat(ApplicationManager.getApplication().isDispatchThread).isFalse + withContext(getCoroutineUiContext()) { + assertThat(ApplicationManager.getApplication().isDispatchThread).isTrue + } + } + } + + @Test + fun `getCoroutineBgContext context runs not on UI thread`() { + runInEdtAndWait { + assertThat(ApplicationManager.getApplication().isDispatchThread).isTrue + runBlocking { + withContext(getCoroutineBgContext()) { + assertThat(ApplicationManager.getApplication().isDispatchThread).isFalse + } + } + } + } +} diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/coroutines/ScopeTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/coroutines/ScopeTest.kt new file mode 100644 index 0000000000..d3abaa65cf --- /dev/null +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/coroutines/ScopeTest.kt @@ -0,0 +1,200 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.coroutines + +import com.intellij.ide.highlighter.ProjectFileType +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.ComponentManager +import com.intellij.openapi.project.ex.ProjectManagerEx +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.DisposableRule +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.TestApplicationManager +import com.intellij.testFramework.createTestOpenProjectOptions +import com.intellij.testFramework.replaceService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.rules.TestName +import software.aws.toolkits.jetbrains.utils.isInstanceOf +import java.time.Duration +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class ScopeTest { + @Rule + @JvmField + val tempDir = TemporaryFolder() + + @Rule + @JvmField + val projectRule = ProjectRule() + + @Rule + @JvmField + val disposableRule = DisposableRule() + + @Rule + @JvmField + val testName = TestName() + + @Test + fun `plugin being uploaded cancels application scope`() { + val fakePluginScope = createFakePluginScope() + + assertScopeIsCanceled(applicationCoroutineScope()) { + Disposer.dispose(fakePluginScope) + } + } + + @Test + fun `plugin being uploaded cancels project scope`() { + val fakePluginScope = createFakePluginScope(projectRule.project) + + assertScopeIsCanceled(projectCoroutineScope(projectRule.project)) { + Disposer.dispose(fakePluginScope) + } + } + + @Test + fun `plugin being uploaded cancels disposable scope`() { + val fakePluginScope = createFakePluginScope() + + // Use fake disposable so we dont accidentally trigger false positive, nor disposable leak detector + val fakeDisposable = Disposable { } + assertScopeIsCanceled(disposableCoroutineScope(fakeDisposable)) { + Disposer.dispose(fakePluginScope) + } + } + + @Test + @Ignore("Disposing the application leads to the AppExecutorUtil.getAppExecutorService being shutdown and no way to restart and thus fails all future tests") + fun `application being disposed cancels application scope`() { + assertScopeIsCanceled(applicationCoroutineScope()) { + TestApplicationManager.getInstance().dispose() + } + } + + @Test + fun `project being disposed cancels project scope`() { + val projectFile = tempDir.newFile("${testName.methodName}${ProjectFileType.DOT_DEFAULT_EXTENSION}").toPath() + val options = createTestOpenProjectOptions(runPostStartUpActivities = false) + val project = ProjectManagerEx.getInstanceEx().openProject(projectFile, options)!! + + assertScopeIsCanceled(projectCoroutineScope(project)) { + PlatformTestUtil.forceCloseProjectWithoutSaving(project) + } + } + + @Test + fun `disposable being disposed cancels disposable scope`() { + val disposable = Disposer.newDisposable() + + assertScopeIsCanceled(disposableCoroutineScope(disposable)) { + Disposer.dispose(disposable) + } + } + + @Test + fun `applicationCoroutineScope launches on background thread`() { + assertScopeIsCorrectThread(applicationCoroutineScope()) + } + + @Test + fun `projectCoroutineScope launches on background thread`() { + assertScopeIsCorrectThread(projectCoroutineScope(projectRule.project)) + } + + @Test + fun `disposableCoroutineScope launches on background thread`() { + assertScopeIsCorrectThread(disposableCoroutineScope(disposableRule.disposable)) + } + + @Test + fun `application and project trackers are different`() { + val projectFile = tempDir.newFile("${testName.methodName}${ProjectFileType.DOT_DEFAULT_EXTENSION}").toPath() + val options = createTestOpenProjectOptions(runPostStartUpActivities = false) + val project2 = ProjectManagerEx.getInstanceEx().openProject(projectFile, options)!! + + try { + assertThat( + listOf( + PluginCoroutineScopeTracker.getInstance(), + PluginCoroutineScopeTracker.getInstance(projectRule.project), + PluginCoroutineScopeTracker.getInstance(project2) + ) + ).doesNotHaveDuplicates() + } finally { + PlatformTestUtil.forceCloseProjectWithoutSaving(project2) + } + } + + @Test + fun `disposableCoroutineScope can't take a project`() { + assertThatThrownBy { disposableCoroutineScope(projectRule.project) }.isInstanceOf() + } + + @Test + fun `disposableCoroutineScope can't take an application`() { + assertThatThrownBy { disposableCoroutineScope(ApplicationManager.getApplication()) }.isInstanceOf() + } + + private fun createFakePluginScope(componentManager: ComponentManager = ApplicationManager.getApplication()): Disposable { + // We can't unload the real plugin in tests, so make another instance of the service and replace it for the tests + val tracker = PluginCoroutineScopeTracker() + componentManager.replaceService(PluginCoroutineScopeTracker::class.java, tracker, disposableRule.disposable) + return tracker + } + + private fun assertScopeIsCorrectThread(scope: CoroutineScope) { + val ran = AtomicBoolean(false) + runBlocking(scope.coroutineContext) { + assertThat(ApplicationManager.getApplication().isDispatchThread).isFalse + ran.set(true) + } + assertThat(ran).isTrue + } + + private fun assertScopeIsCanceled(scope: CoroutineScope, cancellationTask: () -> Unit) { + val testTarget = TestTarget(scope) + val future = testTarget.computeAsync().asCompletableFuture() + assertThat(testTarget.computationStarted.await(10, TimeUnit.SECONDS)).isTrue + + cancellationTask() + + testTarget.cancelFired.countDown() + + assertThat(future).failsWithin(Duration.ofSeconds(10)).withThrowableOfType(CancellationException::class.java) + assertThat(testTarget.bgTaskDone.get()).isFalse + } + + @Suppress("BlockingMethodInNonBlockingContext") + private class TestTarget(private val scope: CoroutineScope) { + val computationStarted = CountDownLatch(1) + val cancelFired = CountDownLatch(1) + val bgTaskDone = AtomicBoolean(false) + + fun computeAsync() = scope.async { + computationStarted.countDown() + cancelFired.await() + doTask() + } + + suspend fun doTask() = withContext(getCoroutineBgContext()) { + bgTaskDone.set(true) + } + } +} 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 deleted file mode 100644 index 85958eb83d..0000000000 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/ChangeAccountSettingsActionGroupTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -// 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/ConnectionSettingsMenuBuilderTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilderTest.kt new file mode 100644 index 0000000000..3c4455a3c5 --- /dev/null +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilderTest.kt @@ -0,0 +1,323 @@ +// Copyright 2021 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.ActionGroup +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.Separator +import com.intellij.openapi.util.Ref +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.RuleChain +import com.intellij.testFramework.TestActionEvent +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.core.credentials.CredentialIdentifier +import software.aws.toolkits.core.region.AwsRegion +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsMenuBuilder.Companion.connectionSettingsMenuBuilder +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsMenuBuilder.SwitchCredentialsAction +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsMenuBuilder.SwitchRegionAction +import software.aws.toolkits.jetbrains.core.credentials.MockAwsConnectionManager.ProjectAccountSettingsManagerRule +import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule +import software.aws.toolkits.resources.message + +class ConnectionSettingsMenuBuilderTest { + private val projectRule = ProjectRule() + private val regionProviderRule = MockRegionProviderRule() + private val credentialManagerRule = MockCredentialManagerRule() + private val settingsManagerRule = ProjectAccountSettingsManagerRule(projectRule) + + @Rule + @JvmField + val ruleChain = RuleChain( + projectRule, + regionProviderRule, + credentialManagerRule, + settingsManagerRule + ) + + @Test + fun `all regions are shown if no previous selection`() { + val partition = aString() + regionProviderRule.createAwsRegion(partitionId = partition) + regionProviderRule.createAwsRegion(partitionId = partition) + regionProviderRule.createAwsRegion(partitionId = aString()) + regionProviderRule.createAwsRegion(partitionId = aString()) + + val group = connectionSettingsMenuBuilder() + .withRegions(currentSelection = null) {} + .build() + val actions = group.getChildren().filterIsInstance() + + assertThat(actions).hasSize(AwsRegionProvider.getInstance().allRegions().size) + } + + @Test + fun `only regions from same partition is shown if has a selection`() { + val partition = aString() + val selected = regionProviderRule.createAwsRegion(partitionId = partition) + regionProviderRule.createAwsRegion(partitionId = partition) + regionProviderRule.createAwsRegion(partitionId = aString()) + regionProviderRule.createAwsRegion(partitionId = aString()) + + val group = connectionSettingsMenuBuilder() + .withRegions(currentSelection = selected) {} + .build() + val actions = group.getChildren().filterIsInstance() + + assertThat(actions).hasSize(AwsRegionProvider.getInstance().regions(selected.partitionId).size) + assertThat(actions).allMatch { it.value.partitionId == selected.partitionId } + } + + @Test + fun `other partitions are a sub-menu if has a selection`() { + val partition = aString() + val selected = regionProviderRule.createAwsRegion(partitionId = partition) + regionProviderRule.createAwsRegion(partitionId = partition) + regionProviderRule.createAwsRegion(partitionId = aString()) + regionProviderRule.createAwsRegion(partitionId = aString()) + + val otherPartitions = AwsRegionProvider.getInstance().partitions().keys.filterNot { partitionId -> partitionId == selected.partitionId } + + val group = connectionSettingsMenuBuilder() + .withRegions(currentSelection = selected) {} + .build() + val actions = group.getChildren().filterIsInstance() + + assertThat(actions).singleElement().satisfies { + assertThat(it.isPopup).isTrue + assertThat(it.templatePresentation.text).isEqualTo(message("settings.partitions")) + assertThat(it.getChildren()).hasSize(otherPartitions.size) + } + } + + @Test + fun `other partitions sub-menu is hidden if only 1 partition`() { + val partition = regionProviderRule.defaultPartition().id + val selected = regionProviderRule.createAwsRegion(partitionId = regionProviderRule.defaultRegion().partitionId) + regionProviderRule.createAwsRegion(partitionId = partition) + regionProviderRule.createAwsRegion(partitionId = partition) + + val otherPartitions = AwsRegionProvider.getInstance().partitions().keys.filterNot { partitionId -> partitionId == selected.partitionId } + assertThat(otherPartitions).isEmpty() + + val group = connectionSettingsMenuBuilder() + .withRegions(currentSelection = selected) {} + .build() + val actions = group.getChildren().filterIsInstance() + assertThat(actions).isEmpty() + } + + @Test + fun `can change regions`() { + val holder = Ref.create() + + val group = connectionSettingsMenuBuilder() + .withRegions(currentSelection = null, holder::set) + .build() + val actions = group.getChildren().filterIsInstance() + + assertThat(actions).hasSizeGreaterThanOrEqualTo(1) + val action = actions.first() + + action.actionPerformed(TestActionEvent()) + + assertThat(holder.get()).isEqualTo(action.value) + } + + @Test + fun `can change region only if not same as selected`() { + val holder = Ref.create() + + val partition = aString() + val selected = regionProviderRule.createAwsRegion(partitionId = partition) + val notSelected = regionProviderRule.createAwsRegion(partitionId = partition) + + val group = connectionSettingsMenuBuilder() + .withRegions(currentSelection = selected, holder::set) + .build() + val actions = group.getChildren().filterIsInstance() + + val selectedAction = actions.first { it.value == selected } + selectedAction.actionPerformed(TestActionEvent()) + assertThat(holder.isNull).isTrue + + val notSelectedAction = actions.first { it.value == notSelected } + notSelectedAction.actionPerformed(TestActionEvent()) + assertThat(holder.get()).isEqualTo(notSelected) + } + + @Test + fun `all credentials are shown`() { + credentialManagerRule.addCredentials() + credentialManagerRule.addCredentials() + credentialManagerRule.addCredentials() + credentialManagerRule.addCredentials() + credentialManagerRule.addCredentials() + + val group = connectionSettingsMenuBuilder() + .withCredentials(currentSelection = null) {} + .build() + val actions = group.getChildren().filterIsInstance() + + assertThat(actions).hasSize(CredentialManager.getInstance().getCredentialIdentifiers().size) + } + + @Test + fun `can change credentials`() { + val holder = Ref.create() + + credentialManagerRule.addCredentials() + + val group = connectionSettingsMenuBuilder() + .withCredentials(currentSelection = null, holder::set) + .build() + val actions = group.getChildren().filterIsInstance() + + assertThat(actions).hasSizeGreaterThan(1) + val action = actions.first() + + action.actionPerformed(TestActionEvent()) + + assertThat(holder.get()).isEqualTo(action.value) + } + + @Test + fun `can change credentials only if not same as selected`() { + val holder = Ref.create() + + val selected = credentialManagerRule.addCredentials() + val notSelected = credentialManagerRule.addCredentials() + + val group = connectionSettingsMenuBuilder() + .withCredentials(currentSelection = selected, holder::set) + .build() + val actions = group.getChildren().filterIsInstance() + + val selectedAction = actions.first { it.value == selected } + selectedAction.actionPerformed(TestActionEvent()) + assertThat(holder.isNull).isTrue + + val notSelectedAction = actions.first { it.value == notSelected } + notSelectedAction.actionPerformed(TestActionEvent()) + assertThat(holder.get()).isEqualTo(notSelected) + } + + @Test + fun `both credentials and regions can be in the same menu`() { + regionProviderRule.createAwsRegion() + regionProviderRule.createAwsRegion() + regionProviderRule.createAwsRegion() + + credentialManagerRule.addCredentials() + credentialManagerRule.addCredentials() + credentialManagerRule.addCredentials() + + val group = connectionSettingsMenuBuilder() + .withCredentials(currentSelection = null) {} + .withRegions(currentSelection = null) {} + .build() + + val regionActions = group.getChildren().filterIsInstance() + val credentialActions = group.getChildren().filterIsInstance() + + assertThat(regionActions).hasSize(AwsRegionProvider.getInstance().allRegions().size) + assertThat(credentialActions).hasSize(CredentialManager.getInstance().getCredentialIdentifiers().size) + } + + @Test + fun `recent regions are shown, with all in a sub-menu`() { + val settingsManager = settingsManagerRule.settingsManager + + settingsManager.addRecentRegion(regionProviderRule.createAwsRegion()) + settingsManager.addRecentRegion(regionProviderRule.createAwsRegion()) + regionProviderRule.createAwsRegion() + regionProviderRule.createAwsRegion() + + val group = connectionSettingsMenuBuilder() + .withRegions(currentSelection = null) {} + .withRecentChoices(projectRule.project) + .build() + + val recentActions = group.getChildren().filterIsInstance() + val allRegions = group.getChildren().filterIsInstance() + .first { it.templatePresentation.text == message("settings.regions.region_sub_menu") } + .getChildren() + .filterIsInstance() + + assertThat(recentActions).hasSize(settingsManager.recentlyUsedRegions().size) + assertThat(allRegions).hasSize(AwsRegionProvider.getInstance().allRegions().size) + } + + @Test + fun `if no recent regions, all are shown`() { + settingsManagerRule.settingsManager.clearRecentRegions() + + regionProviderRule.createAwsRegion() + regionProviderRule.createAwsRegion() + regionProviderRule.createAwsRegion() + regionProviderRule.createAwsRegion() + + val group = connectionSettingsMenuBuilder() + .withRegions(currentSelection = null) {} + .withRecentChoices(projectRule.project) + .build() + + val titleAction = group.getChildren().filterIsInstance().filter { it.text == message("settings.regions.recent") } + val regionActions = group.getChildren().filterIsInstance() + + assertThat(titleAction).isEmpty() + assertThat(regionActions).hasSize(AwsRegionProvider.getInstance().allRegions().size) + } + + @Test + fun `recent credentials are shown, with all in a sub-menu`() { + val settingsManager = settingsManagerRule.settingsManager + + settingsManager.addRecentCredentials(credentialManagerRule.addCredentials()) + settingsManager.addRecentCredentials(credentialManagerRule.addCredentials()) + credentialManagerRule.addCredentials() + credentialManagerRule.addCredentials() + + val group = connectionSettingsMenuBuilder() + .withCredentials(currentSelection = null) {} + .withRecentChoices(projectRule.project) + .build() + + val titleAction = group.getChildren().filterIsInstance().filter { it.text == message("settings.credentials.iam") } + val recentActions = group.getChildren().filterIsInstance() + val allCredentials = group.getChildren().filterIsInstance() + .first { it.templatePresentation.text == message("settings.credentials.profile_sub_menu") } + .getChildren() + .filterIsInstance() + + assertThat(titleAction).singleElement() + assertThat(recentActions).hasSize(settingsManager.recentlyUsedCredentials().size) + assertThat(allCredentials).hasSize(CredentialManager.getInstance().getCredentialIdentifiers().size) + } + + @Test + fun `if no recent credentials, all are shown`() { + settingsManagerRule.settingsManager.clearRecentCredentials() + + credentialManagerRule.addCredentials() + credentialManagerRule.addCredentials() + credentialManagerRule.addCredentials() + + val group = connectionSettingsMenuBuilder() + .withCredentials(currentSelection = null) {} + .withRecentChoices(projectRule.project) + .build() + + val titleAction = group.getChildren().filterIsInstance().filter { it.text == message("settings.credentials.recent") } + val credentialsActions = group.getChildren().filterIsInstance() + + assertThat(titleAction).isEmpty() + assertThat(credentialsActions).hasSize(CredentialManager.getInstance().getCredentialIdentifiers().size) + } + + private fun ActionGroup.getChildren(): Array = this.getChildren(TestActionEvent()) +} diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CreateOrUpdateCredentialProfilesActionTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CreateOrUpdateCredentialProfilesActionTest.kt index b6d1267197..7b59bb01dc 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CreateOrUpdateCredentialProfilesActionTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CreateOrUpdateCredentialProfilesActionTest.kt @@ -3,27 +3,27 @@ package software.aws.toolkits.jetbrains.core.credentials -import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileTypes.FileTypes import com.intellij.openapi.fileTypes.ex.FileTypeManagerEx -import com.intellij.openapi.ui.Messages import com.intellij.openapi.ui.TestDialog +import com.intellij.openapi.ui.TestDialogManager import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.testFramework.ProjectRule import com.intellij.testFramework.TestActionEvent import com.intellij.testFramework.runInEdtAndWait -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.doAnswer -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyZeroInteractions import org.assertj.core.api.Assertions.assertThat import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import java.io.File class CreateOrUpdateCredentialProfilesActionTest { @@ -36,8 +36,14 @@ class CreateOrUpdateCredentialProfilesActionTest { @JvmField val projectRule = ProjectRule() - private val fileEditorManager = FileEditorManager.getInstance(projectRule.project) - private val localFileSystem = LocalFileSystem.getInstance() + private lateinit var fileEditorManager: FileEditorManager + private lateinit var localFileSystem: LocalFileSystem + + @Before + fun setUp() { + fileEditorManager = FileEditorManager.getInstance(projectRule.project) + localFileSystem = LocalFileSystem.getInstance() + } @After fun cleanUp() { @@ -48,39 +54,46 @@ class CreateOrUpdateCredentialProfilesActionTest { @Test fun confirmConfigFileCreated_bothFilesDoNotExist() { - val writer = mock { - on { createFile(any()) }.doAnswer { it.getArgument(0).writeText("hello") } - } - val configFile = File(folderRule.newFolder(), "config") val credFile = File(folderRule.newFolder(), "credentials") - val sut = CreateOrUpdateCredentialProfilesAction(writer, configFile, credFile) - Messages.setTestDialog(TestDialog.OK) + val writer = mock { + on { configPath }.thenReturn(configFile.toPath()) + on { credentialsPath }.thenReturn(credFile.toPath()) + on { createConfigFile() }.doAnswer { configFile.writeText("hello") } + } + + val sut = CreateOrUpdateCredentialProfilesAction(writer) + TestDialogManager.setTestDialog(TestDialog.OK) - sut.actionPerformed(TestActionEvent(DataContext { projectRule.project })) + sut.actionPerformed(TestActionEvent { projectRule.project }) - verify(writer).createFile(configFile) + verify(writer).createConfigFile() assertThat(fileEditorManager.openFiles).hasOnlyOneElementSatisfying { assertThat(it.name).isEqualTo("config") } } @Test fun bothFilesOpened_bothFilesExists() { - val writer = mock() - val configFile = folderRule.newFile("config") val credFile = folderRule.newFile("credentials") + val writer = mock { + on { configPath }.thenReturn(configFile.toPath()) + on { credentialsPath }.thenReturn(credFile.toPath()) + } + // IDE interprets blank files with no extension as binary configFile.writeText("config") credFile.writeText("cred") - val sut = CreateOrUpdateCredentialProfilesAction(writer, configFile, credFile) - Messages.setTestDialog(TestDialog.OK) + val sut = CreateOrUpdateCredentialProfilesAction(writer) + TestDialogManager.setTestDialog(TestDialog.OK) - sut.actionPerformed(TestActionEvent(DataContext { projectRule.project })) + sut.actionPerformed(TestActionEvent { projectRule.project }) - verifyZeroInteractions(writer) + verify(writer, atLeastOnce()).configPath + verify(writer, atLeastOnce()).credentialsPath + verifyNoMoreInteractions(writer) assertThat(fileEditorManager.openFiles).hasSize(2) .anySatisfy { assertThat(it.name).isEqualTo("config") } @@ -89,52 +102,67 @@ class CreateOrUpdateCredentialProfilesActionTest { @Test fun configFileOpened_onlyConfigExists() { - val writer = mock() - val configFile = folderRule.newFile("config") - val credFile = File(folderRule.newFolder(), "credentials") + val credFile = folderRule.newFile("credentials") + credFile.delete() + val writer = mock { + on { configPath }.thenReturn(configFile.toPath()) + on { credentialsPath }.thenReturn(credFile.toPath()) + } + configFile.writeText("config") - val sut = CreateOrUpdateCredentialProfilesAction(writer, configFile, credFile) - Messages.setTestDialog(TestDialog.OK) + val sut = CreateOrUpdateCredentialProfilesAction(writer) + TestDialogManager.setTestDialog(TestDialog.OK) - sut.actionPerformed(TestActionEvent(DataContext { projectRule.project })) + sut.actionPerformed(TestActionEvent { projectRule.project }) - verifyZeroInteractions(writer) + verify(writer, atLeastOnce()).configPath + verify(writer, atLeastOnce()).credentialsPath + verifyNoMoreInteractions(writer) assertThat(fileEditorManager.openFiles).hasOnlyOneElementSatisfying { assertThat(it.name).isEqualTo("config") } } @Test fun credentialFileOpened_onlyCredentialsExists() { - val writer = mock() - - val configFile = File(folderRule.newFolder(), "config") + val configFile = folderRule.newFile("config") + configFile.delete() val credFile = folderRule.newFile("credentials") + val writer = mock { + on { configPath }.thenReturn(configFile.toPath()) + on { credentialsPath }.thenReturn(credFile.toPath()) + } + credFile.writeText("cred") - val sut = CreateOrUpdateCredentialProfilesAction(writer, configFile, credFile) - Messages.setTestDialog(TestDialog.OK) + val sut = CreateOrUpdateCredentialProfilesAction(writer) + TestDialogManager.setTestDialog(TestDialog.OK) - sut.actionPerformed(TestActionEvent(DataContext { projectRule.project })) + sut.actionPerformed(TestActionEvent { projectRule.project }) - verifyZeroInteractions(writer) + verify(writer, atLeastOnce()).configPath + verify(writer, atLeastOnce()).credentialsPath + verifyNoMoreInteractions(writer) assertThat(fileEditorManager.openFiles).hasOnlyOneElementSatisfying { assertThat(it.name).isEqualTo("credentials") } } @Test fun emptyFileCanBeOpenedAsPlainText() { - val writer = mock() - - val configFile = File(folderRule.newFolder(), "config") + val configFile = folderRule.newFile("config") val credFile = folderRule.newFile("credentials") + configFile.delete() + val writer = mock { + on { configPath }.thenReturn(configFile.toPath()) + on { credentialsPath }.thenReturn(credFile.toPath()) + } // Mark the file as unknown for the purpose of the test. This is needed because some // other extensions can have weird file type association patterns (like Docker having - // *. (?)) which makes this test fail because it is not file type unkown - val file = listOf(localFileSystem.refreshAndFindFileByIoFile(credFile)) - localFileSystem.refreshFiles(file, false, false) { + // *. (?)) which makes this test fail because it is not file type unknown + localFileSystem.refreshAndFindFileByIoFile(credFile) + runInEdtAndWait { ApplicationManager.getApplication().runWriteAction { FileTypeManagerEx.getInstanceEx().associatePattern( FileTypes.UNKNOWN, @@ -143,31 +171,39 @@ class CreateOrUpdateCredentialProfilesActionTest { } } - val sut = CreateOrUpdateCredentialProfilesAction(writer, configFile, credFile) - Messages.setTestDialog(TestDialog.OK) + val sut = CreateOrUpdateCredentialProfilesAction(writer) + TestDialogManager.setTestDialog(TestDialog.OK) - sut.actionPerformed(TestActionEvent(DataContext { projectRule.project })) + sut.actionPerformed(TestActionEvent { projectRule.project }) - verifyZeroInteractions(writer) + verify(writer, atLeastOnce()).configPath + verify(writer, atLeastOnce()).credentialsPath + verifyNoMoreInteractions(writer) assertThat(fileEditorManager.openFiles).hasOnlyOneElementSatisfying { assertThat(it.name).isEqualTo("credentials") - assertThat(it.fileType).isEqualTo(FileTypes.PLAIN_TEXT) + // FIX_WHEN_MIN_IS_212: assert that type is `FileTypes.PLAIN_TEXT` or `DetectedByContentFileType` + assertThat(it.fileType).isNotNull() + assertThat(it.fileType).isNotEqualTo(FileTypes.UNKNOWN) } } @Test fun negativeConfirmationDoesNotCreateFile() { - val writer = mock() - - val configFile = File(folderRule.newFolder(), "config") - val credFile = File(folderRule.newFolder(), "credentials") + val configFile = folderRule.newFile("config") + val credFile = folderRule.newFile("credentials") + val writer = mock { + on { configPath }.thenReturn(configFile.toPath()) + on { credentialsPath }.thenReturn(credFile.toPath()) + } - val sut = CreateOrUpdateCredentialProfilesAction(writer, configFile, credFile) - Messages.setTestDialog(TestDialog.NO) + val sut = CreateOrUpdateCredentialProfilesAction(writer) + TestDialogManager.setTestDialog(TestDialog.NO) - sut.actionPerformed(TestActionEvent(DataContext { projectRule.project })) + sut.actionPerformed(TestActionEvent { projectRule.project }) - verifyZeroInteractions(writer) + verify(writer, atLeastOnce()).configPath + verify(writer, atLeastOnce()).credentialsPath + verifyNoMoreInteractions(writer) } } 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 d4357a4000..3cf032bf99 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 @@ -3,27 +3,36 @@ package software.aws.toolkits.jetbrains.core.credentials +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.testFramework.ApplicationRule import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.ExtensionTestUtil +import com.intellij.testFramework.runInEdtAndWait import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Rule import org.junit.Test 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.StaticCredentialsProvider -import software.amazon.awssdk.http.SdkHttpClient 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.CredentialProviderNotFoundException +import software.aws.toolkits.core.credentials.CredentialSourceId import software.aws.toolkits.core.credentials.CredentialsChangeEvent import software.aws.toolkits.core.credentials.CredentialsChangeListener -import software.aws.toolkits.core.credentials.CredentialIdentifierBase import software.aws.toolkits.core.region.AwsRegion -import software.aws.toolkits.jetbrains.core.region.MockRegionProvider +import software.aws.toolkits.core.region.anAwsRegion +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule +import software.aws.toolkits.jetbrains.core.region.getDefaultRegion +import software.aws.toolkits.jetbrains.utils.assertIsNonDispatchThread +import software.aws.toolkits.jetbrains.utils.computeOnEdt +import software.aws.toolkits.jetbrains.utils.isInstanceOf import kotlin.test.assertNotNull -import kotlin.test.assertNull class CredentialManagerTest { @Rule @@ -34,24 +43,22 @@ class CredentialManagerTest { @JvmField val application = ApplicationRule() + @Rule + @JvmField + val regionProvider = MockRegionProviderRule() + @Test fun testCredentialsCanLoadFromExtensions() { - val region = MockRegionProvider.getInstance().defaultRegion() + val region = getDefaultRegion() addFactories( createTestCredentialFactory( "testFactory1", - mapOf( - "testFoo1" to region to createCredentials("testFoo1"), - "testBar1" to region to createCredentials("testBar1") - ) + listOf("testFoo1", "testBar1") ), createTestCredentialFactory( "testFactory2", - mapOf( - "testFoo2" to region to createCredentials("testFoo2"), - "testBar2" to region to createCredentials("testBar2") - ) + listOf("testFoo2", "testBar2") ) ) @@ -64,56 +71,36 @@ class CredentialManagerTest { val credentialProvider = credentialManager.getAwsCredentialProvider(credentialsIdentifier, region) - assertThat(credentialProvider.resolveCredentials()).isInstanceOfSatisfying(AwsBasicCredentials::class.java) { - assertThat(it.accessKeyId()).isEqualTo("testFoo2Access") - assertThat(it.secretAccessKey()).isEqualTo("testFoo2Secret") - } + assertThat(credentialProvider.resolveCredentials()).isInstanceOf() } @Test fun testCredentialsAreScopedToPartition() { - val partition1 = AwsRegion("test-1", "Test-1", "aws-test-1") - val partition2 = AwsRegion("test-1", "Test-1", "aws-test-2") + val partition1 = anAwsRegion(partitionId = "part1") + val partition1Region2 = anAwsRegion(partitionId = "part1") + val partition2 = anAwsRegion(partitionId = "part2") - addFactories( - createTestCredentialFactory( - "testFactory1", - mapOf( - "testFoo1" to partition1 to createCredentials(partition1.partitionId), - "testFoo1" to partition2 to createCredentials(partition2.partitionId) - ) - ) - ) + addFactories(createTestCredentialFactory("testFactory1", listOf("testFoo1"))) val credentialManager = DefaultCredentialManager() val credentialsIdentifier = credentialManager.getCredentialIdentifierById("testFoo1") assertNotNull(credentialsIdentifier) - val credentialProvider = credentialManager.getAwsCredentialProvider(credentialsIdentifier, partition1) - - assertThat(credentialProvider.resolveCredentials()).isInstanceOfSatisfying(AwsBasicCredentials::class.java) { - assertThat(it.accessKeyId()).isEqualTo("aws-test-1Access") - assertThat(it.secretAccessKey()).isEqualTo("aws-test-1Secret") - } - - val credentialProvider2 = credentialManager.getAwsCredentialProvider(credentialsIdentifier, partition2) + val partition1Credentials = credentialManager.getAwsCredentialProvider(credentialsIdentifier, partition1).resolveCredentials() + val partition1Region2Credentials = credentialManager.getAwsCredentialProvider(credentialsIdentifier, partition1Region2).resolveCredentials() + val partition2Credentials = credentialManager.getAwsCredentialProvider(credentialsIdentifier, partition2).resolveCredentials() - assertThat(credentialProvider2.resolveCredentials()).isInstanceOfSatisfying(AwsBasicCredentials::class.java) { - assertThat(it.accessKeyId()).isEqualTo("aws-test-2Access") - assertThat(it.secretAccessKey()).isEqualTo("aws-test-2Secret") - } + assertThat(partition1Credentials).isEqualTo(partition1Region2Credentials).isNotEqualTo(partition2Credentials) } @Test fun testCredentialUpdatingDoesNotBreakExisting() { - val region = MockRegionProvider.getInstance().defaultRegion() - val credentialFactory = createTestCredentialFactory( - "testFactory1", - mapOf( - "testFoo1" to region to createCredentials("testFoo1") - ) - ) + val region = getDefaultRegion() + val originalCredentials = randomCredentialProvider() + val credentialFactory = createTestCredentialFactory("testFactory1").apply { + addCredentialProvider("testFoo1", originalCredentials) + } addFactories(credentialFactory) @@ -124,23 +111,13 @@ class CredentialManagerTest { val credentialProvider = credentialManager.getAwsCredentialProvider(credentialsIdentifier, region) - assertThat(credentialProvider.resolveCredentials()).isInstanceOfSatisfying(AwsBasicCredentials::class.java) { - assertThat(it.accessKeyId()).isEqualTo("testFoo1Access") - assertThat(it.secretAccessKey()).isEqualTo("testFoo1Secret") - } + assertThat(credentialProvider.resolveCredentials()).isEqualTo(originalCredentials.resolveCredentials()) - credentialFactory.updateCredentials( - "testFoo1", - mapOf( - region to createCredentials("testFoo1Updated") - ) - ) + val updatedCredentials = randomCredentialProvider() + credentialFactory.updateCredentials("testFoo1", region, updatedCredentials) // Existing references are good - assertThat(credentialProvider.resolveCredentials()).isInstanceOfSatisfying(AwsBasicCredentials::class.java) { - assertThat(it.accessKeyId()).isEqualTo("testFoo1UpdatedAccess") - assertThat(it.secretAccessKey()).isEqualTo("testFoo1UpdatedSecret") - } + assertThat(credentialProvider.resolveCredentials()).isEqualTo(updatedCredentials.resolveCredentials()) // New ones are good too assertThat( @@ -148,21 +125,13 @@ class CredentialManagerTest { credentialsIdentifier, region ).resolveCredentials() - ).isInstanceOfSatisfying(AwsBasicCredentials::class.java) { - assertThat(it.accessKeyId()).isEqualTo("testFoo1UpdatedAccess") - assertThat(it.secretAccessKey()).isEqualTo("testFoo1UpdatedSecret") - } + ).isEqualTo(updatedCredentials.resolveCredentials()) } @Test fun testRemovedCredentialsCeaseWorkingAfter() { - val region = MockRegionProvider.getInstance().defaultRegion() - val credentialFactory = createTestCredentialFactory( - "testFactory1", - mapOf( - "testFoo1" to region to createCredentials("testFoo1") - ) - ) + val region = getDefaultRegion() + val credentialFactory = createTestCredentialFactory("testFactory1", listOf("testFoo1")) addFactories(credentialFactory) @@ -172,11 +141,7 @@ class CredentialManagerTest { assertNotNull(credentialsIdentifier) val credentialProvider = credentialManager.getAwsCredentialProvider(credentialsIdentifier, region) - - assertThat(credentialProvider.resolveCredentials()).isInstanceOfSatisfying(AwsBasicCredentials::class.java) { - assertThat(it.accessKeyId()).isEqualTo("testFoo1Access") - assertThat(it.secretAccessKey()).isEqualTo("testFoo1Secret") - } + assertThat(credentialProvider.resolveCredentials()).isInstanceOf() credentialFactory.removeCredentials("testFoo1") @@ -189,80 +154,163 @@ class CredentialManagerTest { credentialsIdentifier, region ).resolveCredentials() - }.isInstanceOf(CredentialProviderNotFoundException::class.java) + }.isInstanceOf() - assertNull(credentialManager.getCredentialIdentifierById("testFoo1")) + assertThat(credentialManager.getCredentialIdentifierById("testFoo1")).isNull() + } + + @Test + fun testUpdatedCredentialIdentifierIsApplied() { + val region = getDefaultRegion() + val credentialFactory = createTestCredentialFactory("testFactory1", listOf("testFoo1")) + + addFactories(credentialFactory) + + val credentialManager = DefaultCredentialManager() + + assertThat(credentialManager.getCredentialIdentifierById("testFoo1")?.defaultRegionId).isEqualTo(region.id) + + val newRegion = regionProvider.addRegion(AwsRegion("test", "test", "test")) + credentialFactory.updateCredentials("testFoo1", newRegion) + + assertThat(credentialManager.getCredentialIdentifierById("testFoo1")?.defaultRegionId).isEqualTo(newRegion.id) + } + + @Test + fun resolvingCredentialsRunsInBackground() { + val credentialFactory = createTestCredentialFactory("testFactory1").apply { + addCredentialProvider("testFoo1") { + assertIsNonDispatchThread() + computeOnEdt { + ApplicationManager.getApplication().assertIsDispatchThread() + + AwsBasicCredentials.create(aString(), aString()) + } + } + } + + addFactories(credentialFactory) + + val credentialManager = DefaultCredentialManager() + val credentialsIdentifier = credentialManager.getCredentialIdentifierById("testFoo1") + assertNotNull(credentialsIdentifier) + val credentialProvider = credentialManager.getAwsCredentialProvider(credentialsIdentifier, getDefaultRegion()) + + runInEdtAndWait { + credentialProvider.resolveCredentials() + } + } + + @Test + fun processCancellationBubblesOut() { + val credentialFactory = createTestCredentialFactory("testFactory1").apply { + addCredentialProvider("testFoo1") { + throw ProcessCanceledException() + } + } + + addFactories(credentialFactory) + + val credentialManager = DefaultCredentialManager() + val credentialsIdentifier = credentialManager.getCredentialIdentifierById("testFoo1") + assertNotNull(credentialsIdentifier) + val credentialProvider = credentialManager.getAwsCredentialProvider(credentialsIdentifier, getDefaultRegion()) + + assertThatThrownBy { + credentialProvider.resolveCredentials() + }.isInstanceOf() } private fun addFactories(vararg factories: CredentialProviderFactory) { ExtensionTestUtil.maskExtensions(DefaultCredentialManager.EP_NAME, factories.toList(), disposableRule.disposable) } - private fun createCredentials(id: String) = StaticCredentialsProvider.create(AwsBasicCredentials.create("${id}Access", "${id}Secret")) - - private fun createTestCredentialFactory( - id: String, - initialCredentials: Map, AwsCredentialsProvider> - ): TestCredentialProviderFactory = TestCredentialProviderFactory(id, initialCredentials) + private fun createTestCredentialFactory(id: String, initialProviderIds: List = emptyList()) = TestCredentialProviderFactory(id).apply { + initialProviderIds.forEach(this::addCredentialProvider) + } - private class TestCredentialProviderFactory( - override val id: String, - private val initialCredentials: Map, AwsCredentialsProvider> - ) : CredentialProviderFactory { - private val credentialsMapping = mutableMapOf>() + private class TestCredentialProviderFactory(override val id: String) : CredentialProviderFactory { + private val initialProviders = mutableMapOf() + private val credentialsMapping = mutableMapOf() private lateinit var callback: CredentialsChangeListener + override val credentialSourceId = CredentialSourceId.SharedCredentials + override fun setUp(credentialLoadCallback: CredentialsChangeListener) { callback = credentialLoadCallback - val credentialsAdded = initialCredentials - .onEach { - val providerIdCredentials = credentialsMapping.computeIfAbsent(it.key.first) { mutableMapOf() } - providerIdCredentials[it.key.second] = it.value - } - .map { createCredentialIdentifier(it.key.first) } + credentialsMapping.putAll(initialProviders) callback( CredentialsChangeEvent( - credentialsAdded, + initialProviders.values.toList(), emptyList(), emptyList() ) ) + + initialProviders.clear() } - override fun createAwsCredentialProvider( - providerId: CredentialIdentifier, - region: AwsRegion, - sdkHttpClientSupplier: () -> SdkHttpClient - ): AwsCredentialsProvider = credentialsMapping.getValue(providerId.id).getValue(region) + fun addCredentialProvider( + credentialId: String, + awsCredentialsProvider: AwsCredentialsProvider? = null + ) { + val identifier = TestCredentialProviderIdentifier(credentialId, id, getDefaultRegion().id, awsCredentialsProvider) + if (!::callback.isInitialized) { + initialProviders[credentialId] = identifier + return + } - private fun createCredentialIdentifier(providerId: String): TestCredentialProviderIdentifier = TestCredentialProviderIdentifier(providerId, id) + credentialsMapping[credentialId] = identifier - fun updateCredentials(providerId: String, credentials: Map) { - credentialsMapping[providerId] = credentials.toMutableMap() callback( CredentialsChangeEvent( + listOf(identifier), emptyList(), - listOf(createCredentialIdentifier(providerId)), + emptyList() + ) + ) + } + + override fun createAwsCredentialProvider(providerId: CredentialIdentifier, region: AwsRegion): AwsCredentialsProvider = + (providerId as TestCredentialProviderIdentifier).provider ?: StaticCredentialsProvider.create(AwsBasicCredentials.create(aString(), aString())) + + fun updateCredentials(providerId: String, region: AwsRegion, awsCredentialsProvider: AwsCredentialsProvider = randomCredentialProvider()) { + val identifier = TestCredentialProviderIdentifier(providerId, id, region.id, awsCredentialsProvider) + + credentialsMapping[providerId] = identifier + + callback( + CredentialsChangeEvent( + emptyList(), + listOf(identifier), emptyList() ) ) } fun removeCredentials(providerId: String) { - credentialsMapping.remove(providerId) callback( CredentialsChangeEvent( emptyList(), emptyList(), - listOf(createCredentialIdentifier(providerId)) + listOf(credentialsMapping.remove(providerId)!!) ) ) } } - private class TestCredentialProviderIdentifier(override val id: String, override val factoryId: String) : CredentialIdentifierBase() { + private class TestCredentialProviderIdentifier( + override val id: String, + override val factoryId: String, + override val defaultRegionId: String, + val provider: AwsCredentialsProvider? + ) : CredentialIdentifierBase(null) { override val displayName: String = "$factoryId:$id" } + + private companion object { + private fun randomCredentialProvider() = StaticCredentialsProvider.create(AwsBasicCredentials.create(aString(), aString())) + } } diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialsFileHelpNotificationProviderTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialsFileHelpNotificationProviderTest.kt new file mode 100644 index 0000000000..b1e52a3c54 --- /dev/null +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialsFileHelpNotificationProviderTest.kt @@ -0,0 +1,77 @@ +// Copyright 2021 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.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.EdtRule +import com.intellij.testFramework.RunsInEdt +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 org.junit.rules.TemporaryFolder +import software.aws.toolkits.core.rules.SystemPropertyHelper +import software.aws.toolkits.jetbrains.core.credentials.CredentialsFileHelpNotificationProvider.CredentialFileNotificationPanel +import software.aws.toolkits.jetbrains.core.getEditorNotifications +import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule + +@RunsInEdt +class CredentialsFileHelpNotificationProviderTest { + @Rule + @JvmField + val projectRule = HeavyJavaCodeInsightTestFixtureRule() + + @Rule + @JvmField + val systemPropertyHelper = SystemPropertyHelper() + + @Rule + @JvmField + val temporaryFolder = TemporaryFolder() + + @Rule + @JvmField + val edtRule = EdtRule() + + private lateinit var configFile: VirtualFile + private lateinit var credentialsFile: VirtualFile + + @Before + fun setUp() { + runInEdtAndWait { + configFile = projectRule.fixture.tempDirFixture.createFile("config", "[default]") + credentialsFile = projectRule.fixture.tempDirFixture.createFile("credentials", "[default]") + } + + System.getProperties().setProperty("aws.configFile", configFile.toNioPath().toString()) + System.getProperties().setProperty("aws.sharedCredentialsFile", credentialsFile.toNioPath().toString()) + } + + @Test + fun `notification gets shown on config file`() { + val editor = openEditor(credentialsFile) + assertThat(getEditorNotifications(editor)).isNotNull + } + + @Test + fun `notification gets shown on credentials file`() { + val editor = openEditor(credentialsFile) + assertThat(getEditorNotifications(editor)).isNotNull + } + + @Test + fun `notification not shown on non credentials files`() { + val editor = openEditor(projectRule.fixture.tempDirFixture.createFile("foo.txt")) + assertThat(getEditorNotifications(editor)).isNull() + } + + private fun openEditor(file: VirtualFile): FileEditor = FileEditorManagerEx.getInstanceEx(projectRule.project).openFile(file, true).single() + + private fun getEditorNotifications(editor: FileEditor): CredentialFileNotificationPanel? = + getEditorNotifications(projectRule.project, editor, CredentialsFileHelpNotificationProvider::class.java, CredentialsFileHelpNotificationProvider.KEY) + as CredentialFileNotificationPanel? +} 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 index 2257d8df72..6de5430f8b 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandlerTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/CredentialsRegionHandlerTest.kt @@ -4,8 +4,8 @@ package software.aws.toolkits.jetbrains.core.credentials import com.intellij.notification.Notification +import com.intellij.testFramework.DisposableRule 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 @@ -13,11 +13,11 @@ 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.core.region.MockRegionProviderRule 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.jetbrains.utils.rules.NotificationListenerRule import software.aws.toolkits.resources.message class CredentialsRegionHandlerTest { @@ -28,11 +28,15 @@ class CredentialsRegionHandlerTest { @Rule @JvmField - val regionProviderRule = RegionProviderRule() + val regionProviderRule = MockRegionProviderRule() @Rule @JvmField - val notificationListener = NotificationListenerRule(projectRule) + val disposableRule = DisposableRule() + + @Rule + @JvmField + val notificationListener = NotificationListenerRule(projectRule, disposableRule.disposable) private lateinit var sut: DefaultCredentialsRegionHandler @@ -150,7 +154,7 @@ class CredentialsRegionHandlerTest { val notification = getOnlyNotification() runInEdtAndWait { - Notification.fire(notification, notification.actions.first { it.templateText == "Never" }) + Notification.fire(notification, notification.actions.first { it.templateText == "Never" }, null) } assertThat(AwsSettings.getInstance().useDefaultCredentialRegion).isEqualTo(UseAwsCredentialRegion.Never) @@ -169,7 +173,7 @@ class CredentialsRegionHandlerTest { val notification = getOnlyNotification() runInEdtAndWait { - Notification.fire(notification, notification.actions.first { it.templateText == "Always" }, TestDataProvider(projectRule.project)) + Notification.fire(notification, notification.actions.first { it.templateText == "Always" }, null) } assertThat(AwsSettings.getInstance().useDefaultCredentialRegion).isEqualTo(UseAwsCredentialRegion.Always) diff --git a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManagerTest.kt b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManagerTest.kt index 60b7338dab..f89611069c 100644 --- a/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManagerTest.kt +++ b/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultAwsConnectionManagerTest.kt @@ -3,29 +3,34 @@ package software.aws.toolkits.jetbrains.core.credentials -import com.intellij.configurationStore.deserializeAndLoadState -import com.intellij.configurationStore.serializeStateInto +import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.application.ApplicationManager import org.assertj.core.api.Assertions.assertThat -import org.jdom.Element -import org.jdom.output.XMLOutputter import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.kotlin.mock 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.MockResourceCacheRule import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager.Companion.selectedPartition +import software.aws.toolkits.jetbrains.core.credentials.profiles.DEFAULT_PROFILE_ID 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.core.region.MockRegionProviderRule +import software.aws.toolkits.jetbrains.core.region.getDefaultRegion +import software.aws.toolkits.jetbrains.services.sts.StsResources +import software.aws.toolkits.jetbrains.utils.deserializeState import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule -import software.aws.toolkits.jetbrains.utils.toElement +import software.aws.toolkits.jetbrains.utils.serializeState import java.nio.file.Files +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean class DefaultAwsConnectionManagerTest { @Rule @@ -38,11 +43,17 @@ class DefaultAwsConnectionManagerTest { @Rule @JvmField - val regionProviderRule = RegionProviderRule() + val mockCredentialManager = MockCredentialManagerRule() + + @Rule + @JvmField + val regionProviderRule = MockRegionProviderRule() + + @JvmField + @Rule + val resourceCache = MockResourceCacheRule() - private lateinit var mockCredentialManager: MockCredentialsManager private lateinit var manager: DefaultAwsConnectionManager - private lateinit var mockResourceCache: MockResourceCache @Before fun setUp() { @@ -51,21 +62,17 @@ class DefaultAwsConnectionManagerTest { System.getProperties().setProperty("aws.sharedCredentialsFile", Files.createTempFile("dummy", null).toAbsolutePath().toString()) System.getProperties().remove("aws.region") environmentVariableHelper.remove("AWS_REGION") - - mockCredentialManager = MockCredentialsManager.getInstance() manager = DefaultAwsConnectionManager(projectRule.project) - mockResourceCache = MockResourceCache.getInstance(projectRule.project) } @After fun tearDown() { mockCredentialManager.reset() - mockResourceCache.clear() } @Test fun `Starts with no active credentials`() { - assertThat(manager.isValidConnectionSettings()).isFalse() + assertThat(manager.isValidConnectionSettings()).isFalse assertThat(manager.recentlyUsedCredentials()).isEmpty() } @@ -84,19 +91,19 @@ class DefaultAwsConnectionManagerTest { @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")) - } + val region = AwsRegion("us-west-2", "Oregon", "AWS") + val credentials = mockCredentialManager.addCredentials(id = "Mock", region = region) + markConnectionSettingsAsValid(credentials, regionProviderRule.defaultRegion()) + regionProviderRule.addRegion(region) - deserializeAndLoadState(manager, element) + deserializeState( + """ + + + """, + manager, + ) manager.waitUntilConnectionStateIsStable() @@ -119,14 +126,14 @@ class DefaultAwsConnectionManagerTest { changeCredentialProvider(credentials) - assertThat(manager.isValidConnectionSettings()).isTrue() + assertThat(manager.isValidConnectionSettings()).isTrue assertThat(manager.connectionSettings()?.credentials?.id).isEqualTo(credentials.id) assertThat(manager.recentlyUsedCredentials()).element(0).isEqualTo(credentials) changeCredentialProvider(credentials2) - assertThat(manager.isValidConnectionSettings()).isTrue() + assertThat(manager.isValidConnectionSettings()).isTrue assertThat(manager.connectionSettings()?.credentials?.id).isEqualTo(credentials2.id) assertThat(manager.recentlyUsedCredentials()).element(0).isEqualTo(credentials2) @@ -135,9 +142,8 @@ class DefaultAwsConnectionManagerTest { @Test 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")) + val mockRegion1 = regionProviderRule.addRegion(AwsRegion("MockRegion-1", "MockRegion-1", "aws")) + val mockRegion2 = regionProviderRule.addRegion(AwsRegion("MockRegion-2", "MockRegion-2", "aws")) assertThat(manager.recentlyUsedRegions()).isEmpty() @@ -157,46 +163,54 @@ class DefaultAwsConnectionManagerTest { fun `Activating a region fires a state change notification`() { val project = projectRule.project - var gotNotification = false + val gotNotification = AtomicBoolean(false) + val latch = CountDownLatch(1) val busConnection = project.messageBus.connect() - busConnection.subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, object : ConnectionSettingsStateChangeNotifier { - override fun settingsStateChanged(newState: ConnectionState) { - gotNotification = true + busConnection.subscribe( + AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, + object : ConnectionSettingsStateChangeNotifier { + override fun settingsStateChanged(newState: ConnectionState) { + gotNotification.set(true) + latch.countDown() + } } - }) + ) changeRegion(AwsRegionProvider.getInstance().defaultRegion()) - assertThat(gotNotification).isTrue() + assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue + assertThat(gotNotification).isTrue } @Test fun `Activating a credential fires a state change notification`() { val project = projectRule.project - var gotNotification = false + val gotNotification = AtomicBoolean(false) + val latch = CountDownLatch(1) val busConnection = project.messageBus.connect() - busConnection.subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, object : ConnectionSettingsStateChangeNotifier { - override fun settingsStateChanged(newState: ConnectionState) { - gotNotification = true + busConnection.subscribe( + AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, + object : ConnectionSettingsStateChangeNotifier { + override fun settingsStateChanged(newState: ConnectionState) { + gotNotification.set(true) + latch.countDown() + } } - }) - - changeCredentialProvider( - mockCredentialManager.addCredentials("Mock") ) - assertThat(gotNotification).isTrue() + changeCredentialProvider(mockCredentialManager.addCredentials("Mock")) + + assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue + assertThat(gotNotification).isTrue } @Test fun `Active region is persisted`() { manager.changeRegion(AwsRegion.GLOBAL) - val element = Element("AccountState") - serializeStateInto(manager, element) - assertThat(element.string()).isEqualToIgnoringWhitespace( + assertThat(serializeState("AccountState", manager)).isEqualToIgnoringWhitespace( """