diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..c93afa7d0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.{kt,kts}] +ktlint_code_style = intellij_idea +ktlint_standard_value-argument-comment = disabled +ktlint_standard_value-parameter-comment = disabled +ktlint_standard_no-multi-spaces = disabled +ktlint_standard_spacing-between-declarations-with-annotations = disabled +ktlint_standard_annotation = disabled diff --git a/.github/readme/compatibility_check.png b/.github/readme/compatibility_check.png new file mode 100644 index 000000000..ea5f5dee7 Binary files /dev/null and b/.github/readme/compatibility_check.png differ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2ba92b3c8..c31f44fa0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,49 +1,66 @@ -# GitHub Actions Workflow created for testing and preparing the plugin release in following steps: -# - validate Gradle Wrapper, -# - run 'test' and 'verifyPlugin' tasks, -# - run Qodana inspections, -# - run 'buildPlugin' task and prepare artifact for the further tests, -# - run 'runPluginVerifier' task, -# - create a draft release. -# -# Workflow is triggered on push and pull_request events. -# +# GitHub Actions workflow for testing and preparing the plugin release. # GitHub Actions reference: https://help.github.com/en/actions -# - name: Coder Gateway Plugin Build + on: - # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g. for dependabot pull requests) push: - branches: [ main ] - # Trigger the workflow on any pull request + branches: + - main + - eap + - compat pull_request: jobs: - # Run Gradle Wrapper Validation Action to verify the wrapper's checksum - # Run verifyPlugin, IntelliJ Plugin Verifier, and test Gradle tasks - # Build plugin and provide the artifact for the next workflow jobs + # Run plugin tests on every supported platform. + test: + strategy: + matrix: + platform: + - ubuntu-latest + - macos-latest + - windows-latest + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v5.0.0 + + - uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: 17 + cache: gradle + + - uses: gradle/wrapper-validation-action@v3.5.0 + + # Run tests + - run: ./gradlew test --info + + # Collect Tests Result of failed tests + - if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: tests-result + path: ${{ github.workspace }}/build/reports/tests + + # Run Gradle Wrapper Validation Action to verify the wrapper's checksum. Run + # verifyPlugin and IntelliJ Plugin Verifier. Build plugin and provide the + # artifact for the next workflow jobs. build: name: Build + needs: test runs-on: ubuntu-latest outputs: version: ${{ steps.properties.outputs.version }} changelog: ${{ steps.properties.outputs.changelog }} steps: - # Check out current repository - name: Fetch Sources - uses: actions/checkout@v3.0.2 - - # Validate wrapper - - name: Gradle Wrapper Validation - uses: gradle/wrapper-validation-action@v1.0.4 + uses: actions/checkout@v5.0.0 # Setup Java 11 environment for the next steps - name: Setup Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 @@ -65,22 +82,12 @@ jobs: echo "::set-output name=name::$NAME" echo "::set-output name=changelog::$CHANGELOG" echo "::set-output name=pluginVerifierHomeDir::~/.pluginVerifier" - ./gradlew listProductsReleases # prepare list of IDEs for Plugin Verifier - # Run tests - - name: Run Tests - run: ./gradlew test - - # Collect Tests Result of failed tests - - name: Collect Tests Result - if: ${{ failure() }} - uses: actions/upload-artifact@v3 - with: - name: tests-result - path: ${{ github.workspace }}/build/reports/tests + # prepare list of IDEs for Plugin Verifier + ./gradlew printProductsReleases # Run plugin build - name: Run Build - run: ./gradlew clean buildPlugin + run: ./gradlew clean buildPlugin --info # until https://github.com/JetBrains/gradle-intellij-plugin/issues/1027 is solved @@ -98,14 +105,14 @@ jobs: # # Collect Plugin Verifier Result # - name: Collect Plugin Verifier Result # if: ${{ always() }} -# uses: actions/upload-artifact@v3 +# uses: actions/upload-artifact@v4 # with: # name: pluginVerifier-result # path: ${{ github.workspace }}/build/reports/pluginVerifier # Run Qodana inspections - name: Qodana - Code Inspection - uses: JetBrains/qodana-action@v2022.1.1 + uses: JetBrains/qodana-action@v2023.3.2 # Prepare plugin archive content for creating artifact - name: Prepare Plugin Artifact @@ -118,7 +125,7 @@ jobs: echo "::set-output name=filename::${FILENAME:0:-4}" # Store already-built plugin as an artifact for downloading - name: Upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ steps.artifact.outputs.filename }} path: ./build/distributions/content/*/* @@ -134,7 +141,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v2.4.0 + uses: actions/checkout@v5.0.0 # Remove old release drafts by using the curl request for the available releases with draft flag - name: Remove Old Release Drafts @@ -151,8 +158,9 @@ jobs: run: | gh release create v${{ needs.build.outputs.version }} \ --draft \ + --target ${GITHUB_REF_NAME} \ --title "v${{ needs.build.outputs.version }}" \ --notes "$(cat << 'EOM' ${{ needs.build.outputs.changelog }} EOM - )" \ No newline at end of file + )" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 743314c38..e67f023e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,13 +15,13 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v2.4.0 + uses: actions/checkout@v5.0.0 with: ref: ${{ github.event.release.tag_name }} # Setup Java 17 environment for the next steps - name: Setup Java - uses: actions/setup-java@v2 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 17 @@ -51,9 +51,14 @@ jobs: run: | ./gradlew patchChangelog --release-note="$CHANGELOG" - # Build the zip distribution - - name: Build Zip Plugin - run: ./gradlew buildPlugin + # Publish the plugin to the Marketplace + - name: Publish Plugin + env: + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} + run: ./gradlew publishPlugin --info # Upload artifact as a release asset - name: Upload Release Asset diff --git a/.gitignore b/.gitignore index 8157f01d4..41dda2b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ ## Gradle .gradle build +jvm/ ## Qodana .qodana diff --git a/CHANGELOG.md b/CHANGELOG.md index 0215f829c..f0c2a25b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,33 +1,722 @@ - - -# coder-gateway Changelog - -## [Unreleased] -### Fixed -- `Recent Coder Workspaces` label overlaps with the search bar in the `Connections` view -- working workspaces are now listed when there are issues with resolving agents -- list only workspaces owned by the logged user - -### Changed -- links to documentation now point to the latest Coder OSS -- simplified main action link text from `Connect to Coder Workspaces` to `Connect to Coder` -- minimum supported Gateway build is now 222.3739.24 - -## [2.0.0] -### Added -- support for Gateway 2022.2 - - -### Changed -- Java 17 is now required to run the plugin -- adapted the code to the new SSH API provided by Gateway - -## [1.0.0] -### Added -- initial scaffold for Gateway plugin -- browser based authentication on Coder environments -- REST client for Coder V2 public API -- coder-cli orchestration for setting up the SSH configurations for Coder Workspaces -- basic panel to display live Coder Workspaces -- support for multi-agent Workspaces -- Gateway SSH connection to a Coder Workspace \ No newline at end of file + + +# coder-gateway Changelog + +## Unreleased + +## 2.23.0 - 2025-10-02 + +### Added + +- support for disabling SSH wildcard config. + +## 2.22.3 - 2025-09-19 + +### Fixed + +- relaxed SNI hostname resolution + +## 2.22.2 - 2025-09-08 + +### Fixed + +- api keys are no longer created each time workspaces are polled + +## 2.22.1 - 2025-07-30 + +### Added + +- support for skipping CLI signature verification + +## 2.22.0 - 2025-07-25 + +### Added + +- support for checking if CLI is signed +- improved progress reporting while downloading the CLI +- URL validation is stricter in the connection screen and URI protocol handler + +## 2.21.1 - 2025-06-26 + +### Fixed + +- marketplace logo + +## 2.21.0 - 2025-06-25 + +### Changed + +- the logos and icons now match the new branding +- the plugin is functionally the same but built with the new plugin system + +## 2.20.1 - 2025-05-20 + +### Changed + +- Retrieve workspace directly in link handler when using wildcardSSH feature + +### Fixed + +- installed EAP, RC, NIGHTLY and PREVIEW IDEs are no longer displayed if there is a higher released version available for download. +- project path is prefilled with the `folder` URI parameter when the IDE&Project dialog opens for URI handling. + +## 2.19.0 - 2025-02-21 + +### Added + +- Added functionality to show setup script error message to the end user. + +### Fixed + +- Fix bug where wildcard configs would not be written under certain conditions. + +## 2.18.1 - 2025-02-14 + +### Changed + +- Update the `pluginUntilBuild` to latest EAP + +## 2.18.0 - 2025-02-04 + +### Changed + +- Simplifies the written SSH config and avoids the need to make an API request for every workspace the filter returns. + +## 2.17.0 - 2025-01-27 + +### Added + +- Added setting "Check for IDE updates" which controls whether the plugin + checks and prompts for available IDE backend updates. + +## 2.16.0 - 2025-01-17 + +### Added + +- Added setting "Default IDE Selection" which will look for a matching IDE + code/version/build number to set as the preselected IDE in the select + component. + +## 2.15.2 - 2025-01-06 + +### Changed + +- When starting a workspace, shell out to the Coder binary instead of making an + API call. This reduces drift between what the plugin does and the CLI does. +- Increase workspace polling to one second on the workspace list view, to pick + up changes made via the CLI faster. The recent connections view remains + unchanged at five seconds. + +## 2.15.1 - 2024-10-04 + +### Added + +- Support an "owner" parameter when launching an IDE from the dashboard. This + makes it possible to reliably connect to the right workspace in the case where + multiple users are using the same workspace name and the workspace filter is + configured to show multiple users' workspaces. This requires an updated + Gateway module that includes the new "owner" parameter. + +## 2.15.0 - 2024-10-04 + +### Added + +- Add the ability to customize the workspace query filter used in the workspaces + table view. For example, you can use this to view workspaces other than your + own by changing the filter or making it blank (useful mainly for admins). + Please note that currently, if many workspaces are being fetched this could + result in long configuration times as the plugin will make queries for each + workspace that is not running to find its agents (running workspaces already + include agents in the initial workspaces query) and add them individually to + the SSH config. In the future, we would like to use a wildcard host name to + work around this issue. + + Additionally, be aware that the recents view is using the same query filter. + This means if you connect to a workspace, then change the filter such that the + workspace is excluded, you could cause the workspace to be deleted from the + recent connections even if the workspace still exists in actuality, as it + would no longer show up in the query which the plugin takes as its cue to + delete the connection. +- Add owner column to connections view table. +- Add agent name to the recent connections view. + +## 2.14.2 - 2024-09-23 + +### Changed + +- Add support for latest 2024.3 EAP. + +## 2.14.1 - 2024-09-13 + +### Fixed + +- When a proxy command argument (such as the URL) contains `?` and `&`, escape + it in the SSH config by using double quotes, as these characters have special + meanings in shells. + +## 2.14.0 - 2024-08-30 + +### Fixed + +- When the `CODER_URL` environment variable is set but you connect to a + different URL in Gateway, force the Coder CLI used in the SSH proxy command to + use the current URL instead of `CODER_URL`. This fixes connection issues such + as "failed to retrieve IDEs". To aply this fix, you must add the connection + again through the "Connect to Coder" flow or by using the dashboard link (the + recent connections do not reconfigure SSH). + +### Changed + +- The "Recents" view has been updated to have a new flow. Before, there were + separate controls for managing the workspace and then you could click a link + to launch a project (clicking a link would also start a stopped workspace + automatically). Now, there are no workspace controls, just links which start + the workspace automatically when needed. The links are enabled when the + workspace is STOPPED, CANCELED, FAILED, STARTING, RUNNING. These states + represent valid times to start a workspace and connect, or to simply connect + to a running one or one that's already starting. We also use a spinner icon + when workspaces are in a transition state (STARTING, CANCELING, DELETING, + STOPPING) to give context for why a link might be disabled or a connection + might take longer than usual to establish. + +## 2.13.1 - 2024-07-19 + +### Changed + +- Previously, the plugin would try to respawn the IDE if we fail to get a join + link after five seconds. However, it seems sometimes we do not get a join link + that quickly. Now the plugin will wait indefinitely for a join link as long as + the process is still alive. If the process never comes alive after 30 seconds + or it dies after coming alive, the plugin will attempt to respawn the IDE. + +### Added + +- Extra logging around the IDE spawn to help debugging. +- Add setting to enable logging connection diagnostics from the Coder CLI for + debugging connectivity issues. + +## 2.13.0 - 2024-07-16 + +### Added + +- When using a recent workspace connection, check if there is an update to the + IDE and prompt to upgrade if an upgrade exists. + +## 2.12.2 - 2024-07-12 + +### Fixed + +- On Windows, expand the home directory when paths use `/` separators (for + example `~/foo/bar` or `$HOME/foo/bar`). This results in something like + `c:\users\coder/foo/bar`, but Windows appears to be fine with the mixed + separators. As before, you can still use `\` separators (for example + `~\foo\bar` or `$HOME\foo\bar`. + +## 2.12.1 - 2024-07-09 + +### Changed + +- Allow connecting when the agent state is "connected" but the lifecycle state + is "created". This may resolve issues when trying to connect to an updated + workspace where the agent has restarted but lifecycle scripts have not been + ran again. + +## 2.12.0 - 2024-07-02 + +### Added + +- Set `--usage-app` on the proxy command if the Coder CLI supports it + (>=2.13.0). To make use of this, you must add the connection again through the + "Connect to Coder" flow or by using the dashboard link (the recents + connections do not reconfigure SSH). + +### Changed + +- Add support for latest Gateway 242.* EAP. + +### Fixed + +- The version column now displays "Up to date" or "Outdated" instead of + duplicating the status column. + +## 2.11.7 - 2024-05-22 + +### Fixed + +- Polling and workspace action buttons when running from File > Remote + Development within a local IDE. + +## 2.11.6 - 2024-05-08 + +### Fixed + +- Multiple clients being launched when a backend was already running. + +## 2.11.5 - 2024-05-06 + +### Added + +- Automatically restart and reconnect to the IDE backend when it disappears. + +## 2.11.4 - 2024-05-01 + +### Fixed + +- All recent connections show their status now, not just the first. + +## 2.11.3 - 2024-04-30 + +### Fixed + +- Default URL setting was showing the help text for the setup command instead of + its own description. +- Exception when there is no default or last used URL. + +## 2.11.2 - 2024-04-30 + +### Fixed + +- Sort IDEs by version (latest first). +- Recent connections window will try to recover after encountering an error. + There is still a known issue where if a token expires there is no way to enter + a new one except to go back through the "Connect to Coder" flow. +- Header command ignores stderr and does not error if nothing is output. It + will still error if any blank lines are output. +- Remove "from jetbrains.com" from the download text since the download source + can be configured. + +### Changed + +- If using a certificate and key, it is assumed that token authentication is not + required, all token prompts are skipped, and the token header is not sent. +- Recent connections to deleted workspaces are automatically deleted. +- Display workspace name instead of the generated host name in the recents + window. +- Add deployment URL, IDE product, and build to the recents window. +- Display status and error in the recents window under the workspace name + instead of hiding them in tooltips. +- Truncate the path in the recents window if it is too long to prevent + needing to scroll to press the workspace actions. +- If there is no default URL, coder.example.com will no longer be used. The + field will just be blank, to remove the need to first delete the example URL. + +### Added + +- New setting for a setup command that will run in the directory of the IDE + before connecting to it. By default if this command fails the plugin will + display the command's exit code and output then abort the connection, but + there is an additional setting to ignore failures. +- New setting for extra SSH options. This is arbitrary text and is not + validated in any way. If this setting is left empty, the environment variable + CODER_SSH_CONFIG_OPTIONS will be used if set. +- New setting for the default URL. If this setting is left empty, the + environment variable CODER_URL will be used. If CODER_URL is also empty, the + URL in the global CLI config directory will be used, if it exists. + +## 2.10.0 - 2024-03-12 + +### Changed + +- If IDE details or the folder are missing from a Gateway link, the plugin will + now show the IDE selection screen to allow filling in these details. + +### Fixed + +- Fix matching on the wrong workspace/agent name. If a Gateway link was failing, + this could be why. +- Make errors when starting/stopping/updating a workspace visible. + +## 2.9.4 - 2024-02-26 + +### Changed + +- Disable autostarting workspaces by default on macOS to prevent an issue where + it wakes periodically and keeps the workspace on. This can be toggled via the + "Disable autostart" setting. +- CLI configuration is now reported in the progress indicator. Before it + happened in the background so it made the "Select IDE and project" button + appear to hang for a short time while it completed. + +### Fixed + +- Prevent environment variables being expanded too early in the header + command. This will make header commands like `auth --url=$CODER_URL` work. +- Stop workspaces before updating them. This is necessary in some cases where + the update changes parameters and the old template needs to be stopped with + the existing parameter values first or where the template author was not + diligent about making sure the agent gets restarted with the new ID and token + when doing two build starts in a row. +- Errors from API requests are now read and reported rather than only reporting + the HTTP status code. +- Data and binary directories are expanded so things like `~` can be used now. + +## 2.9.3 - 2024-02-10 + +### Fixed + +- Plugin will now use proxy authorization settings. + +## 2.9.2 - 2023-12-19 + +### Fixed + +- Listing IDEs when using the plugin from the File > Remote Development option + within a local IDE should now work. +- Recent connections are now preserved. + +## 2.9.1 - 2023-11-06 + +### Fixed + +- Set the `CODER_HEADER_COMMAND` environment variable when executing the CLI with the setting value. + +## 2.9.0 - 2023-10-27 + +### Added + +- Configuration options for mTLS. +- Configuration options for adding a CA cert to the trust store and an alternate + hostname. +- Agent ID can be used in place of the name when using the Gateway link. If + both are present the name will be ignored. + +### Fixed + +- Configuring SSH will include all agents even on workspaces that are off. + +## 2.8.0 - 2023-10-03 + +### Added + +- Add a setting for a command to run to get headers that will be set on all + requests to the Coder deployment. +- Support for Gateway 2023.3. + +## 2.6.0 - 2023-09-06 + +### Added + +- Initial support for Gateway links (jetbrains-gateway://). See the readme for + the expected parameters. +- Support for Gateway 232.9921. + +## 2.5.2 - 2023-08-06 + +### Fixed + +- Inability to connect to a workspace after going back to the workspaces view. +- Remove version warning for 2.x release. + +### Changed + +- Add a message to distinguish between connecting to the worker and querying for + IDEs. + +## 2.5.1 - 2023-07-07 + +### Fixed + +- Inability to download new editors in older versions of Gateway. + +## 2.5.0 - 2023-06-29 + +### Added + +- Support for Gateway 2023.2. + +## 2.4.0 - 2023-06-02 + +### Added + +- Allow configuring the binary directory separately from data. +- Add status and start/stop buttons to the recent connections view. + +### Changed + +- Check binary version with `version --output json` (if available) since this is + faster than waiting for the round trip checking etags. It also covers cases + where the binary is hosted somewhere that does not support etags. +- Move the template link from the row to a dedicated button on the toolbar. + +## 2.3.0 - 2023-05-03 + +### Added + +- Support connecting to multiple deployments (existing connections will still be + using the old method; please re-add them if you connect to multiple + deployments) +- Settings page for configuring both the source and destination of the CLI +- Listing editors and connecting will retry automatically on failure +- Surface various errors in the UI to make them more immediately visible + +### Changed + +- A token dialog and browser will not be launched when automatically connecting + to the last known deployment; these actions will only take place when you + explicitly interact by pressing "connect" +- Token dialog has been widened so the entire token can be seen at once + +### Fixed + +- The help text under the IDE dropdown now takes into account whether the IDE is + already installed +- Various minor alignment issues +- Workspaces table now updates when the agent status changes +- Connecting when the directory contains a tilde +- Selection getting lost when a workspace starts or stops +- Wait for the agent to become fully ready before connecting +- Avoid populating the token dialog with the last known token if it was for a + different deployment + +## 2.2.1 - 2023-03-23 + +### Fixed + +- Reading an existing config would sometimes use the wrong directory on Linux +- Two separate SSH sessions would spawn when connecting to a workspace through + the main flow + +## 2.2.0 - 2023-03-08 + +### Added + +- Support for Gateway 2023 + +### Fixed + +- The "Select IDE and Project" button is no longer disabled for a time after + going back a step + +### Changed + +- Initial authentication is now asynchronous which means no hang on the main + screen while that happens and it shows in the progress bar + +## 2.1.7 - 2023-02-28 + +### Fixed + +- Terminal link is now correct when host ends in `/` +- Improved resiliency and error handling when trying to open the last successful connection + +## 2.1.6-eap.0 - 2023-02-02 + +### Fixed + +- Improved resiliency and error handling when resolving installed IDE's + +## 2.1.6 - 2023-02-01 + +### Fixed + +- Improved resiliency and error handling when resolving installed IDE's + +## 2.1.5-eap.0 - 2023-01-24 + +### Fixed + +- Support for `Remote Development` in the Jetbrains IDE's + +## 2.1.5 - 2023-01-24 + +### Fixed + +- Support for `Remote Development` in the Jetbrains IDE's + +## 2.1.4-eap.0 - 2022-12-23 + +Bug fixes and enhancements included in `2.1.4` release: + +### Added + +- Ability to open a template in the Dashboard +- Ability to sort by workspace name, or by template name or by workspace status +- A new token is requested when the one persisted is expired +- Support for re-using already installed IDE backends + +### Changed + +- Renamed the plugin from `Coder Gateway` to `Gateway` +- Workspaces and agents are now resolved and displayed progressively + +### Fixed + +- Icon rendering on `macOS` +- `darwin` agents are now recognized as `macOS` +- Unsupported OS warning is displayed only for running workspaces + +## 2.1.4 - 2022-12-23 + +### Added + +- Ability to open a template in the Dashboard +- Ability to sort by workspace name, or by template name or by workspace status +- A new token is requested when the one persisted is expired +- Support for re-using already installed IDE backends + +### Changed + +- Renamed the plugin from `Coder Gateway` to `Gateway` +- Workspaces and agents are now resolved and displayed progressively + +### Fixed + +- Icon rendering on `macOS` +- `darwin` agents are now recognized as `macOS` +- Unsupported OS warning is displayed only for running workspaces + +## 2.1.3-eap.0 - 2022-12-12 + +Bug fixes and enhancements included in `2.1.3` release: + +### Added + +- Warning system when plugin might not be compatible with Coder REST API +- A `Create workspace` button which links to Coder's templates page +- Workspace icons +- Quick toolbar action to open Coder Dashboard in the browser +- Custom user agent for the HTTP client + +### Changed + +- Redesigned the information&warning banner. Messages can now include hyperlinks + +### Removed + +- Connection handle window is no longer displayed + +### Fixed + +- Outdated Coder CLI binaries are cleaned up +- Workspace status color style: running workspaces are green, failed ones should be red, everything else is gray +- Typos in plugin description + +## 2.1.3 - 2022-12-09 + +### Added + +- Warning system when plugin might not be compatible with Coder REST API +- A `Create workspace` button which links to Coder's templates page +- Workspace icons +- Quick toolbar action to open Coder Dashboard in the browser +- Custom user agent for the HTTP client + +### Changed + +- Redesigned the information&warning banner. Messages can now include hyperlinks + +### Removed + +- Connection handle window is no longer displayed + +### Fixed + +- Outdated Coder CLI binaries are cleaned up +- Workspace status color style: running workspaces are green, failed ones should be red, everything else is gray +- Typos in plugin description + +## 2.1.2-eap.0 - 2022-11-29 + +### Added + +- Support for Gateway 2022.3 RC +- Upgraded support for the latest Coder REST API +- Support for latest Gateway 2022.2.x builds + +### Fixed + +- Authentication flow is now done using HTTP headers + +## 2.1.2 - 2022-11-23 + +### Added + +- Upgraded support for the latest Coder REST API +- Support for latest Gateway 2022.2.x builds + +### Fixed + +- Authentication flow is now done using HTTP headers + +## 2.1.1 + +### Added + +- Support for remembering last opened Coder session + +### Changed + +- Minimum supported Gateway build is now 222.3739.54 +- Some dialog titles + +## 2.1.0 + +### Added + +- Support for displaying workspace version +- Support for managing the lifecycle of a workspace, i.e. start and stop and update workspace to the latest template version + +### Changed + +- Workspace panel is now updated every 5 seconds +- Combinations of workspace names and agent names are now listed even when a workspace is down +- Minimum supported Gateway build is now 222.3739.40 + +### Fixed + +- Terminal link for workspaces with a single agent +- No longer allow users to open a connection to a Windows or macOS workspace. It's not yet supported by Gateway + +## 2.0.2 + +### Added + +- Support for displaying working and non-working workspaces +- Better support for Light and Dark themes in the "Status" column + +### Fixed + +- Left panel is no longer visible when a new connection is triggered from Coder's "Recent Workspaces" panel. + This provides consistency with other plugins compatible with Gateway +- The "Select IDE and Project" button in the "Coder Workspaces" view is now disabled when no workspace is selected + +### Changed + +- The authentication view is now merged with the "Coder Workspaces" view allowing users to quickly change the host + +## 2.0.1 + +### Fixed + +- `Recent Coder Workspaces` label overlaps with the search bar in the `Connections` view +- Working workspaces are now listed when there are issues with resolving agents +- List only workspaces owned by the logged user + +### Changed + +- Links to documentation now point to the latest Coder OSS +- Simplified main action link text from `Connect to Coder Workspaces` to `Connect to Coder` +- Minimum supported Gateway build is now 222.3739.24 + +## 2.0.0 + +### Added + +- Support for Gateway 2022.2 + +### Changed + +- Java 17 is now required to run the plugin +- Adapted the code to the new SSH API provided by Gateway + +## 1.0.0 + +### Added + +- Initial scaffold for Gateway plugin +- Browser based authentication on Coder environments +- REST client for Coder V2 public API +- coder-cli orchestration for setting up the SSH configurations for Coder Workspaces +- Basic panel to display live Coder Workspaces +- Support for multi-agent Workspaces +- Gateway SSH connection to a Coder Workspace diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..d88e8d1e1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,152 @@ +# Contributing + +## Architecture + +The Coder Gateway plugin uses Gateway APIs to SSH into the remote machine, +download the requested IDE backend, run the backend, then launches a client that +connects to that backend using a port forward over SSH. If the backend goes down +due to a crash or a workspace restart, it will restart the backend and relaunch +the client. + +There are three ways to get into a workspace: + +1. Dashboard link. +2. "Connect to Coder" button. +3. Using a recent connection. + +Currently the first two will configure SSH but the third does not yet. + +## GPG Signature Verification + +The Coder Gateway plugin starting with version *2.22.0* implements a comprehensive GPG signature verification system to +ensure the authenticity and integrity of downloaded Coder CLI binaries. This security feature helps protect users from +running potentially malicious or tampered binaries. + +### How It Works + +1. **Binary Download**: When connecting to a Coder deployment, the plugin downloads the appropriate Coder CLI binary for + the user's operating system and architecture from the deployment's `/bin/` endpoint. + +2. **Signature Download**: After downloading the binary, the plugin attempts to download the corresponding `.asc` + signature file from the same location. The signature file is named according to the binary (e.g., + `coder-linux-amd64.asc` for `coder-linux-amd64`). + +3. **Fallback Signature Sources**: If the signature is not available from the deployment, the plugin can optionally fall + back to downloading signatures from `releases.coder.com`. This is controlled by the `fallbackOnCoderForSignatures` + setting. + +4. **GPG Verification**: The plugin uses the BouncyCastle library shipped with Gateway app to verify the detached GPG + signature against the downloaded binary using Coder's trusted public key. + +5. **User Interaction**: If signature verification fails or signatures are unavailable, the plugin presents security + warnings + to users, allowing them to accept the risk and continue or abort the operation. + +### Verification Process + +The verification process involves several components: + +- **`GPGVerifier`**: Handles the core GPG signature verification logic using BouncyCastle +- **`VerificationResult`**: Represents the outcome of verification (Valid, Invalid, Failed, SignatureNotFound) +- **`CoderDownloadService`**: Manages downloading both binaries and their signatures +- **`CoderCLIManager`**: Orchestrates the download and verification workflow + +### Configuration Options + +Users can control signature verification behavior through plugin settings: + +- **`disableSignatureVerification`**: When enabled, skips all signature verification. This is useful for clients running + custom CLI builds, or + customers with old deployment versions that don't have a signature published on `releases.coder.com`. +- **`fallbackOnCoderForSignatures`**: When enabled, allows downloading signatures from `releases.coder.com` if not + available from the deployment + +### Security Considerations + +- The plugin embeds Coder's trusted public key in the plugin resources +- Verification uses detached signatures, which are more secure than attached signatures +- Users are warned about security risks when verification fails +- The system gracefully handles cases where signatures are unavailable +- All verification failures are logged for debugging purposes + +### Error Handling + +The system handles various failure scenarios: + +- **Missing signatures**: Prompts user to accept risk or abort +- **Invalid signatures**: Warns user about potential tampering and prompts user to accept risk or abort +- **Verification failures**: Prompts user to accept risk or abort + +This signature verification system ensures that users can trust the Coder CLI binaries they download through the plugin, +protecting against supply chain attacks and ensuring binary integrity. + +## Development + +To manually install a local build: + +1. Install [Jetbrains Gateway](https://www.jetbrains.com/remote-development/gateway/) +2. Run `./gradlew clean buildPlugin` to generate a zip distribution. +3. Locate the zip file in the `build/distributions` folder and follow [these + instructions](https://www.jetbrains.com/help/idea/managing-plugins.html#install_plugin_from_disk) + on how to install a plugin from disk. + +Alternatively, `./gradlew clean runIde` will deploy a Gateway distribution (the +one specified in `gradle.properties` - `platformVersion`) with the latest plugin +changes deployed. + +To simulate opening a workspace from the dashboard pass the Gateway link via +`--args`. For example: + +``` +./gradlew clean runIDE --args="jetbrains-gateway://connect#type=coder&workspace=dev&agent=coder&folder=/home/coder&url=https://dev.coder.com&token=&ide_product_code=IU&ide_build_number=223.8836.41&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2022.3.3.tar.gz" +``` + +Alternatively, if you have separately built the plugin and already installed it +in a Gateway distribution you can launch that distribution with the URL as the +first argument (no `--args` in this case). + +If your change is something users ought to be aware of, add an entry in the +changelog. + +Generally we prefer that PRs be squashed into `main` but you can rebase or merge +if it is important to keep the individual commits (make sure to clean up the +commits first if you are doing this). + +## Testing + +Run tests with `./gradlew test`. By default this will test against +`https://dev.coder.com` but you can set `CODER_GATEWAY_TEST_DEPLOYMENT` to a URL +of your choice or to `mock` to use mocks only. + +There are two ways of using the plugin: from standalone Gateway, and from within +an IDE (`File` > `Remote Development`). There are subtle differences so it +makes usually sense to test both. We should also be testing both the latest +stable and latest EAP. + +## Plugin compatibility + +`./gradlew runPluginVerifier` can check the plugin compatibility against the specified Gateway. The integration with Github Actions is commented until [this gradle intellij plugin issue](https://github.com/JetBrains/gradle-intellij-plugin/issues/1027) is fixed. + +## Releasing + +1. Check that the changelog lists all the important changes. +2. Update the gradle.properties version. +3. Publish the resulting draft release after validating it. +4. Merge the resulting changelog PR. + +## `main` vs `eap` branch + +Sometimes there can be API incompatibilities between the latest stable version +of Gateway and EAP ones (Early Access Program). + +If this happens, use the `eap` branch to make a separate release. Once it +becomes stable, update the versions in `main`. + +## Supported Coder versions + +`Coder Gateway` includes checks for compatibility with a specified version +range. A warning is raised when the Coder deployment build version is outside of +compatibility range. + +At the moment the upper range is 3.0.0 so the check essentially has no effect, +but in the future we may want to keep this updated. diff --git a/LICENSE b/LICENSE index 29ebfa545..0270469f3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,20 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. \ No newline at end of file +The MIT License + +Copyright (c) 2019 Coder Technologies Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index dd0582bb2..48d5f8a36 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,41 @@ -# Coder Gateway Plugin +# Coder Gateway Plugin [!["Join us on Discord"](https://img.shields.io/badge/join-us%20on%20Discord-gray.svg?longCache=true&logo=discord&colorB=purple)](https://discord.gg/coder) [![Twitter Follow](https://img.shields.io/twitter/follow/CoderHQ?label=%40CoderHQ&style=social)](https://twitter.com/coderhq) -[![Coder Gateway Plugin Build](https://github.com/coder/coder-jetbrains/actions/workflows/build.yml/badge.svg)](https://github.com/coder/coder-jetbrains/actions/workflows/build.yml) +[![Coder Gateway Plugin Build](https://github.com/coder/jetbrains-coder/actions/workflows/build.yml/badge.svg)](https://github.com/coder/jetbrains-coder/actions/workflows/build.yml) -**Coder Gateway** connects your Jetbrains IDE to your [Coder Workspaces](https://coder.com/docs/coder-oss/latest/workspaces) so that you can develop from anywhere. +The Coder Gateway plugin lets you open [Coder](https://github.com/coder/coder) +workspaces in your JetBrains IDEs with a single click. + +> [!NOTE] +> We recommend using the [Coder Toolbox plugin](https://github.com/coder/coder-jetbrains-toolbox), which offers significant stability and connectivity benefits over Gateway. Future updates of the Coder plugin on Jetbrains will be made to the Toolbox plugin. Reference our [documentation](https://coder.com/docs/user-guides/workspace-access/jetbrains/toolbox) for more information. + **Manage less** - Ensure your entire team is using the same tools and resources + - Rollout critical updates to your developers with one command +- Automatically shut down expensive cloud resources - Keep your source code and data behind your firewall **Code more** - Build and test faster - - Leveraging cloud CPUs, RAM, network speeds, etc. -- Access your environment from any place + - Leveraging cloud CPUs, RAM, network speeds, etc. +- Access your environment from any place on any client (even an iPad) - Onboard instantly then stay up to date continuously ## Getting Started -To manually install a local build: - -1. Install [Jetbrains Gateway](https://www.jetbrains.com/help/phpstorm/remote-development-a.html#gateway) -2. run `./gradlew clean buildPlugin` to generate a zip distribution -3. locate the zip file in the `build/distributions` folder and follow [these instructions](https://www.jetbrains.com/help/idea/managing-plugins.html#install_plugin_from_disk) on how to install a plugin from disk. - -Alternatively, `./gradlew clean runIde` will deploy a Gateway distribution (the one specified in `gradle.properties` - `platformVersion`) with the latest plugin changes deployed. - -### Plugin Structure - -``` -├── .github/ GitHub Actions workflows and Dependabot configuration files -├── gradle -│ └── wrapper/ Gradle Wrapper -├── build/ Output build directory -├── src Plugin sources -│ └── main -│ ├── kotlin/ Kotlin production sources -│ └── resources/ Resources - plugin.xml, icons, i8n -│ └── test -│ ├── kotlin/ Kotlin test sources -├── .gitignore Git ignoring rules -├── build.gradle.kts Gradle configuration -├── CHANGELOG.md Full change history -├── gradle.properties Gradle configuration properties -├── gradlew *nix Gradle Wrapper script -├── gradlew.bat Windows Gradle Wrapper script -├── qodana.yml Qodana profile configuration file -├── README.md README -└── settings.gradle.kts Gradle project settings -``` - -`src` directory is the most important part of the project, the Coder Gateway implementation and the manifest for the plugin – [`plugin.xml`](src/main/resources/META-INF/plugin.xml). - -### Gradle Configuration Properties - -The project-specific configuration file [`gradle.properties`](gradle.properties) contains: - -| Property name | Description | -| --------------------------- |---------------------------------------------------------------------------------------------------------------| -| `pluginGroup` | Package name, set to `com.coder.gateway`. | -| `pluginName` | Zip filename. | -| `pluginVersion` | The current version of the plugin in [SemVer](https://semver.org/) format. | -| `pluginSinceBuild` | The `since-build` attribute of the `` tag. The minimum Gateway build supported by the plugin | -| `pluginUntilBuild` | The `until-build` attribute of the `` tag. Supported Gateway builds, until & not inclusive | -| `platformType` | The type of IDE distribution, in this GW. | -| `platformVersion` | The version of the Gateway used to build&run the plugin. | -| `platformDownloadSources` | Gateway sources downloaded while initializing the Gradle build. Note: Gateway does not have open sources | -| `platformPlugins` | Comma-separated list of dependencies to the bundled Gateway plugins and plugins from the Plugin Repositories. | -| `javaVersion` | Java language level used to compile sources and generate the files for - Java 11 is required since 2020.3. | -| `gradleVersion` | Version of Gradle used for plugin development. | - -The properties listed define the plugin itself or configure the [gradle-intellij-plugin](https://github.com/JetBrains/gradle-intellij-plugin) – check its documentation for more details. - -### Testing - -No functional or UI tests are available yet. - -### Code Monitoring - -Code quality is monitored with the help of [Qodana](https://www.jetbrains.com/qodana/) - -Qodana inspections are accessible within the project on two levels: - -- using the [Qodana IntelliJ GitHub Action][docs:qodana-github-action], run automatically within the [Build](.github/workflows/build.yml) workflow, -- with the [Gradle Qodana Plugin](https://github.com/JetBrains/gradle-qodana-plugin), so you can use it on the local environment or any CI other than GitHub Actions. - -Qodana inspection is configured with the `qodana { ... }` section in the [Gradle build file](build.gradle.kts) and [`qodana.yml`](qodana.yml) YAML configuration file. - -> **NOTE:** Qodana requires Docker to be installed and available in your environment. - -To run inspections, you can use a predefined *Run Qodana* configuration, which will provide a full report on `http://localhost:8080`, or invoke the Gradle task directly with the `./gradlew runInspections` command. - -A final report is available in the `./build/reports/inspections/` directory. - -![Qodana](.github/readme/qodana.png) - -### Plugin compatibility - -`./gradlew runPluginVerifier` can check the plugin compatibility against the specified Gateway. The integration with Github Actions is commented until [this gradle intellij plugin issue](https://github.com/JetBrains/gradle-intellij-plugin/issues/1027) is fixed. - -## Continuous integration - -In the `.github/workflows` directory, you can find definitions for the following GitHub Actions workflows: +1. Install [Jetbrains Gateway](https://www.jetbrains.com/remote-development/gateway/) +2. [Install this plugin from the JetBrains Marketplace](https://plugins.jetbrains.com/plugin/19620-coder/). + Alternatively, if you launch a JetBrains IDE from the Coder dashboard, this + plugin will be automatically installed. -- [Build](.github/workflows/build.yml) - - Triggered on `push` and `pull_request` events. - - Runs the *Gradle Wrapper Validation Action* to verify the wrapper's checksum. - - Runs the `verifyPlugin` and `test` Gradle tasks. - - Builds the plugin with the `buildPlugin` Gradle task and provides the artifact for the next jobs in the workflow. - - ~~Verifies the plugin using the *IntelliJ Plugin Verifier* tool.~~ (this is commented until [this issue](https://github.com/JetBrains/gradle-intellij-plugin/issues/1027) is fixed) - - Prepares a draft release of the GitHub Releases page for manual verification. -- [Release](.github/workflows/release.yml) - - Triggered on `Publish release` event. - - Updates `CHANGELOG.md` file with the content provided with the release note. - - Pat \ No newline at end of file +It is also possible to install this plugin in a local JetBrains IDE and then use +`File` > `Remote Development`. diff --git a/build.gradle.kts b/build.gradle.kts index ea36a6c09..a126d0010 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,54 +1,138 @@ +import org.jetbrains.changelog.Changelog import org.jetbrains.changelog.markdownToHTML -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType fun properties(key: String) = project.findProperty(key).toString() plugins { // Java support id("java") + // Groovy support + id("groovy") // Kotlin support - id("org.jetbrains.kotlin.jvm") version "1.7.10" - // Gradle IntelliJ Plugin - id("org.jetbrains.intellij") version "1.7.0" + id("org.jetbrains.kotlin.jvm") version "1.9.23" + // Gradle IntelliJ Platform Plugin + id("org.jetbrains.intellij.platform") version "2.6.0" // Gradle Changelog Plugin - id("org.jetbrains.changelog") version "1.3.1" + id("org.jetbrains.changelog") version "2.2.1" // Gradle Qodana Plugin id("org.jetbrains.qodana") version "0.1.13" + // Gradle Kover Plugin + id("org.jetbrains.kotlinx.kover") version "0.9.1" + // Generate Moshi adapters. + id("com.google.devtools.ksp") version "1.9.23-1.0.20" } -group = properties("pluginGroup") -version = properties("pluginVersion") +group = providers.gradleProperty("pluginGroup").get() +version = providers.gradleProperty("pluginVersion").get() -val ktorVersion = properties("ktorVersion") -dependencies { - implementation("com.squareup.retrofit2:retrofit:2.9.0") - // define a BOM and its version - implementation(platform("com.squareup.okhttp3:okhttp-bom:4.9.3")) - implementation("com.squareup.retrofit2:converter-gson:2.9.0") - implementation("com.squareup.okhttp3:okhttp") - implementation("com.squareup.okhttp3:okhttp-urlconnection") - implementation("com.squareup.okhttp3:logging-interceptor") - - implementation("org.zeroturnaround:zt-exec:1.12") { - exclude("org.slf4j") - } +// Set the JVM language level used to build the project. +kotlin { + jvmToolchain(17) } // Configure project's dependencies repositories { mavenCentral() - maven(url = "https://www.jetbrains.com/intellij-repository/releases") + // IntelliJ Platform Gradle Plugin Repositories Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-repositories-extension.html + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + implementation(platform("com.squareup.okhttp3:okhttp-bom:4.12.0")) + implementation("com.squareup.okhttp3:okhttp") + implementation("com.squareup.okhttp3:logging-interceptor") + + implementation("com.squareup.moshi:moshi:1.15.1") + ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.1") + + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-moshi:2.11.0") + + implementation("org.zeroturnaround:zt-exec:1.12") + + testImplementation(kotlin("test")) + // required by the unit tests + testImplementation(kotlin("test-junit5")) + testImplementation("io.mockk:mockk:1.13.12") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + // required by IntelliJ test framework + testImplementation("junit:junit:4.13.2") + + + // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html + intellijPlatform { + gateway(providers.gradleProperty("platformVersion")) + + // Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins. + bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) + + // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file for plugin from JetBrains Marketplace. + plugins(providers.gradleProperty("platformPlugins").map { it.split(',') }) + + pluginVerifier() + } } // Configure Gradle IntelliJ Plugin - read more: https://github.com/JetBrains/gradle-intellij-plugin -intellij { - pluginName.set(properties("pluginName")) - version.set(properties("platformVersion")) - type.set(properties("platformType")) - - downloadSources.set(properties("platformDownloadSources").toBoolean()) - // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file. - plugins.set(properties("platformPlugins").split(',').map(String::trim).filter(String::isNotEmpty)) +intellijPlatform { + buildSearchableOptions = false + instrumentCode = true + + pluginConfiguration { + name = providers.gradleProperty("pluginName") + version = providers.gradleProperty("pluginVersion") + + // Extract the section from README.md and provide for the plugin's manifest + description = providers.fileContents(layout.projectDirectory.file("README.md")).asText.map { + val start = "" + val end = "" + + with(it.lines()) { + if (!containsAll(listOf(start, end))) { + throw GradleException("Plugin description section not found in README.md:\n$start ... $end") + } + subList(indexOf(start) + 1, indexOf(end)).joinToString("\n").let(::markdownToHTML) + } + } + + val changelog = project.changelog // local variable for configuration cache compatibility + // Get the latest available change notes from the changelog file + changeNotes = providers.gradleProperty("pluginVersion").map { pluginVersion -> + with(changelog) { + renderItem( + (getOrNull(pluginVersion) ?: getUnreleased()) + .withHeader(false) + .withEmptySections(false), + Changelog.OutputType.HTML, + ) + } + } + + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + untilBuild = provider { null } + } + } + + pluginVerification { + ides { + providers.gradleProperty("verifyVersions").get().split(',').map(String::trim).forEach { version -> + ide(IntelliJPlatformType.Gateway, version) + } + } + } + + publishing { + token = providers.environmentVariable("PUBLISH_TOKEN") + // The pluginVersion is based on the SemVer (https://semver.org) and supports pre-release labels, like 2.1.7-alpha.3 + // Specify pre-release label to publish the plugin in a custom Release Channel automatically. Read more: + // https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel + channels = providers.gradleProperty("pluginVersion") + .map { listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) } + } } // Configure Gradle Changelog Plugin - read more: https://github.com/JetBrains/gradle-changelog-plugin @@ -57,6 +141,17 @@ changelog { groups.set(emptyList()) } +// Configure Gradle Kover Plugin - read more: https://github.com/Kotlin/kotlinx-kover#configuration +kover { + reports { + total { + xml { + onCheck = true + } + } + } +} + // Configure Gradle Qodana Plugin - read more: https://github.com/JetBrains/gradle-qodana-plugin qodana { cachePath.set(projectDir.resolve(".qodana").canonicalPath) @@ -67,68 +162,43 @@ qodana { tasks { buildPlugin { + archiveBaseName = providers.gradleProperty("artifactName").get() exclude { "coroutines" in it.name } } prepareSandbox { exclude { "coroutines" in it.name } } - // Set the JVM compatibility versions - properties("javaVersion").let { - withType { - sourceCompatibility = it - targetCompatibility = it - } - withType { - kotlinOptions.jvmTarget = it - } - } - wrapper { - gradleVersion = properties("gradleVersion") + gradleVersion = providers.gradleProperty("gradleVersion").get() } - instrumentCode { - compilerVersion.set(properties("instrumentationCompiler")) + publishPlugin { + dependsOn(patchChangelog) } - // TODO - this fails with linkage error, remove when it works - buildSearchableOptions { - isEnabled = false + test { + useJUnitPlatform() } +} - patchPluginXml { - version.set(properties("pluginVersion")) - sinceBuild.set(properties("pluginSinceBuild")) - untilBuild.set(properties("pluginUntilBuild")) - - // Extract the section from README.md and provide for the plugin's manifest - pluginDescription.set( - projectDir.resolve("README.md").readText().lines().run { - val start = "" - val end = "" - - if (!containsAll(listOf(start, end))) { - throw GradleException("Plugin description section not found in README.md:\n$start ... $end") +intellijPlatformTesting { + runIde { + register("runIdeForUiTests") { + task { + jvmArgumentProviders += CommandLineArgumentProvider { + listOf( + "-Drobot-server.port=8082", + "-Dide.mac.message.dialogs.as.sheets=false", + "-Djb.privacy.policy.text=", + "-Djb.consents.confirmation.enabled=false", + ) } - subList(indexOf(start) + 1, indexOf(end)) - }.joinToString("\n").run { markdownToHTML(this) } - ) + } - // Get the latest available change notes from the changelog file - changeNotes.set(provider { - changelog.run { - getOrNull(properties("pluginVersion")) ?: getLatest() - }.toHTML() - }) - } - - // Configure UI tests plugin - // Read more: https://github.com/JetBrains/intellij-ui-test-robot - runIdeForUiTests { - systemProperty("robot-server.port", "8082") - systemProperty("ide.mac.message.dialogs.as.sheets", "false") - systemProperty("jb.privacy.policy.text", "") - systemProperty("jb.consents.confirmation.enabled", "false") + plugins { + robotServerPlugin() + } + } } -} +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 91f67785c..26ae7b236 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,27 +1,45 @@ # IntelliJ Platform Artifacts Repositories # -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html pluginGroup=com.coder.gateway -pluginName=coder-gateway +# Zip file name. +artifactName=coder-gateway +pluginName=Coder # SemVer format -> https://semver.org -pluginVersion=2.0.1 +pluginVersion=2.23.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. -pluginSinceBuild=222.3739.24 -pluginUntilBuild=222.* +pluginSinceBuild=243.26574 # IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties # Gateway available build versions https://www.jetbrains.com/intellij-repository/snapshots and https://www.jetbrains.com/intellij-repository/releases -platformType=GW -platformVersion=222.3739.24-CUSTOM-SNAPSHOT -instrumentationCompiler=222.3739.24-CUSTOM-SNAPSHOT +# +# The platform version must match the "since build" version while the +# instrumentation version appears to be used in development. The plugin +# verifier should be used after bumping versions to ensure compatibility in the +# range. +# +# Occasionally the build of Gateway we are using disappears from JetBrains’s +# servers. When this happens, find the closest version match from +# https://www.jetbrains.com/intellij-repository/snapshots and update accordingly +# (for example if 233.14808-EAP-CANDIDATE-SNAPSHOT is missing then find a 233.* +# that exists, ideally the most recent one, for example +# 233.15325-EAP-CANDIDATE-SNAPSHOT). +platformVersion=2024.3.6 +# Gateway does not have open sources. platformDownloadSources=true +# available releases listed at: https://data.services.jetbrains.com/products?code=GW +verifyVersions=2024.3.6,2025.1,2025.2 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 platformPlugins= -# Java language level used to compile sources and to generate the files for - Java 17 is required since 2022.2 -javaVersion=17 +# Example: platformBundledPlugins = com.intellij.java +platformBundledPlugins = # Gradle Releases -> https://github.com/gradle/gradle/releases -gradleVersion=7.4 +gradleVersion=8.5 # Opt-out flag for bundling Kotlin standard library. # See https://plugins.jetbrains.com/docs/intellij/kotlin.html#kotlin-standard-library for details. # suppress inspection "UnusedProperty" -kotlin.stdlib.default.dependency=false \ No newline at end of file +kotlin.stdlib.default.dependency=true +# Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html +org.gradle.configuration-cache = true +# Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html +org.gradle.caching = true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 41d9927a4..d64cd4917 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 41dfb8790..1af9e0930 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c78733..1aa94a426 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# 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/. @@ -80,13 +80,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# 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"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +131,29 @@ location of your Java installation." fi else 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. + if ! command -v java >/dev/null 2>&1 + then + 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 location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. 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=SC2039,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=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,11 +198,15 @@ if "$cygwin" || "$msys" ; then done fi -# 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. + +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ @@ -205,6 +214,12 @@ set -- \ 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. diff --git a/gradlew.bat b/gradlew.bat index 107acd32c..93e3f59f1 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,7 +25,8 @@ 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% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :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/src/main/kotlin/com/coder/gateway/CoderGatewayBundle.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayBundle.kt index 05d5c0ec5..d680f8624 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayBundle.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayBundle.kt @@ -8,8 +8,10 @@ import org.jetbrains.annotations.PropertyKey private const val BUNDLE = "messages.CoderGatewayBundle" object CoderGatewayBundle : DynamicBundle(BUNDLE) { - @Suppress("SpreadOperator") @JvmStatic - fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any) = getMessage(key, *params) -} \ No newline at end of file + fun message( + @PropertyKey(resourceBundle = BUNDLE) key: String, + vararg params: Any, + ) = getMessage(key, *params) +} diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt index e963a3fd1..b421fc7a2 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConnectionProvider.kt @@ -2,120 +2,37 @@ package com.coder.gateway -import com.coder.gateway.models.RecentWorkspaceConnection -import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService +import com.coder.gateway.services.CoderSettingsService +import com.coder.gateway.util.DialogUi +import com.coder.gateway.util.LinkHandler +import com.coder.gateway.util.isCoder import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.rd.util.launchUnderBackgroundProgress -import com.intellij.remote.AuthType -import com.intellij.remote.RemoteCredentialsHolder -import com.intellij.ssh.config.unified.SshConfig -import com.intellij.ssh.config.unified.SshConfigManager import com.jetbrains.gateway.api.ConnectionRequestor import com.jetbrains.gateway.api.GatewayConnectionHandle import com.jetbrains.gateway.api.GatewayConnectionProvider -import com.jetbrains.gateway.ssh.HighLevelHostAccessor -import com.jetbrains.gateway.ssh.HostDeployInputs -import com.jetbrains.gateway.ssh.IdeInfo -import com.jetbrains.gateway.ssh.IntelliJPlatformProduct -import com.jetbrains.gateway.ssh.SshDeployFlowUtil -import com.jetbrains.gateway.ssh.SshMultistagePanelContext -import com.jetbrains.gateway.ssh.deploy.DeployTargetInfo.DeployWithDownload -import com.jetbrains.rd.util.lifetime.LifetimeDefinition -import kotlinx.coroutines.launch -import java.net.URI -import java.time.Duration -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -class CoderGatewayConnectionProvider : GatewayConnectionProvider { - private val recentConnectionsService = service() - private val sshConfigService = service() - - private val connections = mutableSetOf() - private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm") - - override suspend fun connect(parameters: Map, requestor: ConnectionRequestor): GatewayConnectionHandle? { - val coderWorkspaceHostname = parameters["coder_workspace_hostname"] - val projectPath = parameters["project_path"] - val ideProductCode = parameters["ide_product_code"]!! - val ideBuildNumber = parameters["ide_build_number"]!! - val ideDownloadLink = parameters["ide_download_link"]!! - val webTerminalLink = parameters["web_terminal_link"]!! - - if (coderWorkspaceHostname != null && projectPath != null) { - val connection = CoderConnectionMetadata(coderWorkspaceHostname) - if (connection in connections) { - logger.warn("There is already a connection started on ${connection.workspaceHostname}") - return null - } - val sshConfiguration = SshConfig(true).apply { - setHost(coderWorkspaceHostname) - setUsername("coder") - port = 22 - authType = AuthType.OPEN_SSH - } - - val clientLifetime = LifetimeDefinition() - clientLifetime.launchUnderBackgroundProgress("Coder Gateway Deploy", canBeCancelled = true, isIndeterminate = true, project = null) { - val context = SshMultistagePanelContext( - HostDeployInputs.FullySpecified( - remoteProjectPath = projectPath, - deployTarget = DeployWithDownload( - URI(ideDownloadLink), - null, - IdeInfo( - product = IntelliJPlatformProduct.fromProductCode(ideProductCode)!!, - buildNumber = ideBuildNumber - ) - ), - remoteInfo = HostDeployInputs.WithDeployedWorker( - HighLevelHostAccessor.create( - RemoteCredentialsHolder().apply { - setHost(coderWorkspaceHostname) - userName = "coder" - port = 22 - authType = AuthType.OPEN_SSH - }, - true - ), - HostDeployInputs.WithHostInfo(sshConfiguration) - ) - ) - ) - launch { - @Suppress("UnstableApiUsage") SshDeployFlowUtil.fullDeployCycle( - clientLifetime, context, Duration.ofMinutes(10) - ) - } - } - - recentConnectionsService.addRecentConnection( - RecentWorkspaceConnection( - coderWorkspaceHostname, projectPath, localTimeFormatter.format(LocalDateTime.now()), ideProductCode, ideBuildNumber, ideDownloadLink, webTerminalLink - ) - ) - - return object : GatewayConnectionHandle(clientLifetime) { - override fun getTitle(): String { - return "Connection to Coder Workspaces" - } - - override fun hideToTrayOnStart(): Boolean { - return false - } +// CoderGatewayConnectionProvider handles connecting via a Gateway link such as +// jetbrains-gateway://connect#type=coder. +class CoderGatewayConnectionProvider : + LinkHandler(service(), null, DialogUi(service())), + GatewayConnectionProvider { + override suspend fun connect( + parameters: Map, + requestor: ConnectionRequestor, + ): GatewayConnectionHandle? { + CoderRemoteConnectionHandle().connect { indicator -> + logger.debug("Launched Coder link handler", parameters) + handle(parameters) { + indicator.text = it } } return null } - override fun isApplicable(parameters: Map): Boolean { - return parameters["type"] == "coder" - } + override fun isApplicable(parameters: Map): Boolean = parameters.isCoder() companion object { val logger = Logger.getInstance(CoderGatewayConnectionProvider::class.java.simpleName) } } - -internal data class CoderConnectionMetadata(val workspaceHostname: String) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt index 2b4f8bdf6..1defb91d8 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayConstants.kt @@ -3,4 +3,5 @@ package com.coder.gateway object CoderGatewayConstants { const val GATEWAY_CONNECTOR_ID = "Coder.Gateway.Connector" const val GATEWAY_RECENT_CONNECTIONS_ID = "Coder.Gateway.Recent.Connections" -} \ No newline at end of file + const val GATEWAY_SETUP_COMMAND_ERROR = "CODER_SETUP_ERROR" +} diff --git a/src/main/kotlin/com/coder/gateway/CoderGatewayMainView.kt b/src/main/kotlin/com/coder/gateway/CoderGatewayMainView.kt index 9c4c79853..e72968891 100644 --- a/src/main/kotlin/com/coder/gateway/CoderGatewayMainView.kt +++ b/src/main/kotlin/com/coder/gateway/CoderGatewayMainView.kt @@ -1,17 +1,17 @@ package com.coder.gateway +import com.coder.gateway.help.ABOUT_HELP_TOPIC import com.coder.gateway.icons.CoderIcons import com.coder.gateway.views.CoderGatewayConnectorWizardWrapperView import com.coder.gateway.views.CoderGatewayRecentWorkspaceConnectionsView -import com.intellij.ui.components.ActionLink -import com.intellij.ui.components.BrowserLink +import com.intellij.openapi.help.HelpManager import com.jetbrains.gateway.api.GatewayConnector +import com.jetbrains.gateway.api.GatewayConnectorDocumentation import com.jetbrains.gateway.api.GatewayConnectorView import com.jetbrains.gateway.api.GatewayRecentConnections import com.jetbrains.rd.util.lifetime.Lifetime import java.awt.Component import javax.swing.Icon -import javax.swing.JComponent class CoderGatewayMainView : GatewayConnector { override fun getConnectorId() = CoderGatewayConstants.GATEWAY_CONNECTOR_ID @@ -19,35 +19,19 @@ class CoderGatewayMainView : GatewayConnector { override val icon: Icon get() = CoderIcons.LOGO - override fun createView(lifetime: Lifetime): GatewayConnectorView { - return CoderGatewayConnectorWizardWrapperView() - } - - override fun getActionText(): String { - return CoderGatewayBundle.message("gateway.connector.action.text") - } + override fun createView(lifetime: Lifetime): GatewayConnectorView = CoderGatewayConnectorWizardWrapperView() - override fun getDescription(): String { - return CoderGatewayBundle.message("gateway.connector.description") - } + override fun getActionText(): String = CoderGatewayBundle.message("gateway.connector.action.text") - override fun getDocumentationLink(): ActionLink { - return BrowserLink("Learn more", "https://coder.com/docs/coder-oss/latest") - } + override fun getDescription(): String = CoderGatewayBundle.message("gateway.connector.description") - override fun getRecentConnections(setContentCallback: (Component) -> Unit): GatewayRecentConnections { - return CoderGatewayRecentWorkspaceConnectionsView() + override fun getDocumentationAction(): GatewayConnectorDocumentation = GatewayConnectorDocumentation(true) { + HelpManager.getInstance().invokeHelp(ABOUT_HELP_TOPIC) } - override fun getTitle(): String { - return CoderGatewayBundle.message("gateway.connector.title") - } + override fun getRecentConnections(setContentCallback: (Component) -> Unit): GatewayRecentConnections = CoderGatewayRecentWorkspaceConnectionsView(setContentCallback) - override fun getTitleAdornment(): JComponent? { - return null - } + override fun getTitle(): String = CoderGatewayBundle.message("gateway.connector.title") - override fun isAvailable(): Boolean { - return true - } -} \ No newline at end of file + override fun isAvailable(): Boolean = true +} diff --git a/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt new file mode 100644 index 000000000..481e5aa78 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/CoderRemoteConnectionHandle.kt @@ -0,0 +1,559 @@ +@file:Suppress("DialogTitleCapitalization") + +package com.coder.gateway + +import com.coder.gateway.CoderGatewayConstants.GATEWAY_SETUP_COMMAND_ERROR +import com.coder.gateway.cli.CoderCLIManager +import com.coder.gateway.models.WorkspaceProjectIDE +import com.coder.gateway.models.toIdeWithStatus +import com.coder.gateway.models.toRawString +import com.coder.gateway.models.withWorkspaceProject +import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService +import com.coder.gateway.services.CoderSettingsService +import com.coder.gateway.util.DialogUi +import com.coder.gateway.util.SemVer +import com.coder.gateway.util.humanizeDuration +import com.coder.gateway.util.isCancellation +import com.coder.gateway.util.isWorkerTimeout +import com.coder.gateway.util.suspendingRetryWithExponentialBackOff +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.rd.util.launchUnderBackgroundProgress +import com.intellij.openapi.ui.Messages +import com.intellij.remote.AuthType +import com.intellij.remote.RemoteCredentialsHolder +import com.intellij.remoteDev.hostStatus.UnattendedHostStatus +import com.jetbrains.gateway.ssh.CachingProductsJsonWrapper +import com.jetbrains.gateway.ssh.ClientOverSshTunnelConnector +import com.jetbrains.gateway.ssh.HighLevelHostAccessor +import com.jetbrains.gateway.ssh.IdeWithStatus +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct +import com.jetbrains.gateway.ssh.ReleaseType +import com.jetbrains.gateway.ssh.SshHostTunnelConnector +import com.jetbrains.gateway.ssh.deploy.DeployException +import com.jetbrains.gateway.ssh.deploy.ShellArgument +import com.jetbrains.gateway.ssh.deploy.TransferProgressTracker +import com.jetbrains.gateway.ssh.util.validateIDEInstallPath +import com.jetbrains.rd.util.lifetime.LifetimeDefinition +import com.jetbrains.rd.util.lifetime.LifetimeStatus +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.connection.ConnectionException +import org.zeroturnaround.exec.ProcessExecutor +import java.net.URI +import java.nio.file.Path +import java.time.Duration +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +// CoderRemoteConnection uses the provided workspace SSH parameters to launch an +// IDE against the workspace. If successful the connection is added to recent +// connections. +@Suppress("UnstableApiUsage") +class CoderRemoteConnectionHandle { + private val recentConnectionsService = service() + private val settings = service() + + private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm") + private val dialogUi = DialogUi(settings) + + fun connect(getParameters: suspend (indicator: ProgressIndicator) -> WorkspaceProjectIDE) { + val clientLifetime = LifetimeDefinition() + clientLifetime.launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.coder.connection.provider.title")) { + try { + var parameters = getParameters(indicator) + var oldParameters: WorkspaceProjectIDE? = null + logger.debug("Creating connection handle", parameters) + indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting") + suspendingRetryWithExponentialBackOff( + action = { attempt -> + logger.info("Connecting to remote worker on ${parameters.hostname}... (attempt $attempt)") + if (attempt > 1) { + // indicator.text is the text above the progress bar. + indicator.text = CoderGatewayBundle.message("gateway.connector.coder.connecting.retry", attempt) + } else { + indicator.text = "Connecting to remote worker..." + } + // This establishes an SSH connection to a remote worker binary. + // TODO: Can/should accessors to the same host be shared? + val accessor = HighLevelHostAccessor.create( + RemoteCredentialsHolder().apply { + setHost(CoderCLIManager.getBackgroundHostName(parameters.hostname)) + userName = "coder" + port = 22 + authType = AuthType.OPEN_SSH + }, + true, + ) + if (settings.checkIDEUpdate && attempt == 1) { + // See if there is a newer (non-EAP) version of the IDE available. + checkUpdate(accessor, parameters, indicator)?.let { update -> + // Store the old IDE to delete later. + oldParameters = parameters + // Continue with the new IDE. + parameters = update.withWorkspaceProject( + name = parameters.name, + hostname = parameters.hostname, + projectPath = parameters.projectPath, + deploymentURL = parameters.deploymentURL, + ) + } + } + doConnect( + accessor, + parameters, + indicator, + clientLifetime, + settings.setupCommand, + settings.ignoreSetupFailure, + ) + // If successful, delete the old IDE and connection. + oldParameters?.let { + indicator.text = "Deleting ${it.ideName} backend..." + try { + it.idePathOnHost?.let { path -> + accessor.removePathOnRemote(accessor.makeRemotePath(ShellArgument.PlainText(path))) + } + recentConnectionsService.removeConnection(it.toRecentWorkspaceConnection()) + } catch (ex: Exception) { + logger.error("Failed to delete old IDE or connection", ex) + } + } + indicator.text = "Connecting ${parameters.ideName} client..." + // The presence handler runs a good deal earlier than the client + // actually appears, which results in some dead space where it can look + // like opening the client silently failed. This delay janks around + // that, so we can keep the progress indicator open a bit longer. + delay(5000) + }, + retryIf = { + it is ConnectionException || + it is TimeoutException || + it is SSHException || + it is DeployException + }, + onException = { attempt, nextMs, e -> + logger.error("Failed to connect (attempt $attempt; will retry in $nextMs ms)") + // indicator.text2 is the text below the progress bar. + indicator.text2 = + if (isWorkerTimeout(e)) { + "Failed to upload worker binary...it may have timed out" + } else { + e.message ?: e.javaClass.simpleName + } + }, + onCountdown = { remainingMs -> + indicator.text = + CoderGatewayBundle.message( + "gateway.connector.coder.connecting.failed.retry", + humanizeDuration(remainingMs), + ) + }, + ) + logger.info("Adding ${parameters.ideName} for ${parameters.hostname}:${parameters.projectPath} to recent connections") + recentConnectionsService.addRecentConnection(parameters.toRecentWorkspaceConnection()) + } catch (e: CoderSetupCommandException) { + logger.error("Failed to run setup command", e) + showConnectionErrorMessage( + e.message ?: "Unknown error", + "gateway.connector.coder.setup-command.failed", + ) + } catch (e: Exception) { + if (isCancellation(e)) { + logger.info("Connection canceled due to ${e.javaClass.simpleName}") + } else { + logger.error("Failed to connect (will not retry)", e) + showConnectionErrorMessage( + e.message ?: e.javaClass.simpleName ?: "Aborted", + "gateway.connector.coder.connection.failed" + ) + } + } + } + } + + // The dialog will close once we return so write the error + // out into a new dialog. + private fun showConnectionErrorMessage(message: String, titleKey: String) { + ApplicationManager.getApplication().invokeAndWait { + Messages.showMessageDialog( + message, + CoderGatewayBundle.message(titleKey), + Messages.getErrorIcon(), + ) + } + } + + /** + * Return a new (non-EAP) IDE if we should update. + */ + private suspend fun checkUpdate( + accessor: HighLevelHostAccessor, + workspace: WorkspaceProjectIDE, + indicator: ProgressIndicator, + ): IdeWithStatus? { + indicator.text = "Checking for updates..." + val workspaceOS = accessor.guessOs() + logger.info("Got $workspaceOS for ${workspace.hostname}") + val latest = CachingProductsJsonWrapper.getInstance().getAvailableIdes( + IntelliJPlatformProduct.fromProductCode(workspace.ideProduct.productCode) + ?: throw Exception("invalid product code ${workspace.ideProduct.productCode}"), + workspaceOS, + ) + .filter { it.releaseType == ReleaseType.RELEASE } + .minOfOrNull { it.toIdeWithStatus() } + if (latest != null && SemVer.parse(latest.buildNumber) > SemVer.parse(workspace.ideBuildNumber)) { + logger.info("Got newer version: ${latest.buildNumber} versus current ${workspace.ideBuildNumber}") + if (dialogUi.confirm("Update IDE", "There is a new version of this IDE: ${latest.buildNumber}. Would you like to update?")) { + return latest + } + } + return null + } + + /** + * Check for updates, deploy (if needed), connect to the IDE, and update the + * last opened date. + */ + private suspend fun doConnect( + accessor: HighLevelHostAccessor, + workspace: WorkspaceProjectIDE, + indicator: ProgressIndicator, + lifetime: LifetimeDefinition, + setupCommand: String, + ignoreSetupFailure: Boolean, + timeout: Duration = Duration.ofMinutes(10), + ) { + workspace.lastOpened = localTimeFormatter.format(LocalDateTime.now()) + + // Deploy if we need to. + val ideDir = deploy(accessor, workspace, indicator, timeout) + workspace.idePathOnHost = ideDir.toRawString() + + // Run the setup command. + setup(workspace, indicator, setupCommand, ignoreSetupFailure) + + // Wait for the IDE to come up. + indicator.text = "Waiting for ${workspace.ideName} backend..." + val remoteProjectPath = accessor.makeRemotePath(ShellArgument.PlainText(workspace.projectPath)) + val logsDir = accessor.getLogsDir(workspace.ideProduct.productCode, remoteProjectPath) + var status = ensureIDEBackend(accessor, workspace, ideDir, remoteProjectPath, logsDir, lifetime, null) + + // We wait for non-null, so this only happens on cancellation. + val joinLink = status?.joinLink + if (joinLink.isNullOrBlank()) { + logger.info("Connection to ${workspace.ideName} on ${workspace.hostname} was canceled") + return + } + + // Makes sure the ssh log directory exists. + if (settings.sshLogDirectory.isNotBlank()) { + Path.of(settings.sshLogDirectory).toFile().mkdirs() + } + + // Make the initial connection. + indicator.text = "Connecting ${workspace.ideName} client..." + logger.info("Connecting ${workspace.ideName} client to coder@${workspace.hostname}:22") + val client = ClientOverSshTunnelConnector( + lifetime, + SshHostTunnelConnector( + RemoteCredentialsHolder().apply { + setHost(workspace.hostname) + userName = "coder" + port = 22 + authType = AuthType.OPEN_SSH + }, + ), + ) + val handle = client.connect(URI(joinLink)) // Downloads the client too, if needed. + + // Reconnect if the join link changes. + logger.info("Launched ${workspace.ideName} client; beginning backend monitoring") + lifetime.coroutineScope.launch { + while (isActive) { + delay(5000) + val newStatus = ensureIDEBackend(accessor, workspace, ideDir, remoteProjectPath, logsDir, lifetime, status) + val newLink = newStatus?.joinLink + if (newLink != null && newLink != status?.joinLink) { + logger.info("${workspace.ideName} backend join link changed; updating") + // Unfortunately, updating the link is not a smooth + // reconnection. The client closes and is relaunched. + // Trying to reconnect without updating the link results in + // a fingerprint mismatch error. + handle.updateJoinLink(URI(newLink), true) + status = newStatus + } + } + } + + // Tie the lifetime and client together, and wait for the initial open. + suspendCancellableCoroutine { continuation -> + // Close the client if the user cancels. + lifetime.onTermination { + logger.info("Connection to ${workspace.ideName} on ${workspace.hostname} canceled") + if (continuation.isActive) { + continuation.cancel() + } + handle.close() + } + // Kill the lifetime if the client is closed by the user. + handle.clientClosed.advise(lifetime) { + logger.info("${workspace.ideName} client to ${workspace.hostname} closed") + if (lifetime.status == LifetimeStatus.Alive) { + if (continuation.isActive) { + continuation.resumeWithException(Exception("${workspace.ideName} client was closed")) + } + lifetime.terminate() + } + } + // Continue once the client is present. + handle.onClientPresenceChanged.advise(lifetime) { + logger.info("${workspace.ideName} client to ${workspace.hostname} presence: ${handle.clientPresent}") + if (handle.clientPresent && continuation.isActive) { + continuation.resume(true) + } + } + } + } + + /** + * Deploy the IDE if necessary and return the path to its location on disk. + */ + private suspend fun deploy( + accessor: HighLevelHostAccessor, + workspace: WorkspaceProjectIDE, + indicator: ProgressIndicator, + timeout: Duration, + ): ShellArgument.RemotePath { + // The backend might already exist at the provided path. + if (!workspace.idePathOnHost.isNullOrBlank()) { + indicator.text = "Verifying ${workspace.ideName} installation..." + logger.info("Verifying ${workspace.ideName} exists at ${workspace.hostname}:${workspace.idePathOnHost}") + val validatedPath = validateIDEInstallPath(workspace.idePathOnHost, accessor).pathOrNull + if (validatedPath != null) { + logger.info("${workspace.ideName} exists at ${workspace.hostname}:${validatedPath.toRawString()}") + return validatedPath + } + } + + // The backend might already be installed somewhere on the system. + indicator.text = "Searching for ${workspace.ideName} installation..." + logger.info("Searching for ${workspace.ideName} on ${workspace.hostname}") + val installed = + accessor.getInstalledIDEs().find { + it.product == workspace.ideProduct && it.buildNumber == workspace.ideBuildNumber + } + if (installed != null) { + logger.info("${workspace.ideName} found at ${workspace.hostname}:${installed.pathToIde}") + return accessor.makeRemotePath(ShellArgument.PlainText(installed.pathToIde)) + } + + // Otherwise we have to download it. + if (workspace.downloadSource.isNullOrBlank()) { + throw Exception("${workspace.ideName} could not be found on the remote and no download source was provided") + } + + // TODO: Should we download to idePathOnHost if set? That would require + // symlinking instead of creating the sentinel file if the path is + // outside the default dist directory. + indicator.text = "Downloading ${workspace.ideName}..." + indicator.text2 = workspace.downloadSource + val distDir = accessor.getDefaultDistDir() + + // HighLevelHostAccessor.downloadFile does NOT create the directory. + logger.info("Creating ${workspace.hostname}:${distDir.toRawString()}") + accessor.createPathOnRemote(distDir) + + // Download the IDE. + val fileName = workspace.downloadSource.split("/").last() + val downloadPath = distDir.join(listOf(ShellArgument.PlainText(fileName))) + logger.info("Downloading ${workspace.ideName} to ${workspace.hostname}:${downloadPath.toRawString()} from ${workspace.downloadSource}") + accessor.downloadFile( + indicator, + URI(workspace.downloadSource), + downloadPath, + object : TransferProgressTracker { + override var isCancelled: Boolean = false + + override fun updateProgress( + transferred: Long, + speed: Long?, + ) { + // Since there is no total size, this is useless. + } + }, + ) + + // Extract the IDE to its final resting place. + val ideDir = distDir.join(listOf(ShellArgument.PlainText(workspace.ideName))) + indicator.text = "Extracting ${workspace.ideName}..." + indicator.text2 = "" + logger.info("Extracting ${workspace.ideName} to ${workspace.hostname}:${ideDir.toRawString()}") + accessor.removePathOnRemote(ideDir) + accessor.expandArchive(downloadPath, ideDir, timeout.toMillis()) + accessor.removePathOnRemote(downloadPath) + + // Without this file it does not show up in the installed IDE list. + val sentinelFile = ideDir.join(listOf(ShellArgument.PlainText(".expandSucceeded"))).toRawString() + logger.info("Creating ${workspace.hostname}:$sentinelFile") + accessor.fileAccessor.uploadFileFromLocalStream( + sentinelFile, + "".byteInputStream(), + null, + ) + + logger.info("Successfully installed ${workspace.ideName} on ${workspace.hostname}") + return ideDir + } + + /** + * Run the setup command in the IDE's bin directory. + */ + private fun setup( + workspace: WorkspaceProjectIDE, + indicator: ProgressIndicator, + setupCommand: String, + ignoreSetupFailure: Boolean, + ) { + if (setupCommand.isNotBlank()) { + indicator.text = "Running setup command..." + processSetupCommand(ignoreSetupFailure) { + exec(workspace, setupCommand) + } + } else { + logger.info("No setup command to run on ${workspace.hostname}") + } + } + + + /** + * Execute a command in the IDE's bin directory. + * This exists since the accessor does not provide a generic exec. + */ + private fun exec(workspace: WorkspaceProjectIDE, command: String): String { + logger.info("Running command `$command` in ${workspace.hostname}:${workspace.idePathOnHost}/bin...") + return ProcessExecutor() + .command("ssh", "-t", CoderCLIManager.getBackgroundHostName(workspace.hostname), "cd '${workspace.idePathOnHost}' ; cd bin ; $command") + .exitValues(0) + .readOutput(true) + .execute() + .outputUTF8() + } + + /** + * Ensure the backend is started. It will not return until a join link is + * received or the lifetime expires. + */ + private suspend fun ensureIDEBackend( + accessor: HighLevelHostAccessor, + workspace: WorkspaceProjectIDE, + ideDir: ShellArgument.RemotePath, + remoteProjectPath: ShellArgument.RemotePath, + logsDir: ShellArgument.RemotePath, + lifetime: LifetimeDefinition, + currentStatus: UnattendedHostStatus?, + ): UnattendedHostStatus? { + val details = "${workspace.hostname}:${ideDir.toRawString()}, project=${remoteProjectPath.toRawString()}" + val wait = TimeUnit.SECONDS.toMillis(5) + + // Check if the current IDE is alive. + if (currentStatus != null) { + while (lifetime.status == LifetimeStatus.Alive) { + try { + val isAlive = accessor.isPidAlive(currentStatus.appPid.toInt()) + logger.info("${workspace.ideName} status: pid=${currentStatus.appPid}, alive=$isAlive") + if (isAlive) { + // Use the current status and join link. + return currentStatus + } else { + logger.info("Relaunching ${workspace.ideName} since it is not alive...") + break + } + } catch (ex: Exception) { + logger.info("Failed to check if ${workspace.ideName} is alive on $details; waiting $wait ms to try again: pid=${currentStatus.appPid}", ex) + } + delay(wait) + } + } else { + logger.info("Launching ${workspace.ideName} for the first time on ${workspace.hostname}...") + } + + // This means we broke out because the user canceled or closed the IDE. + if (lifetime.status != LifetimeStatus.Alive) { + return null + } + + // If the PID is not alive, spawn a new backend. This may not be + // idempotent, so only call if we are really sure we need to. + accessor.startHostIdeInBackgroundAndDetach(lifetime, ideDir, remoteProjectPath, logsDir) + + // Get the newly spawned PID and join link. + var attempts = 0 + val maxAttempts = 6 + while (lifetime.status == LifetimeStatus.Alive) { + try { + attempts++ + val status = accessor.getHostIdeStatus(ideDir, remoteProjectPath) + if (!status.joinLink.isNullOrBlank()) { + logger.info("Found join link for ${workspace.ideName}; proceeding to connect: pid=${status.appPid}") + return status + } + // If we did not get a join link, see if the IDE is alive in + // case it died and we need to respawn. + val isAlive = status.appPid > 0 && accessor.isPidAlive(status.appPid.toInt()) + logger.info("${workspace.ideName} status: pid=${status.appPid}, alive=$isAlive, unresponsive=${status.backendUnresponsive}, attempt=$attempts") + // It is not clear whether the PID can be trusted because we get + // one even when there is no backend at all. For now give it + // some time and if it is still dead, only then try to respawn. + if (!isAlive && attempts >= maxAttempts) { + logger.info("${workspace.ideName} is still not alive after $attempts checks, respawning backend and waiting $wait ms to try again") + accessor.startHostIdeInBackgroundAndDetach(lifetime, ideDir, remoteProjectPath, logsDir) + attempts = 0 + } else { + logger.info("No join link found in status; waiting $wait ms to try again") + } + } catch (ex: Exception) { + logger.info("Failed to get ${workspace.ideName} status from $details; waiting $wait ms to try again", ex) + } + delay(wait) + } + + // This means the lifetime is no longer alive. + logger.info("Connection to ${workspace.ideName} on $details aborted by user") + return null + } + + companion object { + val logger = Logger.getInstance(CoderRemoteConnectionHandle::class.java.simpleName) + @Throws(CoderSetupCommandException::class) + fun processSetupCommand( + ignoreSetupFailure: Boolean, + execCommand: () -> String + ) { + try { + val errorText = execCommand + .invoke() + .lines() + .firstOrNull { it.contains(GATEWAY_SETUP_COMMAND_ERROR) } + ?.let { it.substring(it.indexOf(GATEWAY_SETUP_COMMAND_ERROR) + GATEWAY_SETUP_COMMAND_ERROR.length).trim() } + + if (!errorText.isNullOrBlank()) { + throw CoderSetupCommandException(errorText) + } + } catch (ex: Exception) { + if (!ignoreSetupFailure) { + throw CoderSetupCommandException(ex.message ?: "Unknown error", ex) + } + } + } + } +} diff --git a/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt new file mode 100644 index 000000000..76096e981 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt @@ -0,0 +1,208 @@ +package com.coder.gateway + +import com.coder.gateway.services.CoderSettingsService +import com.coder.gateway.services.CoderSettingsStateService +import com.coder.gateway.settings.CODER_SSH_CONFIG_OPTIONS +import com.coder.gateway.util.canCreateDirectory +import com.intellij.openapi.components.service +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.ValidationInfo +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.Cell +import com.intellij.ui.dsl.builder.RowLayout +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.selected +import com.intellij.ui.layout.ValidationInfoBuilder +import com.intellij.ui.layout.not +import java.net.URL +import java.nio.file.Path + +class CoderSettingsConfigurable : BoundConfigurable("Coder") { + override fun createPanel(): DialogPanel { + val state: CoderSettingsStateService = service() + val settings: CoderSettingsService = service() + return panel { + row(CoderGatewayBundle.message("gateway.connector.settings.data-directory.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::dataDirectory) + .validationOnApply(validateDataDirectory()) + .validationOnInput(validateDataDirectory()) + .comment( + CoderGatewayBundle.message( + "gateway.connector.settings.data-directory.comment", + settings.dataDir.toString(), + ), + ) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.binary-source.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::binarySource) + .comment( + CoderGatewayBundle.message( + "gateway.connector.settings.binary-source.comment", + settings.binSource(URL("http://localhost")).path, + ), + ) + }.layout(RowLayout.PARENT_GRID) + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.enable-downloads.title")) + .bindSelected(state::enableDownloads) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.enable-downloads.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + // The binary directory is not validated because it could be a + // read-only path that is pre-downloaded by admins. + row(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::binaryDirectory) + .comment(CoderGatewayBundle.message("gateway.connector.settings.binary-destination.comment")) + }.layout(RowLayout.PARENT_GRID) + group { + lateinit var signatureVerificationCheckBox: Cell + row { + cell() // For alignment. + signatureVerificationCheckBox = + checkBox(CoderGatewayBundle.message("gateway.connector.settings.disable-signature-validation.title")) + .bindSelected(state::disableSignatureVerification) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.disable-signature-validation.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title")) + .bindSelected(state::fallbackOnCoderForSignatures) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"), + ) + }.visibleIf(signatureVerificationCheckBox.selected.not()) + .layout(RowLayout.PARENT_GRID) + } + row(CoderGatewayBundle.message("gateway.connector.settings.header-command.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::headerCommand) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.header-command.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.tls-cert-path.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::tlsCertPath) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.tls-cert-path.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.tls-key-path.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::tlsKeyPath) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.tls-key-path.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.tls-ca-path.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::tlsCAPath) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.tls-ca-path.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.tls-alt-name.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::tlsAlternateHostname) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.tls-alt-name.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + group { + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.title")) + .bindSelected(state::disableAutostart) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.disable-autostart.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.wildcard-config.title")) + .bindSelected(state::isSshWildcardConfigEnabled) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.wildcard-config.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + } + row(CoderGatewayBundle.message("gateway.connector.settings.ssh-config-options.title")) { + textArea().resizableColumn().align(AlignX.FILL) + .bindText(state::sshConfigOptions) + .comment( + CoderGatewayBundle.message( + "gateway.connector.settings.ssh-config-options.comment", + CODER_SSH_CONFIG_OPTIONS + ), + ) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.setup-command.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::setupCommand) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.setup-command.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row { + cell() // For alignment. + checkBox(CoderGatewayBundle.message("gateway.connector.settings.ignore-setup-failure.title")) + .bindSelected(state::ignoreSetupFailure) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.ignore-setup-failure.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.default-url.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::defaultURL) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.default-url.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.ssh-log-directory.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::sshLogDirectory) + .comment(CoderGatewayBundle.message("gateway.connector.settings.ssh-log-directory.comment")) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.workspace-filter.title")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::workspaceFilter) + .comment(CoderGatewayBundle.message("gateway.connector.settings.workspace-filter.comment")) + }.layout(RowLayout.PARENT_GRID) + row(CoderGatewayBundle.message("gateway.connector.settings.default-ide")) { + textField().resizableColumn().align(AlignX.FILL) + .bindText(state::defaultIde) + .comment( + "The default IDE version to display in the IDE selection dropdown. " + + "Example format: CL 2023.3.6 233.15619.8", + ) + } + row(CoderGatewayBundle.message("gateway.connector.settings.check-ide-updates.heading")) { + checkBox(CoderGatewayBundle.message("gateway.connector.settings.check-ide-updates.title")) + .bindSelected(state::checkIDEUpdates) + .comment( + CoderGatewayBundle.message("gateway.connector.settings.check-ide-updates.comment"), + ) + }.layout(RowLayout.PARENT_GRID) + } + } + + private fun validateDataDirectory(): ValidationInfoBuilder.(JBTextField) -> ValidationInfo? = { + if (it.text.isNotBlank() && !Path.of(it.text).canCreateDirectory()) { + error("Cannot create this directory") + } else { + null + } + } +} diff --git a/src/main/kotlin/com/coder/gateway/CoderSetupCommandException.kt b/src/main/kotlin/com/coder/gateway/CoderSetupCommandException.kt new file mode 100644 index 000000000..e43d92695 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/CoderSetupCommandException.kt @@ -0,0 +1,7 @@ +package com.coder.gateway + +class CoderSetupCommandException : Exception { + + constructor(message: String) : super(message) + constructor(message: String, cause: Throwable) : super(message, cause) +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/CoderSupportedVersions.kt b/src/main/kotlin/com/coder/gateway/CoderSupportedVersions.kt new file mode 100644 index 000000000..a955f7c9f --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/CoderSupportedVersions.kt @@ -0,0 +1,21 @@ +package com.coder.gateway + +import com.coder.gateway.util.SemVer +import com.intellij.DynamicBundle +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +@NonNls +private const val BUNDLE = "version.CoderSupportedVersions" + +object CoderSupportedVersions : DynamicBundle(BUNDLE) { + val minCompatibleCoderVersion = SemVer.parse(message("minCompatibleCoderVersion")) + val maxCompatibleCoderVersion = SemVer.parse(message("maxCompatibleCoderVersion")) + + @JvmStatic + @Suppress("SpreadOperator") + private fun message( + @PropertyKey(resourceBundle = BUNDLE) key: String, + vararg params: Any, + ) = getMessage(key, *params) +} diff --git a/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt new file mode 100644 index 000000000..74c3ee88f --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/CoderCLIManager.kt @@ -0,0 +1,678 @@ +package com.coder.gateway.cli + +import com.coder.gateway.cli.downloader.CoderDownloadApi +import com.coder.gateway.cli.downloader.CoderDownloadService +import com.coder.gateway.cli.downloader.DownloadResult +import com.coder.gateway.cli.ex.MissingVersionException +import com.coder.gateway.cli.ex.SSHConfigFormatException +import com.coder.gateway.cli.ex.UnsignedBinaryExecutionDeniedException +import com.coder.gateway.cli.gpg.GPGVerifier +import com.coder.gateway.cli.gpg.VerificationResult +import com.coder.gateway.sdk.v2.models.User +import com.coder.gateway.sdk.v2.models.Workspace +import com.coder.gateway.sdk.v2.models.WorkspaceAgent +import com.coder.gateway.settings.CoderSettings +import com.coder.gateway.util.CoderHostnameVerifier +import com.coder.gateway.util.DialogUi +import com.coder.gateway.util.InvalidVersionException +import com.coder.gateway.util.SemVer +import com.coder.gateway.util.coderSocketFactory +import com.coder.gateway.util.coderTrustManagers +import com.coder.gateway.util.escape +import com.coder.gateway.util.escapeSubcommand +import com.coder.gateway.util.safeHost +import com.intellij.openapi.diagnostic.Logger +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.Moshi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.zeroturnaround.exec.ProcessExecutor +import retrofit2.Retrofit +import java.io.EOFException +import java.io.FileNotFoundException +import java.net.URL +import java.nio.file.Path +import javax.net.ssl.X509TrustManager + +/** + * Version output from the CLI's version command. + */ +@JsonClass(generateAdapter = true) +internal data class Version( + @Json(name = "version") val version: String, +) + +/** + * Do as much as possible to get a valid, up-to-date CLI. + * + * 1. Read the binary directory for the provided URL. + * 2. Abort if we already have an up-to-date version. + * 3. Download the binary using an ETag. + * 4. Abort if we get a 304 (covers cases where the binary is older and does not + * have a version command). + * 5. Download on top of the existing binary. + * 6. Since the binary directory can be read-only, if downloading fails, start + * from step 2 with the data directory. + */ +suspend fun ensureCLI( + deploymentURL: URL, + buildVersion: String, + settings: CoderSettings, + indicator: ((t: String) -> Unit)? = null, +): CoderCLIManager { + val cli = CoderCLIManager(deploymentURL, settings) + + // Short-circuit if we already have the expected version. This + // lets us bypass the 304 which is slower and may not be + // supported if the binary is downloaded from alternate sources. + // For CLIs without the JSON output flag we will fall back to + // the 304 method. + val cliMatches = cli.matchesVersion(buildVersion) + if (cliMatches == true) { + indicator?.invoke("Local CLI version matches server version: $buildVersion") + return cli + } + + // If downloads are enabled download the new version. + if (settings.enableDownloads) { + indicator?.invoke("Downloading Coder CLI...") + try { + cli.download(buildVersion, indicator) + return cli + } catch (e: java.nio.file.AccessDeniedException) { + // Might be able to fall back to the data directory. + val binPath = settings.binPath(deploymentURL) + val dataDir = settings.dataDir(deploymentURL) + if (binPath.parent == dataDir || !settings.enableBinaryDirectoryFallback) { + throw e + } + } + } + + // Try falling back to the data directory. + val dataCLI = CoderCLIManager(deploymentURL, settings, true) + val dataCLIMatches = dataCLI.matchesVersion(buildVersion) + if (dataCLIMatches == true) { + indicator?.invoke("Local CLI version from data directory matches server version: $buildVersion") + return dataCLI + } + + if (settings.enableDownloads) { + indicator?.invoke("Downloading Coder CLI to the data directory...") + dataCLI.download(buildVersion, indicator) + return dataCLI + } + + // Prefer the binary directory unless the data directory has a + // working binary and the binary directory does not. + return if (cliMatches == null && dataCLIMatches != null) dataCLI else cli +} + +/** + * The supported features of the CLI. + */ +data class Features( + val disableAutostart: Boolean = false, + val reportWorkspaceUsage: Boolean = false, + val wildcardSSH: Boolean = false, + val buildReason: Boolean = false, +) + +/** + * Manage the CLI for a single deployment. + */ +class CoderCLIManager( + // The URL of the deployment this CLI is for. + private val deploymentURL: URL, + // Plugin configuration. + private val settings: CoderSettings, + // If the binary directory is not writable, this can be used to force the + // manager to download to the data directory instead. + private val forceDownloadToData: Boolean = false, +) { + private val downloader = createDownloadService() + private val gpgVerifier = GPGVerifier(settings) + + val remoteBinaryURL: URL = settings.binSource(deploymentURL) + val localBinaryPath: Path = settings.binPath(deploymentURL, forceDownloadToData) + val coderConfigPath: Path = settings.dataDir(deploymentURL).resolve("config") + + private fun createDownloadService(): CoderDownloadService { + val okHttpClient = OkHttpClient.Builder() + .sslSocketFactory( + coderSocketFactory(settings.tls), + coderTrustManagers(settings.tls.caPath)[0] as X509TrustManager + ) + .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(deploymentURL.toString()) + .client(okHttpClient) + .build() + + val service = retrofit.create(CoderDownloadApi::class.java) + return CoderDownloadService(settings, service, deploymentURL, forceDownloadToData) + } + + /** + * Download the CLI from the deployment if necessary. + */ + suspend fun download(buildVersion: String, showTextProgress: ((t: String) -> Unit)? = null): Boolean { + try { + val cliResult = withContext(Dispatchers.IO) { + downloader.downloadCli(buildVersion, showTextProgress) + }.let { result -> + when { + result.isSkipped() -> return false + result.isNotFound() -> throw IllegalStateException("Could not find Coder CLI") + result.isFailed() -> throw (result as DownloadResult.Failed).error + else -> result as DownloadResult.Downloaded + } + } + if (settings.disableSignatureVerification) { + downloader.commit() + logger.info("Skipping over CLI signature verification, it is disabled by the user") + return true + } + + var signatureResult = withContext(Dispatchers.IO) { + downloader.downloadSignature(showTextProgress) + } + + if (signatureResult.isNotDownloaded()) { + if (settings.fallbackOnCoderForSignatures) { + logger.info("Trying to download signature file from releases.coder.com") + signatureResult = withContext(Dispatchers.IO) { + downloader.downloadReleasesSignature(buildVersion, showTextProgress) + } + + // if we could still not download it, ask the user if he accepts the risk + if (signatureResult.isNotDownloaded()) { + val acceptsUnsignedBinary = DialogUi(settings) + .confirm( + "Security Warning", + "Could not fetch any signatures for ${cliResult.source} from releases.coder.com. Would you like to run it anyway?" + ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") + } + } + } else { + // we are not allowed to fetch signatures from releases.coder.com + // so we will ask the user if he wants to continue + val acceptsUnsignedBinary = DialogUi(settings) + .confirm( + "Security Warning", + "No signatures were found for ${cliResult.source} and fallback to releases.coder.com is not allowed. Would you like to run it anyway?" + ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") + } + } + } + + // we have the cli, and signature is downloaded, let's verify the signature + signatureResult = signatureResult as DownloadResult.Downloaded + gpgVerifier.verifySignature(cliResult.dst, signatureResult.dst).let { result -> + when { + result.isValid() -> { + downloader.commit() + return true + } + + else -> { + logFailure(result, cliResult, signatureResult) + // prompt the user if he wants to accept the risk + val shouldRunAnyway = DialogUi(settings) + .confirm( + "Security Warning", + "Could not verify the authenticity of the ${cliResult.source}, it may be tampered with. Would you like to run it anyway?" + ) + + if (shouldRunAnyway) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unverified CLI from ${cliResult.source} was denied by the user") + } + } + } + } + } finally { + downloader.cleanup() + } + } + + private fun logFailure( + result: VerificationResult, + cliResult: DownloadResult.Downloaded, + signatureResult: DownloadResult.Downloaded + ) { + when { + result.isInvalid() -> { + val reason = (result as VerificationResult.Invalid).reason + logger.error("Signature of ${cliResult.dst} is invalid." + reason?.let { " Reason: $it" } + .orEmpty()) + } + + result.signatureIsNotFound() -> { + logger.error("Can't verify signature of ${cliResult.dst} because ${signatureResult.dst} does not exist") + } + + else -> { + val failure = result as VerificationResult.Failed + UnsignedBinaryExecutionDeniedException(result.error.message) + logger.error("Failed to verify signature for ${cliResult.dst}", failure.error) + } + } + } + + /** + * Use the provided token to authenticate the CLI. + */ + fun login(token: String): String { + logger.info("Storing CLI credentials in $coderConfigPath") + return exec( + "login", + deploymentURL.toString(), + "--use-token-as-session", + "--token", + token, + "--global-config", + coderConfigPath.toString(), + ) + } + + /** + * Configure SSH to use this binary. + * + * This can take supported features for testing purposes only. + */ + fun configSsh( + workspacesAndAgents: Set>, + currentUser: User, + feats: Features = features, + ) { + logger.info("Configuring SSH config at ${settings.sshConfigPath}") + writeSSHConfig(modifySSHConfig(readSSHConfig(), workspacesAndAgents, feats, currentUser)) + } + + /** + * Return the contents of the SSH config or null if it does not exist. + */ + private fun readSSHConfig(): String? = try { + settings.sshConfigPath.toFile().readText() + } catch (e: FileNotFoundException) { + null + } + + /** + * Given an existing SSH config modify it to add or remove the config for + * this deployment and return the modified config or null if it does not + * need to be modified. + * + * If features are not provided, calculate them based on the binary + * version. + */ + private fun modifySSHConfig( + contents: String?, + workspaceNames: Set>, + feats: Features, + currentUser: User, + ): String? { + val host = deploymentURL.safeHost() + val startBlock = "# --- START CODER JETBRAINS $host" + val endBlock = "# --- END CODER JETBRAINS $host" + val baseArgs = + listOfNotNull( + escape(localBinaryPath.toString()), + "--global-config", + escape(coderConfigPath.toString()), + // CODER_URL might be set, and it will override the URL file in + // the config directory, so override that here to make sure we + // always use the correct URL. + "--url", + escape(deploymentURL.toString()), + if (settings.headerCommand.isNotBlank()) "--header-command" else null, + if (settings.headerCommand.isNotBlank()) escapeSubcommand(settings.headerCommand) else null, + "ssh", + "--stdio", + if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, + ) + val proxyArgs = baseArgs + listOfNotNull( + if (settings.sshLogDirectory.isNotBlank()) "--log-dir" else null, + if (settings.sshLogDirectory.isNotBlank()) escape(settings.sshLogDirectory) else null, + if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, + ) + val backgroundProxyArgs = + baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null) + val extraConfig = + if (settings.sshConfigOptions.isNotBlank()) { + "\n" + settings.sshConfigOptions.prependIndent(" ") + } else { + "" + } + val sshOpts = """ + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + """.trimIndent() + val blockContent = + if (settings.isSshWildcardConfigEnabled && feats.wildcardSSH) { + startBlock + System.lineSeparator() + + """ + Host ${getHostPrefix()}--* + ProxyCommand ${proxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-- %h + """.trimIndent() + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig) + .plus("\n\n") + .plus( + """ + Host ${getHostPrefix()}-bg--* + ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${getHostPrefix()}-bg-- %h + """.trimIndent() + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig), + ).replace("\n", System.lineSeparator()) + + System.lineSeparator() + endBlock + } else if (workspaceNames.isEmpty()) { + "" + } else { + workspaceNames.joinToString( + System.lineSeparator(), + startBlock + System.lineSeparator(), + System.lineSeparator() + endBlock, + transform = { + """ + Host ${getHostName(it.first, currentUser, it.second)} + ProxyCommand ${proxyArgs.joinToString(" ")} ${getWorkspaceParts(it.first, it.second)} + """.trimIndent() + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig) + .plus("\n") + .plus( + """ + Host ${getBackgroundHostName(it.first, currentUser, it.second)} + ProxyCommand ${backgroundProxyArgs.joinToString(" ")} ${ + getWorkspaceParts( + it.first, + it.second + ) + } + """.trimIndent() + .plus("\n" + sshOpts.prependIndent(" ")) + .plus(extraConfig), + ).replace("\n", System.lineSeparator()) + }, + ) + } + + if (contents == null) { + logger.info("No existing SSH config to modify") + return blockContent + System.lineSeparator() + } + + val start = "(\\s*)$startBlock".toRegex().find(contents) + val end = "$endBlock(\\s*)".toRegex().find(contents) + + val isRemoving = blockContent.isEmpty() + + if (start == null && end == null && isRemoving) { + logger.info("No workspaces and no existing config blocks to remove") + return null + } + + if (start == null && end == null) { + logger.info("Appending config block") + val toAppend = + if (contents.isEmpty()) { + blockContent + } else { + listOf( + contents, + blockContent, + ).joinToString(System.lineSeparator()) + } + return toAppend + System.lineSeparator() + } + + if (start == null) { + throw SSHConfigFormatException("End block exists but no start block") + } + if (end == null) { + throw SSHConfigFormatException("Start block exists but no end block") + } + if (start.range.first > end.range.first) { + throw SSHConfigFormatException("Start block found after end block") + } + + if (isRemoving) { + logger.info("No workspaces; removing config block") + return listOf( + contents.substring(0, start.range.first), + // Need to keep the trailing newline(s) if we are not at the + // front of the file otherwise the before and after lines would + // get joined. + if (start.range.first > 0) end.groupValues[1] else "", + contents.substring(end.range.last + 1), + ).joinToString("") + } + + logger.info("Replacing existing config block") + return listOf( + contents.substring(0, start.range.first), + start.groupValues[1], // Leading newline(s). + blockContent, + end.groupValues[1], // Trailing newline(s). + contents.substring(end.range.last + 1), + ).joinToString("") + } + + /** + * Write the provided SSH config or do nothing if null. + */ + private fun writeSSHConfig(contents: String?) { + if (contents != null) { + settings.sshConfigPath.parent.toFile().mkdirs() + settings.sshConfigPath.toFile().writeText(contents) + // The Coder cli will *not* create the log directory. + if (settings.sshLogDirectory.isNotBlank()) { + Path.of(settings.sshLogDirectory).toFile().mkdirs() + } + } + } + + /** + * Return the binary version. + * + * Throws if it could not be determined. + */ + fun version(): SemVer { + val raw = exec("version", "--output", "json") + try { + val json = Moshi.Builder().build().adapter(Version::class.java).fromJson(raw) + if (json?.version == null || json.version.isBlank()) { + throw MissingVersionException("No version found in output") + } + return SemVer.parse(json.version) + } catch (exception: JsonDataException) { + throw MissingVersionException("No version found in output") + } catch (exception: EOFException) { + throw MissingVersionException("No version found in output") + } + } + + /** + * Like version(), but logs errors instead of throwing them. + */ + private fun tryVersion(): SemVer? = try { + version() + } catch (e: Exception) { + when (e) { + is InvalidVersionException -> { + logger.info("Got invalid version from $localBinaryPath: ${e.message}") + } + + else -> { + // An error here most likely means the CLI does not exist or + // it executed successfully but output no version which + // suggests it is not the right binary. + logger.info("Unable to determine $localBinaryPath version: ${e.message}") + } + } + null + } + + /** + * Returns true if the CLI has the same major/minor/patch version as the + * provided version, false if it does not match, or null if the CLI version + * could not be determined because the binary could not be executed or the + * version could not be parsed. + */ + fun matchesVersion(rawBuildVersion: String): Boolean? { + val cliVersion = tryVersion() ?: return null + val buildVersion = + try { + SemVer.parse(rawBuildVersion) + } catch (e: InvalidVersionException) { + logger.info("Got invalid build version: $rawBuildVersion") + return null + } + + val matches = cliVersion == buildVersion + logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches") + return matches + } + + /** + * Start a workspace. + * + * Throws if the command execution fails. + */ + fun startWorkspace(workspaceOwner: String, workspaceName: String, feats: Features = features): String { + val args = mutableListOf( + "--global-config", + coderConfigPath.toString(), + "start", + "--yes", + workspaceOwner + "/" + workspaceName + ) + + if (feats.buildReason) { + args.addAll(listOf("--reason", "jetbrains_connection")) + } + + return exec(*args.toTypedArray()) + } + + private fun exec(vararg args: String): String { + val stdout = + ProcessExecutor() + .command(localBinaryPath.toString(), *args) + .environment("CODER_HEADER_COMMAND", settings.headerCommand) + .exitValues(0) + .readOutput(true) + .execute() + .outputUTF8() + val redactedArgs = listOf(*args).joinToString(" ").replace(tokenRegex, "--token ") + logger.info("`$localBinaryPath $redactedArgs`: $stdout") + return stdout + } + + val features: Features + get() { + val version = tryVersion() + return if (version == null) { + Features() + } else { + Features( + disableAutostart = version >= SemVer(2, 5, 0), + reportWorkspaceUsage = version >= SemVer(2, 13, 0), + wildcardSSH = version >= SemVer(2, 19, 0), + buildReason = version >= SemVer(2, 25, 0), + ) + } + } + + /* + * This function returns the ssh-host-prefix used for Host entries. + */ + fun getHostPrefix(): String = "coder-jetbrains-${deploymentURL.safeHost()}" + + /** + * This function returns the ssh host name generated for connecting to the workspace. + */ + fun getHostName( + workspace: Workspace, + currentUser: User, + agent: WorkspaceAgent, + ): String = if (settings.isSshWildcardConfigEnabled && features.wildcardSSH) { + "${getHostPrefix()}--${workspace.ownerName}--${workspace.name}.${agent.name}" + } else { + // For a user's own workspace, we use the old syntax without a username for backwards compatibility, + // since the user might have recent connections that still use the old syntax. + if (currentUser.username == workspace.ownerName) { + "coder-jetbrains--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}" + } else { + "coder-jetbrains--${workspace.ownerName}--${workspace.name}.${agent.name}--${deploymentURL.safeHost()}" + } + } + + fun getBackgroundHostName( + workspace: Workspace, + currentUser: User, + agent: WorkspaceAgent, + ): String = if (settings.isSshWildcardConfigEnabled && features.wildcardSSH) { + "${getHostPrefix()}-bg--${workspace.ownerName}--${workspace.name}.${agent.name}" + } else { + getHostName(workspace, currentUser, agent) + "--bg" + } + + companion object { + val logger = Logger.getInstance(CoderCLIManager::class.java.simpleName) + + private val tokenRegex = "--token [^ ]+".toRegex() + + /** + * This function returns the identifier for the workspace to pass to the + * coder ssh proxy command. + */ + @JvmStatic + fun getWorkspaceParts( + workspace: Workspace, + agent: WorkspaceAgent, + ): String = "${workspace.ownerName}/${workspace.name}.${agent.name}" + + @JvmStatic + fun getBackgroundHostName( + hostname: String, + ): String { + val parts = hostname.split("--").toMutableList() + if (parts.size < 2) { + throw SSHConfigFormatException("Invalid hostname: $hostname") + } + // non-wildcard case + if (parts[0] == "coder-jetbrains") { + return hostname + "--bg" + } + // wildcard case + parts[0] += "-bg" + return parts.joinToString("--") + } + } +} diff --git a/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadApi.kt b/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadApi.kt new file mode 100644 index 000000000..fa27fdc7d --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadApi.kt @@ -0,0 +1,29 @@ +package com.coder.gateway.cli.downloader + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.HeaderMap +import retrofit2.http.Streaming +import retrofit2.http.Url + +/** + * Retrofit API for downloading CLI + */ +interface CoderDownloadApi { + @GET + @Streaming + suspend fun downloadCli( + @Url url: String, + @Header("If-None-Match") eTag: String? = null, + @HeaderMap headers: Map = emptyMap(), + @Header("Accept-Encoding") acceptEncoding: String = "gzip", + ): Response + + @GET + suspend fun downloadSignature( + @Url url: String, + @HeaderMap headers: Map = emptyMap() + ): Response +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadService.kt new file mode 100644 index 000000000..3c315dd0e --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/downloader/CoderDownloadService.kt @@ -0,0 +1,238 @@ +package com.coder.gateway.cli.downloader + +import com.coder.gateway.cli.ex.ResponseException +import com.coder.gateway.settings.CoderSettings +import com.coder.gateway.util.OS +import com.coder.gateway.util.SemVer +import com.coder.gateway.util.getHeaders +import com.coder.gateway.util.getOS +import com.coder.gateway.util.sha1 +import com.intellij.openapi.diagnostic.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import retrofit2.Response +import java.io.FileInputStream +import java.net.HttpURLConnection.HTTP_NOT_FOUND +import java.net.HttpURLConnection.HTTP_NOT_MODIFIED +import java.net.HttpURLConnection.HTTP_OK +import java.net.URI +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.util.zip.GZIPInputStream +import kotlin.io.path.name +import kotlin.io.path.notExists + +/** + * Handles the download steps of Coder CLI + */ +class CoderDownloadService( + private val settings: CoderSettings, + private val downloadApi: CoderDownloadApi, + private val deploymentUrl: URL, + forceDownloadToData: Boolean, +) { + private val remoteBinaryURL: URL = settings.binSource(deploymentUrl) + private val cliFinalDst: Path = settings.binPath(deploymentUrl, forceDownloadToData) + private val cliTempDst: Path = cliFinalDst.resolveSibling("${cliFinalDst.name}.tmp") + + suspend fun downloadCli(buildVersion: String, showTextProgress: ((t: String) -> Unit)? = null): DownloadResult { + val eTag = calculateLocalETag() + if (eTag != null) { + logger.info("Found existing binary at $cliFinalDst; calculated hash as $eTag") + } + val response = downloadApi.downloadCli( + url = remoteBinaryURL.toString(), + eTag = eTag?.let { "\"$it\"" }, + headers = getRequestHeaders() + ) + + return when (response.code()) { + HTTP_OK -> { + logger.info("Downloading binary to temporary $cliTempDst") + response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable() + DownloadResult.Downloaded(remoteBinaryURL, cliTempDst) + } + + HTTP_NOT_MODIFIED -> { + logger.info("Using cached binary at $cliFinalDst") + showTextProgress?.invoke("Using cached binary") + DownloadResult.Skipped + } + + else -> { + throw ResponseException( + "Unexpected response from $remoteBinaryURL", + response.code() + ) + } + } + } + + /** + * Renames the temporary binary file to its original destination name. + * The implementation will override sibling file that has the original + * destination name. + */ + suspend fun commit(): Path { + return withContext(Dispatchers.IO) { + logger.info("Renaming binary from $cliTempDst to $cliFinalDst") + Files.move(cliTempDst, cliFinalDst, StandardCopyOption.REPLACE_EXISTING) + cliFinalDst.makeExecutable() + cliFinalDst + } + } + + /** + * Cleans up the temporary binary file if it exists. + */ + suspend fun cleanup() { + withContext(Dispatchers.IO) { + runCatching { Files.deleteIfExists(cliTempDst) } + .onFailure { ex -> + logger.warn("Failed to delete temporary CLI file: $cliTempDst", ex) + } + } + } + + private fun calculateLocalETag(): String? { + return try { + if (cliFinalDst.notExists()) { + return null + } + sha1(FileInputStream(cliFinalDst.toFile())) + } catch (e: Exception) { + logger.warn("Unable to calculate hash for $cliFinalDst", e) + null + } + } + + private fun getRequestHeaders(): Map { + return if (settings.headerCommand.isBlank()) { + emptyMap() + } else { + getHeaders(deploymentUrl, settings.headerCommand) + } + } + + private fun Response.saveToDisk( + localPath: Path, + showTextProgress: ((t: String) -> Unit)? = null, + buildVersion: String? = null + ): Path? { + val responseBody = this.body() ?: return null + Files.deleteIfExists(localPath) + Files.createDirectories(localPath.parent) + + val outputStream = Files.newOutputStream( + localPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + val contentEncoding = this.headers()["Content-Encoding"] + val sourceStream = if (contentEncoding?.contains("gzip", ignoreCase = true) == true) { + GZIPInputStream(responseBody.byteStream()) + } else { + responseBody.byteStream() + } + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalRead = 0L + // local path is a temporary filename, reporting the progress with the real name + val binaryName = localPath.name.removeSuffix(".tmp") + sourceStream.use { source -> + outputStream.use { sink -> + while (source.read(buffer).also { bytesRead = it } != -1) { + sink.write(buffer, 0, bytesRead) + totalRead += bytesRead + val prettyBuildVersion = buildVersion ?: "" + showTextProgress?.invoke( + "$binaryName $prettyBuildVersion - ${totalRead.toHumanReadableSize()} downloaded" + ) + } + } + } + return cliFinalDst + } + + + private fun Path.makeExecutable() { + if (getOS() != OS.WINDOWS) { + logger.info("Making $this executable...") + this.toFile().setExecutable(true) + } + } + + private fun Long.toHumanReadableSize(): String { + if (this < 1024) return "$this B" + + val kb = this / 1024.0 + if (kb < 1024) return String.format("%.1f KB", kb) + + val mb = kb / 1024.0 + if (mb < 1024) return String.format("%.1f MB", mb) + + val gb = mb / 1024.0 + return String.format("%.1f GB", gb) + } + + suspend fun downloadSignature(showTextProgress: ((t: String) -> Unit)? = null): DownloadResult { + return downloadSignature(remoteBinaryURL, showTextProgress, getRequestHeaders()) + } + + private suspend fun downloadSignature( + url: URL, + showTextProgress: ((t: String) -> Unit)? = null, + headers: Map = emptyMap() + ): DownloadResult { + val signatureURL = url.toURI().resolve(settings.defaultSignatureNameByOsAndArch).toURL() + val localSignaturePath = cliFinalDst.parent.resolve(settings.defaultSignatureNameByOsAndArch) + logger.info("Downloading signature from $signatureURL") + + val response = downloadApi.downloadSignature( + url = signatureURL.toString(), + headers = headers + ) + + return when (response.code()) { + HTTP_OK -> { + response.saveToDisk(localSignaturePath, showTextProgress) + DownloadResult.Downloaded(signatureURL, localSignaturePath) + } + + HTTP_NOT_FOUND -> { + logger.warn("Signature file not found at $signatureURL") + DownloadResult.NotFound + } + + else -> { + DownloadResult.Failed( + ResponseException( + "Failed to download signature from $signatureURL", + response.code() + ) + ) + } + } + + } + + suspend fun downloadReleasesSignature( + buildVersion: String, + showTextProgress: ((t: String) -> Unit)? = null + ): DownloadResult { + val semVer = SemVer.parse(buildVersion) + return downloadSignature( + URI.create("https://releases.coder.com/coder-cli/${semVer.major}.${semVer.minor}.${semVer.patch}/").toURL(), + showTextProgress + ) + } + + companion object { + val logger = Logger.getInstance(CoderDownloadService::class.java.simpleName) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt b/src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt new file mode 100644 index 000000000..a0fcfc933 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/downloader/DownloadResult.kt @@ -0,0 +1,23 @@ +package com.coder.gateway.cli.downloader + +import java.net.URL +import java.nio.file.Path + + +/** + * Result of a download operation + */ +sealed class DownloadResult { + object Skipped : DownloadResult() + object NotFound : DownloadResult() + data class Downloaded(val source: URL, val dst: Path) : DownloadResult() + data class Failed(val error: Exception) : DownloadResult() + + fun isSkipped(): Boolean = this is Skipped + + fun isNotFound(): Boolean = this is NotFound + + fun isFailed(): Boolean = this is Failed + + fun isNotDownloaded(): Boolean = this !is Downloaded +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt b/src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt new file mode 100644 index 000000000..448847bed --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/ex/Exceptions.kt @@ -0,0 +1,9 @@ +package com.coder.gateway.cli.ex + +class ResponseException(message: String, val code: Int) : Exception(message) + +class SSHConfigFormatException(message: String) : Exception(message) + +class MissingVersionException(message: String) : Exception(message) + +class UnsignedBinaryExecutionDeniedException(message: String?) : Exception(message) diff --git a/src/main/kotlin/com/coder/gateway/cli/gpg/GPGVerifier.kt b/src/main/kotlin/com/coder/gateway/cli/gpg/GPGVerifier.kt new file mode 100644 index 000000000..ec1040ded --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/gpg/GPGVerifier.kt @@ -0,0 +1,142 @@ +package com.coder.gateway.cli.gpg + +import com.coder.gateway.settings.CoderSettings +import com.coder.gateway.cli.gpg.VerificationResult.Failed +import com.coder.gateway.cli.gpg.VerificationResult.Invalid +import com.coder.gateway.cli.gpg.VerificationResult.SignatureNotFound +import com.coder.gateway.cli.gpg.VerificationResult.Valid +import com.intellij.openapi.diagnostic.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bouncycastle.bcpg.ArmoredInputStream +import org.bouncycastle.openpgp.PGPException +import org.bouncycastle.openpgp.PGPPublicKey +import org.bouncycastle.openpgp.PGPPublicKeyRing +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.PGPSignatureList +import org.bouncycastle.openpgp.PGPUtil +import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider +import java.io.ByteArrayInputStream +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.inputStream + +class GPGVerifier( + private val settings: CoderSettings +) { + + suspend fun verifySignature( + cli: Path, + signature: Path, + ): VerificationResult { + return try { + if (!Files.exists(signature)) { + logger.warn("Signature file not found, skipping verification") + return SignatureNotFound + } + + val (signatureBytes, publicKeyRing) = withContext(Dispatchers.IO) { + val signatureBytes = Files.readAllBytes(signature) + val publicKeyRing = getCoderPublicKeyRings() + + Pair(signatureBytes, publicKeyRing) + } + return verifyDetachedSignature( + cliPath = cli, + signatureBytes = signatureBytes, + publicKeyRings = publicKeyRing + ) + } catch (e: Exception) { + logger.error("GPG signature verification failed", e) + Failed(e) + } + } + + private fun getCoderPublicKeyRings(): List { + try { + val coderPublicKey = javaClass.getResourceAsStream("/META-INF/trusted-keys/pgp-public.key") + ?.readAllBytes() ?: throw IllegalStateException("Trusted public key not found") + return loadPublicKeyRings(coderPublicKey) + } catch (e: Exception) { + throw PGPException("Failed to load Coder public GPG key", e) + } + } + + /** + * Load public key rings from bytes + */ + fun loadPublicKeyRings(publicKeyBytes: ByteArray): List { + return try { + val keyInputStream = ArmoredInputStream(ByteArrayInputStream(publicKeyBytes)) + val keyRingCollection = PGPPublicKeyRingCollection( + PGPUtil.getDecoderStream(keyInputStream), + JcaKeyFingerprintCalculator() + ) + keyRingCollection.keyRings.asSequence().toList() + } catch (e: Exception) { + throw PGPException("Failed to load public key ring", e) + } + } + + /** + * Verify a detached GPG signature + */ + fun verifyDetachedSignature( + cliPath: Path, + signatureBytes: ByteArray, + publicKeyRings: List + ): VerificationResult { + try { + val signatureInputStream = ArmoredInputStream(ByteArrayInputStream(signatureBytes)) + val pgpObjectFactory = JcaPGPObjectFactory(signatureInputStream) + val signatureList = pgpObjectFactory.nextObject() as? PGPSignatureList + ?: throw PGPException("Invalid signature format") + + if (signatureList.isEmpty) { + return Invalid("No signatures found in signature file") + } + + val signature = signatureList[0] + val publicKey = findPublicKey(publicKeyRings, signature.keyID) + ?: throw PGPException("Public key not found for signature") + + signature.init(JcaPGPContentVerifierBuilderProvider(), publicKey) + cliPath.inputStream().use { fileStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (fileStream.read(buffer).also { bytesRead = it } != -1) { + signature.update(buffer, 0, bytesRead) + } + } + + val isValid = signature.verify() + logger.info("GPG signature verification result: $isValid") + if (isValid) { + return Valid + } + return Invalid() + } catch (e: Exception) { + logger.error("GPG signature verification failed", e) + return Failed(e) + } + } + + /** + * Find a public key across all key rings in the collection + */ + private fun findPublicKey( + keyRings: List, + keyId: Long + ): PGPPublicKey? { + keyRings.forEach { keyRing -> + keyRing.getPublicKey(keyId)?.let { return it } + } + return null + } + + companion object { + val logger = Logger.getInstance(GPGVerifier::class.java.simpleName) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/cli/gpg/VerificationResult.kt b/src/main/kotlin/com/coder/gateway/cli/gpg/VerificationResult.kt new file mode 100644 index 000000000..5e7a94ff4 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/cli/gpg/VerificationResult.kt @@ -0,0 +1,15 @@ +package com.coder.gateway.cli.gpg + +/** + * Result of signature verification + */ +sealed class VerificationResult { + object Valid : VerificationResult() + data class Invalid(val reason: String? = null) : VerificationResult() + data class Failed(val error: Exception) : VerificationResult() + object SignatureNotFound : VerificationResult() + + fun isValid(): Boolean = this == Valid + fun isInvalid(): Boolean = this is Invalid + fun signatureIsNotFound(): Boolean = this == SignatureNotFound +} diff --git a/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt b/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt new file mode 100644 index 000000000..b441cbd10 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/help/CoderWebHelp.kt @@ -0,0 +1,12 @@ +package com.coder.gateway.help + +import com.intellij.openapi.help.WebHelpProvider + +const val ABOUT_HELP_TOPIC = "com.coder.gateway.about" + +class CoderWebHelp : WebHelpProvider() { + override fun getHelpPageUrl(helpTopicId: String): String = when (helpTopicId) { + ABOUT_HELP_TOPIC -> "https://coder.com/docs" + else -> "https://coder.com/docs" + } +} diff --git a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt b/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt index 2d084eb04..3011e633c 100644 --- a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt +++ b/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt @@ -1,22 +1,150 @@ package com.coder.gateway.icons import com.intellij.openapi.util.IconLoader +import com.intellij.ui.JreHiDpiUtil +import com.intellij.ui.paint.PaintUtil +import com.intellij.ui.scale.JBUIScale +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.image.BufferedImage +import javax.swing.Icon object CoderIcons { - val LOGO = IconLoader.getIcon("coder_logo.svg", javaClass) - val LOGO_16 = IconLoader.getIcon("coder_logo_16.svg", javaClass) - val LOGO_52 = IconLoader.getIcon("coder_logo_52.svg", javaClass) + val LOGO = IconLoader.getIcon("logo/coder_logo.svg", javaClass) + val LOGO_16 = IconLoader.getIcon("logo/coder_logo_16.svg", javaClass) - val OPEN_TERMINAL = IconLoader.getIcon("open_terminal.svg", javaClass) + val OPEN_TERMINAL = IconLoader.getIcon("icons/open_terminal.svg", javaClass) - val WINDOWS = IconLoader.getIcon("windows.svg", javaClass) - val MACOS = IconLoader.getIcon("macOS.svg", javaClass) - val LINUX = IconLoader.getIcon("linux.svg", javaClass) - val UNKNOWN = IconLoader.getIcon("unknown.svg", javaClass) + val HOME = IconLoader.getIcon("icons/homeFolder.svg", javaClass) + val CREATE = IconLoader.getIcon("icons/create.svg", javaClass) + val RUN = IconLoader.getIcon("icons/run.svg", javaClass) + val STOP = IconLoader.getIcon("icons/stop.svg", javaClass) + val UPDATE = IconLoader.getIcon("icons/update.svg", javaClass) + val DELETE = IconLoader.getIcon("icons/delete.svg", javaClass) - val GREEN_CIRCLE = IconLoader.getIcon("green_circle.svg", javaClass) - val GRAY_CIRCLE = IconLoader.getIcon("gray_circle.svg", javaClass) - val RED_CIRCLE = IconLoader.getIcon("red_circle.svg", javaClass) + val UNKNOWN = IconLoader.getIcon("icons/unknown.svg", javaClass) - val DELETE = IconLoader.getIcon("delete.svg", javaClass) -} \ No newline at end of file + private val ZERO = IconLoader.getIcon("symbols/0.svg", javaClass) + private val ONE = IconLoader.getIcon("symbols/1.svg", javaClass) + private val TWO = IconLoader.getIcon("symbols/2.svg", javaClass) + private val THREE = IconLoader.getIcon("symbols/3.svg", javaClass) + private val FOUR = IconLoader.getIcon("symbols/4.svg", javaClass) + private val FIVE = IconLoader.getIcon("symbols/5.svg", javaClass) + private val SIX = IconLoader.getIcon("symbols/6.svg", javaClass) + private val SEVEN = IconLoader.getIcon("symbols/7.svg", javaClass) + private val EIGHT = IconLoader.getIcon("symbols/8.svg", javaClass) + private val NINE = IconLoader.getIcon("symbols/9.svg", javaClass) + + private val A = IconLoader.getIcon("symbols/a.svg", javaClass) + private val B = IconLoader.getIcon("symbols/b.svg", javaClass) + private val C = IconLoader.getIcon("symbols/c.svg", javaClass) + private val D = IconLoader.getIcon("symbols/d.svg", javaClass) + private val E = IconLoader.getIcon("symbols/e.svg", javaClass) + private val F = IconLoader.getIcon("symbols/f.svg", javaClass) + private val G = IconLoader.getIcon("symbols/g.svg", javaClass) + private val H = IconLoader.getIcon("symbols/h.svg", javaClass) + private val I = IconLoader.getIcon("symbols/i.svg", javaClass) + private val J = IconLoader.getIcon("symbols/j.svg", javaClass) + private val K = IconLoader.getIcon("symbols/k.svg", javaClass) + private val L = IconLoader.getIcon("symbols/l.svg", javaClass) + private val M = IconLoader.getIcon("symbols/m.svg", javaClass) + private val N = IconLoader.getIcon("symbols/n.svg", javaClass) + private val O = IconLoader.getIcon("symbols/o.svg", javaClass) + private val P = IconLoader.getIcon("symbols/p.svg", javaClass) + private val Q = IconLoader.getIcon("symbols/q.svg", javaClass) + private val R = IconLoader.getIcon("symbols/r.svg", javaClass) + private val S = IconLoader.getIcon("symbols/s.svg", javaClass) + private val T = IconLoader.getIcon("symbols/t.svg", javaClass) + private val U = IconLoader.getIcon("symbols/u.svg", javaClass) + private val V = IconLoader.getIcon("symbols/v.svg", javaClass) + private val W = IconLoader.getIcon("symbols/w.svg", javaClass) + private val X = IconLoader.getIcon("symbols/x.svg", javaClass) + private val Y = IconLoader.getIcon("symbols/y.svg", javaClass) + private val Z = IconLoader.getIcon("symbols/z.svg", javaClass) + + fun fromChar(c: Char) = when (c) { + '0' -> ZERO + '1' -> ONE + '2' -> TWO + '3' -> THREE + '4' -> FOUR + '5' -> FIVE + '6' -> SIX + '7' -> SEVEN + '8' -> EIGHT + '9' -> NINE + + 'a' -> A + 'b' -> B + 'c' -> C + 'd' -> D + 'e' -> E + 'f' -> F + 'g' -> G + 'h' -> H + 'i' -> I + 'j' -> J + 'k' -> K + 'l' -> L + 'm' -> M + 'n' -> N + 'o' -> O + 'p' -> P + 'q' -> Q + 'r' -> R + 's' -> S + 't' -> T + 'u' -> U + 'v' -> V + 'w' -> W + 'x' -> X + 'y' -> Y + 'z' -> Z + + else -> UNKNOWN + } +} + +fun alignToInt(g: Graphics) { + if (g !is Graphics2D) { + return + } + + val rm = PaintUtil.RoundingMode.ROUND_FLOOR_BIAS + PaintUtil.alignTxToInt(g, null, true, true, rm) + PaintUtil.alignClipToInt(g, true, true, rm, rm) +} + +// We could replace this with com.intellij.ui.icons.toRetinaAwareIcon at +// some point if we want to break support for Gateway < 232. +fun toRetinaAwareIcon(image: BufferedImage): Icon { + val sysScale = JBUIScale.sysScale() + return object : Icon { + override fun paintIcon( + c: Component?, + g: Graphics, + x: Int, + y: Int, + ) { + if (isJreHiDPI) { + val newG = g.create(x, y, image.width, image.height) as Graphics2D + alignToInt(newG) + newG.scale(1.0 / sysScale, 1.0 / sysScale) + newG.drawImage(image, 0, 0, null) + newG.dispose() + } else { + g.drawImage(image, x, y, null) + } + } + + override fun getIconWidth(): Int = if (isJreHiDPI) (image.width / sysScale).toInt() else image.width + + override fun getIconHeight(): Int = if (isJreHiDPI) (image.height / sysScale).toInt() else image.height + + private val isJreHiDPI: Boolean + get() = JreHiDpiUtil.isJreHiDPI(sysScale) + + override fun toString(): String = "TemplateIconDownloader.toRetinaAwareIcon for $image" + } +} diff --git a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt deleted file mode 100644 index 990b4f435..000000000 --- a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.coder.gateway.models - -data class CoderWorkspacesWizardModel( - var coderURL: String = "https://localhost", - var token: String = "", - var buildVersion: String = "", - var workspaceAgents: List = mutableListOf(), - var selectedWorkspace: WorkspaceAgentModel? = null -) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt index c6cad9c20..ba2eb7198 100644 --- a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt +++ b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt @@ -3,17 +3,27 @@ package com.coder.gateway.models import com.intellij.openapi.components.BaseState import com.intellij.util.xmlb.annotations.Attribute -class RecentWorkspaceConnection() : BaseState(), Comparable { - constructor(hostname: String, prjPath: String, openedAt: String, productCode: String, buildNumber: String, source: String, terminalLink: String) : this() { - coderWorkspaceHostname = hostname - projectPath = prjPath - lastOpened = openedAt - ideProductCode = productCode - ideBuildNumber = buildNumber - downloadSource = source - webTerminalLink = terminalLink - } - +/** + * A workspace, project, and IDE. + * + * This is read from a file so values could be missing, and names must not be + * changed to maintain backwards compatibility. + */ +class RecentWorkspaceConnection( + coderWorkspaceHostname: String? = null, + projectPath: String? = null, + lastOpened: String? = null, + ideProductCode: String? = null, + ideBuildNumber: String? = null, + downloadSource: String? = null, + idePathOnHost: String? = null, + // webTerminalLink and configDirectory are deprecated by deploymentURL. + webTerminalLink: String? = null, + configDirectory: String? = null, + name: String? = null, + deploymentURL: String? = null, +) : BaseState(), + Comparable { @get:Attribute var coderWorkspaceHostname by string() @@ -32,9 +42,39 @@ class RecentWorkspaceConnection() : BaseState(), Comparable() fun add(connection: RecentWorkspaceConnection): Boolean { - // if the item is already there but with a different last update timestamp, remove it + // If the item is already there but with a different last updated + // timestamp or config directory, remove it. recentConnections.remove(connection) - // and add it again with the new timestamp val result = recentConnections.add(connection) if (result) incrementModificationCount() return result @@ -21,4 +24,4 @@ class RecentWorkspaceConnectionState : BaseState() { if (result) incrementModificationCount() return result } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentListModel.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentListModel.kt new file mode 100644 index 000000000..f7b94da14 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentListModel.kt @@ -0,0 +1,22 @@ +package com.coder.gateway.models + +import com.coder.gateway.sdk.v2.models.Workspace +import com.coder.gateway.sdk.v2.models.WorkspaceAgent +import javax.swing.Icon + +// This represents a single row in the flattened agent list. It is either an +// agent with its associated workspace or a workspace with no agents, in which +// case it acts as a placeholder for performing actions on the workspace but +// cannot be connected to. +data class WorkspaceAgentListModel( + val workspace: Workspace, + // If this is missing, assume the workspace is off or has no agents. + val agent: WorkspaceAgent? = null, + // The icon of the template from which this workspace was created. + var icon: Icon? = null, + // The combined status of the workspace and agent to display on the row. + val status: WorkspaceAndAgentStatus = WorkspaceAndAgentStatus.from(workspace, agent), + // The combined `workspace.agent` name to display on the row. Users can have workspaces with the same name, so it + // must not be used as a unique identifier. + val name: String = if (agent != null) "${workspace.name}.${agent.name}" else workspace.name, +) diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt deleted file mode 100644 index 890146bd6..000000000 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.coder.gateway.models - -import com.coder.gateway.sdk.Arch -import com.coder.gateway.sdk.OS -import com.coder.gateway.sdk.v2.models.ProvisionerJobStatus -import com.coder.gateway.sdk.v2.models.WorkspaceBuildTransition - -data class WorkspaceAgentModel( - val name: String, - val templateName: String, - - val jobStatus: ProvisionerJobStatus, - val buildTransition: WorkspaceBuildTransition, - - val agentOS: OS?, - val agentArch: Arch?, - val homeDirectory: String? -) diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt new file mode 100644 index 000000000..601a02b90 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt @@ -0,0 +1,130 @@ +package com.coder.gateway.models + +import com.coder.gateway.sdk.v2.models.Workspace +import com.coder.gateway.sdk.v2.models.WorkspaceAgent +import com.coder.gateway.sdk.v2.models.WorkspaceAgentLifecycleState +import com.coder.gateway.sdk.v2.models.WorkspaceAgentStatus +import com.coder.gateway.sdk.v2.models.WorkspaceStatus +import com.intellij.ui.JBColor + +/** + * WorkspaceAndAgentStatus represents the combined status of a single agent and + * its workspace (or just the workspace if there are no agents). + */ +enum class WorkspaceAndAgentStatus(val label: String, val description: String) { + // Workspace states. + QUEUED("Queued", "The workspace is queueing to start."), + STARTING("Starting", "The workspace is starting."), + FAILED("Failed", "The workspace has failed to start."), + DELETING("Deleting", "The workspace is being deleted."), + DELETED("Deleted", "The workspace has been deleted."), + STOPPING("Stopping", "The workspace is stopping."), + STOPPED("Stopped", "The workspace has stopped."), + CANCELING("Canceling action", "The workspace is being canceled."), + CANCELED("Canceled action", "The workspace has been canceled."), + RUNNING("Running", "The workspace is running, waiting for agents."), + + // Agent states. + CONNECTING("Connecting", "The agent is connecting."), + DISCONNECTED("Disconnected", "The agent has disconnected."), + TIMEOUT("Timeout", "The agent is taking longer than expected to connect."), + AGENT_STARTING("Starting", "The startup script is running."), + AGENT_STARTING_READY( + "Starting", + "The startup script is still running but the agent is ready to accept connections.", + ), + CREATED("Created", "The agent has been created."), + START_ERROR("Started with error", "The agent is ready but the startup script errored."), + START_TIMEOUT("Starting", "The startup script is taking longer than expected."), + START_TIMEOUT_READY( + "Starting", + "The startup script is taking longer than expected but the agent is ready to accept connections.", + ), + SHUTTING_DOWN("Shutting down", "The agent is shutting down."), + SHUTDOWN_ERROR("Shutdown with error", "The agent shut down but the shutdown script errored."), + SHUTDOWN_TIMEOUT("Shutting down", "The shutdown script is taking longer than expected."), + OFF("Off", "The agent has shut down."), + READY("Ready", "The agent is ready to accept connections."), + ; + + fun statusColor(): JBColor = when (this) { + READY, AGENT_STARTING_READY, START_TIMEOUT_READY -> JBColor.GREEN + CREATED, START_ERROR, START_TIMEOUT, SHUTDOWN_TIMEOUT -> JBColor.YELLOW + FAILED, DISCONNECTED, TIMEOUT, SHUTDOWN_ERROR -> JBColor.RED + else -> if (JBColor.isBright()) JBColor.LIGHT_GRAY else JBColor.DARK_GRAY + } + + /** + * Return true if the agent is in a connectable state. + */ + fun ready(): Boolean { + // It seems that the agent can get stuck in a `created` state if the + // workspace is updated and the agent is restarted (presumably because + // lifecycle scripts are not running again). This feels like either a + // Coder or template bug, but `coder ssh` and the VS Code plugin will + // still connect so do the same here to not be the odd one out. + return listOf(READY, START_ERROR, AGENT_STARTING_READY, START_TIMEOUT_READY, CREATED) + .contains(this) + } + + /** + * Return true if the agent might soon be in a connectable state. + */ + fun pending(): Boolean { + // See ready() for why `CREATED` is not in this list. + return listOf(CONNECTING, TIMEOUT, AGENT_STARTING, START_TIMEOUT) + .contains(this) + } + + // We want to check that the workspace is `running`, the agent is + // `connected`, and the agent lifecycle state is `ready` to ensure the best + // possible scenario for attempting a connection. + // + // We can also choose to allow `start_error` for the agent lifecycle state; + // this means the startup script did not successfully complete but the agent + // will still accept SSH connections. + // + // Lastly we can also allow connections when the agent lifecycle state is + // `starting` or `start_timeout` if `login_before_ready` is true on the + // workspace response since this bypasses the need to wait for the script. + // + // Note that latest_build.status is derived from latest_build.job.status and + // latest_build.job.transition so there is no need to check those. + companion object { + fun from( + workspace: Workspace, + agent: WorkspaceAgent? = null, + ) = when (workspace.latestBuild.status) { + WorkspaceStatus.PENDING -> QUEUED + WorkspaceStatus.STARTING -> STARTING + WorkspaceStatus.RUNNING -> + when (agent?.status) { + WorkspaceAgentStatus.CONNECTED -> + when (agent.lifecycleState) { + WorkspaceAgentLifecycleState.CREATED -> CREATED + WorkspaceAgentLifecycleState.STARTING -> if (agent.loginBeforeReady == true) AGENT_STARTING_READY else AGENT_STARTING + WorkspaceAgentLifecycleState.START_TIMEOUT -> if (agent.loginBeforeReady == true) START_TIMEOUT_READY else START_TIMEOUT + WorkspaceAgentLifecycleState.START_ERROR -> START_ERROR + WorkspaceAgentLifecycleState.READY -> READY + WorkspaceAgentLifecycleState.SHUTTING_DOWN -> SHUTTING_DOWN + WorkspaceAgentLifecycleState.SHUTDOWN_TIMEOUT -> SHUTDOWN_TIMEOUT + WorkspaceAgentLifecycleState.SHUTDOWN_ERROR -> SHUTDOWN_ERROR + WorkspaceAgentLifecycleState.OFF -> OFF + } + + WorkspaceAgentStatus.DISCONNECTED -> DISCONNECTED + WorkspaceAgentStatus.TIMEOUT -> TIMEOUT + WorkspaceAgentStatus.CONNECTING -> CONNECTING + else -> RUNNING + } + + WorkspaceStatus.STOPPING -> STOPPING + WorkspaceStatus.STOPPED -> STOPPED + WorkspaceStatus.FAILED -> FAILED + WorkspaceStatus.CANCELING -> CANCELING + WorkspaceStatus.CANCELED -> CANCELED + WorkspaceStatus.DELETING -> DELETING + WorkspaceStatus.DELETED -> DELETED + } + } +} diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt new file mode 100644 index 000000000..d1d33b08d --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceProjectIDE.kt @@ -0,0 +1,255 @@ +package com.coder.gateway.models + +import com.intellij.openapi.diagnostic.Logger +import com.jetbrains.gateway.ssh.AvailableIde +import com.jetbrains.gateway.ssh.IdeStatus +import com.jetbrains.gateway.ssh.IdeWithStatus +import com.jetbrains.gateway.ssh.InstalledIdeUIEx +import com.jetbrains.gateway.ssh.IntelliJPlatformProduct +import com.jetbrains.gateway.ssh.ReleaseType +import com.jetbrains.gateway.ssh.deploy.ShellArgument +import java.net.URL +import java.nio.file.Path +import kotlin.io.path.name + +private val NON_STABLE_RELEASE_TYPES = setOf("EAP", "RC", "NIGHTLY", "PREVIEW") + +/** + * Validated parameters for downloading and opening a project using an IDE on a + * workspace. + */ +data class WorkspaceProjectIDE( + // Either `workspace.agent` for old connections or `user/workspace.agent` + // for new connections. + val name: String, + val hostname: String, + val projectPath: String, + val ideProduct: IntelliJPlatformProduct, + val ideBuildNumber: String, + // One of these must exist; enforced by the constructor. + var idePathOnHost: String?, + val downloadSource: String?, + // These are used in the recent connections window. + val deploymentURL: URL, + var lastOpened: String?, // Null if never opened. +) { + val ideName = "${ideProduct.productCode}-$ideBuildNumber" + + private val maxDisplayLength = 35 + + /** + * A shortened path for displaying where space is tight. + */ + val projectPathDisplay = + if (projectPath.length <= maxDisplayLength) { + projectPath + } else { + "…" + projectPath.substring(projectPath.length - maxDisplayLength, projectPath.length) + } + + init { + if (idePathOnHost.isNullOrBlank() && downloadSource.isNullOrBlank()) { + throw Exception("A path to the IDE on the host or a download source is required") + } + } + + /** + * Convert parameters into a recent workspace connection (for storage). + */ + fun toRecentWorkspaceConnection(): RecentWorkspaceConnection = RecentWorkspaceConnection( + name = name, + coderWorkspaceHostname = hostname, + projectPath = projectPath, + ideProductCode = ideProduct.productCode, + ideBuildNumber = ideBuildNumber, + downloadSource = downloadSource, + idePathOnHost = idePathOnHost, + deploymentURL = deploymentURL.toString(), + lastOpened = lastOpened, + ) + + companion object { + val logger = Logger.getInstance(WorkspaceProjectIDE::class.java.simpleName) + + /** + * Create from unvalidated user inputs. + */ + @JvmStatic + fun fromInputs( + name: String?, + hostname: String?, + projectPath: String?, + deploymentURL: String?, + lastOpened: String?, + ideProductCode: String?, + ideBuildNumber: String?, + downloadSource: String?, + idePathOnHost: String?, + ): WorkspaceProjectIDE { + if (name.isNullOrBlank()) { + throw Exception("Workspace name is missing") + } else if (deploymentURL.isNullOrBlank()) { + throw Exception("Deployment URL is missing") + } else if (hostname.isNullOrBlank()) { + throw Exception("Host name is missing") + } else if (projectPath.isNullOrBlank()) { + throw Exception("Project path is missing") + } else if (ideProductCode.isNullOrBlank()) { + throw Exception("IDE product code is missing") + } else if (ideBuildNumber.isNullOrBlank()) { + throw Exception("IDE build number is missing") + } + + return WorkspaceProjectIDE( + name = name, + hostname = hostname, + projectPath = projectPath, + ideProduct = IntelliJPlatformProduct.fromProductCode(ideProductCode) + ?: throw Exception("invalid product code"), + ideBuildNumber = ideBuildNumber, + idePathOnHost = idePathOnHost, + downloadSource = downloadSource, + deploymentURL = URL(deploymentURL), + lastOpened = lastOpened, + ) + } + } +} + +/** + * Convert into parameters for making a connection to a project using an IDE + * on a workspace. Throw if invalid. + */ +fun RecentWorkspaceConnection.toWorkspaceProjectIDE(): WorkspaceProjectIDE { + val hostname = coderWorkspaceHostname + + @Suppress("DEPRECATION") + val dir = configDirectory + return WorkspaceProjectIDE.fromInputs( + // The name was added to query the workspace status on the recent + // connections page, so it could be missing. Try to get it from the + // host name. + name = + if (name.isNullOrBlank() && !hostname.isNullOrBlank()) { + hostname + .removePrefix("coder-jetbrains--") + .removeSuffix("--${hostname.split("--").last()}") + } else { + name + }, + hostname = hostname, + projectPath = projectPath, + ideProductCode = ideProductCode, + ideBuildNumber = ideBuildNumber, + idePathOnHost = idePathOnHost, + downloadSource = downloadSource, + // The deployment URL was added to replace storing the web terminal link + // and config directory, as we can construct both from the URL and the + // config directory might not always exist (for example, authentication + // might happen with mTLS, and we can skip login which normally creates + // the config directory). For backwards compatibility with existing + // entries, extract the URL from the config directory or host name. + deploymentURL = + if (deploymentURL.isNullOrBlank()) { + if (!dir.isNullOrBlank()) { + "https://${Path.of(dir).parent.name}" + } else if (!hostname.isNullOrBlank()) { + "https://${hostname.split("--").last()}" + } else { + deploymentURL + } + } else { + deploymentURL + }, + lastOpened = lastOpened, + ) +} + +/** + * Convert an IDE into parameters for making a connection to a project using + * that IDE on a workspace. Throw if invalid. + */ +fun IdeWithStatus.withWorkspaceProject( + name: String, + hostname: String, + projectPath: String, + deploymentURL: URL, +): WorkspaceProjectIDE = WorkspaceProjectIDE( + name = name, + hostname = hostname, + projectPath = projectPath, + ideProduct = this.product, + ideBuildNumber = this.buildNumber, + downloadSource = this.download?.link, + idePathOnHost = this.pathOnHost, + deploymentURL = deploymentURL, + lastOpened = null, +) + +/** + * Convert an available IDE to an IDE with status. + */ +fun AvailableIde.toIdeWithStatus(): IdeWithStatus = IdeWithStatus( + product = product, + buildNumber = buildNumber, + status = IdeStatus.DOWNLOAD, + download = download, + pathOnHost = null, + presentableVersion = presentableVersion, + remoteDevType = remoteDevType, +) + +/** + * Returns a list of installed IDEs that don't have a RELEASED version available for download. + * Typically, installed EAP, RC, nightly or preview builds should be superseded by released versions. + */ +fun List.filterOutAvailableReleasedIdes(availableIde: List): List { + val availableReleasedByProductCode = availableIde + .filter { it.releaseType == ReleaseType.RELEASE } + .groupBy { it.product.productCode } + val result = mutableListOf() + + this.forEach { installedIde -> + // installed IDEs have the release type embedded in the presentable version + // which is a string in the form: 2024.2.4 NIGHTLY + if (NON_STABLE_RELEASE_TYPES.any { it in installedIde.presentableVersion }) { + // we can show the installed IDe if there isn't a higher released version available for download + if (installedIde.isSNotSupersededBy(availableReleasedByProductCode[installedIde.product.productCode])) { + result.add(installedIde) + } + } else { + result.add(installedIde) + } + } + + return result +} + +private fun InstalledIdeUIEx.isSNotSupersededBy(availableIdes: List?): Boolean { + if (availableIdes.isNullOrEmpty()) { + return true + } + return !availableIdes.any { it.buildNumber >= this.buildNumber } +} + +/** + * Convert an installed IDE to an IDE with status. + */ +fun InstalledIdeUIEx.toIdeWithStatus(): IdeWithStatus = IdeWithStatus( + product = product, + buildNumber = buildNumber, + status = IdeStatus.ALREADY_INSTALLED, + download = null, + pathOnHost = pathToIde, + presentableVersion = presentableVersion, + remoteDevType = remoteDevType, +) + +val remotePathRe = Regex("^[^(]+\\((.+)\\)$") + +fun ShellArgument.RemotePath.toRawString(): String { + // TODO: Surely there is an actual way to do this. + val remotePath = flatten().toString() + return remotePathRe.find(remotePath)?.groupValues?.get(1) + ?: throw Exception("Got invalid path $remotePath") +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIDownloader.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIDownloader.kt deleted file mode 100644 index 5f17e7ae4..000000000 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIDownloader.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.coder.gateway.sdk - -import com.intellij.openapi.diagnostic.Logger -import java.io.InputStream -import java.net.URL -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.nio.file.StandardCopyOption - -class CoderCLIDownloader(private val buildVersion: String) { - - fun downloadCLI(url: URL, outputName: String, ext: String): Path { - val filename = if (ext.isBlank()) "${outputName}-$buildVersion" else "${outputName}-${buildVersion}.${ext}" - val cliPath = Paths.get(System.getProperty("java.io.tmpdir"), filename) - if (Files.exists(cliPath)) { - logger.info("${cliPath.toAbsolutePath()} already exists, skipping download") - return cliPath - } - logger.info("Starting Coder CLI download to ${cliPath.toAbsolutePath()}") - url.openStream().use { - Files.copy(it as InputStream, cliPath, StandardCopyOption.REPLACE_EXISTING) - } - return cliPath - } - - companion object { - val logger = Logger.getInstance(CoderCLIDownloader::class.java.simpleName) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt deleted file mode 100644 index 85a0b52fa..000000000 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.coder.gateway.sdk - -import com.intellij.openapi.diagnostic.Logger -import java.net.URL -import java.nio.file.Path - -class CoderCLIManager(private val url: URL, buildVersion: String) { - private val coderCLIDownloader = CoderCLIDownloader(buildVersion) - - fun download(): Path? { - val os = getOS() - val cliName = getCoderCLIForOS(os, getArch()) ?: return null - val cliNameWitExt = if (os == OS.WINDOWS) "$cliName.exe" else cliName - return coderCLIDownloader.downloadCLI(URL(url.protocol, url.host, url.port, "/bin/$cliNameWitExt"), cliName, if (os == OS.WINDOWS) "exe" else "") - } - - private fun getCoderCLIForOS(os: OS?, arch: Arch?): String? { - logger.info("Resolving coder cli for $os $arch") - if (os == null) { - return null - } - return when (os) { - OS.WINDOWS -> when (arch) { - Arch.AMD64 -> "coder-windows-amd64" - Arch.ARM64 -> "coder-windows-arm64" - else -> "coder-windows-amd64" - } - OS.LINUX -> when (arch) { - Arch.AMD64 -> "coder-linux-amd64" - Arch.ARM64 -> "coder-linux-arm64" - Arch.ARMV7 -> "coder-linux-armv7" - else -> "coder-linux-amd64" - } - OS.MAC -> when (arch) { - Arch.AMD64 -> "coder-darwin-amd64" - Arch.ARM64 -> "coder-darwin-arm64" - else -> "coder-darwin-amd64" - } - } - } - - companion object { - val logger = Logger.getInstance(CoderCLIManager::class.java.simpleName) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt new file mode 100644 index 000000000..3224f517c --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClient.kt @@ -0,0 +1,309 @@ +package com.coder.gateway.sdk + +import com.coder.gateway.icons.CoderIcons +import com.coder.gateway.icons.toRetinaAwareIcon +import com.coder.gateway.sdk.convertors.ArchConverter +import com.coder.gateway.sdk.convertors.InstantConverter +import com.coder.gateway.sdk.convertors.OSConverter +import com.coder.gateway.sdk.convertors.UUIDConverter +import com.coder.gateway.sdk.ex.APIResponseException +import com.coder.gateway.sdk.v2.CoderV2RestFacade +import com.coder.gateway.sdk.v2.models.BuildInfo +import com.coder.gateway.sdk.v2.models.CreateWorkspaceBuildRequest +import com.coder.gateway.sdk.v2.models.Template +import com.coder.gateway.sdk.v2.models.User +import com.coder.gateway.sdk.v2.models.Workspace +import com.coder.gateway.sdk.v2.models.WorkspaceAgent +import com.coder.gateway.sdk.v2.models.WorkspaceBuild +import com.coder.gateway.sdk.v2.models.WorkspaceBuildReason +import com.coder.gateway.sdk.v2.models.WorkspaceResource +import com.coder.gateway.sdk.v2.models.WorkspaceStatus +import com.coder.gateway.sdk.v2.models.WorkspaceTransition +import com.coder.gateway.settings.CoderSettings +import com.coder.gateway.settings.CoderSettingsState +import com.coder.gateway.util.CoderHostnameVerifier +import com.coder.gateway.util.coderSocketFactory +import com.coder.gateway.util.coderTrustManagers +import com.coder.gateway.util.getArch +import com.coder.gateway.util.getHeaders +import com.coder.gateway.util.getOS +import com.coder.gateway.util.toURL +import com.coder.gateway.util.withPath +import com.intellij.util.ImageLoader +import com.intellij.util.ui.ImageUtil +import com.squareup.moshi.Moshi +import okhttp3.Credentials +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.imgscalr.Scalr +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.net.HttpURLConnection +import java.net.ProxySelector +import java.net.URL +import java.util.UUID +import javax.net.ssl.X509TrustManager +import javax.swing.Icon + +/** + * Holds proxy information. + */ +data class ProxyValues( + val username: String?, + val password: String?, + val useAuth: Boolean, + val selector: ProxySelector, +) + +/** + * An HTTP client that can make requests to the Coder API. + * + * The token can be omitted if some other authentication mechanism is in use. + */ +open class CoderRestClient( + val url: URL, + val token: String?, + private val settings: CoderSettings = CoderSettings(CoderSettingsState()), + private val proxyValues: ProxyValues? = null, + private val pluginVersion: String = "development", + existingHttpClient: OkHttpClient? = null, +) { + private val httpClient: OkHttpClient + private val retroRestClient: CoderV2RestFacade + + lateinit var me: User + lateinit var buildVersion: String + + init { + val moshi = + Moshi.Builder() + .add(ArchConverter()) + .add(InstantConverter()) + .add(OSConverter()) + .add(UUIDConverter()) + .build() + + val socketFactory = coderSocketFactory(settings.tls) + val trustManagers = coderTrustManagers(settings.tls.caPath) + var builder = existingHttpClient?.newBuilder() ?: OkHttpClient.Builder() + + if (proxyValues != null) { + builder = + builder + .proxySelector(proxyValues.selector) + .proxyAuthenticator { _, response -> + if (proxyValues.useAuth && proxyValues.username != null && proxyValues.password != null) { + val credentials = Credentials.basic(proxyValues.username, proxyValues.password) + response.request.newBuilder() + .header("Proxy-Authorization", credentials) + .build() + } else { + null + } + } + } + + if (token != null) { + builder = builder.addInterceptor { it.proceed(it.request().newBuilder().addHeader("Coder-Session-Token", token).build()) } + } + + httpClient = + builder + .sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) + .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) + .addInterceptor { + it.proceed( + it.request().newBuilder().addHeader( + "User-Agent", + "Coder Gateway/$pluginVersion (${getOS()}; ${getArch()})", + ).build(), + ) + } + .addInterceptor { + var request = it.request() + val headers = getHeaders(url, settings.headerCommand) + if (headers.isNotEmpty()) { + val reqBuilder = request.newBuilder() + headers.forEach { h -> reqBuilder.addHeader(h.key, h.value) } + request = reqBuilder.build() + } + it.proceed(request) + } + // This should always be last if we want to see previous interceptors logged. + .addInterceptor(HttpLoggingInterceptor().apply { setLevel(HttpLoggingInterceptor.Level.BASIC) }) + .build() + + retroRestClient = + Retrofit.Builder().baseUrl(url.toString()).client(httpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build().create(CoderV2RestFacade::class.java) + } + + /** + * Authenticate and load information about the current user and the build + * version. + * + * @throws [APIResponseException]. + */ + fun authenticate(): User { + me = me() + buildVersion = buildInfo().version + return me + } + + /** + * Retrieve the current user. + * @throws [APIResponseException]. + */ + fun me(): User { + val userResponse = retroRestClient.me().execute() + if (!userResponse.isSuccessful) { + throw APIResponseException("authenticate", url, userResponse) + } + + return userResponse.body()!! + } + + /** + * Retrieves the available workspaces created by the user. + * @throws [APIResponseException]. + */ + fun workspaces(): List { + val workspacesResponse = retroRestClient.workspaces(settings.workspaceFilter).execute() + if (!workspacesResponse.isSuccessful) { + throw APIResponseException("retrieve workspaces", url, workspacesResponse) + } + + return workspacesResponse.body()!!.workspaces + } + + /** + * Retrieves a specific workspace by owner and name. + * @throws [APIResponseException]. + */ + fun workspaceByOwnerAndName(owner: String, workspaceName: String): Workspace { + val workspaceResponse = retroRestClient.workspaceByOwnerAndName(owner, workspaceName).execute() + if (!workspaceResponse.isSuccessful) { + throw APIResponseException("retrieve workspace", url, workspaceResponse) + } + + return workspaceResponse.body()!! + } + + /** + * Retrieves all the agent names for all workspaces, including those that + * are off. Meant to be used when configuring SSH. + */ + fun withAgents(workspaces: List): Set> { + // It is possible for there to be resources with duplicate names so we + // need to use a set. + return workspaces.flatMap { ws -> + when (ws.latestBuild.status) { + WorkspaceStatus.RUNNING -> ws.latestBuild.resources + else -> resources(ws) + }.filter { it.agents != null }.flatMap { it.agents!! }.map { + ws to it + } + }.toSet() + } + + /** + * Retrieves resources for the specified workspace. The workspaces response + * does not include agents when the workspace is off so this can be used to + * get them instead, just like `coder config-ssh` does (otherwise we risk + * removing hosts from the SSH config when they are off). + * @throws [APIResponseException]. + */ + fun resources(workspace: Workspace): List { + val resourcesResponse = retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID).execute() + if (!resourcesResponse.isSuccessful) { + throw APIResponseException("retrieve resources for ${workspace.name}", url, resourcesResponse) + } + return resourcesResponse.body()!! + } + + fun buildInfo(): BuildInfo { + val buildInfoResponse = retroRestClient.buildInfo().execute() + if (!buildInfoResponse.isSuccessful) { + throw APIResponseException("retrieve build information", url, buildInfoResponse) + } + return buildInfoResponse.body()!! + } + + /** + * @throws [APIResponseException]. + */ + private fun template(templateID: UUID): Template { + val templateResponse = retroRestClient.template(templateID).execute() + if (!templateResponse.isSuccessful) { + throw APIResponseException("retrieve template with ID $templateID", url, templateResponse) + } + return templateResponse.body()!! + } + + /** + * @throws [APIResponseException]. + */ + fun stopWorkspace(workspace: Workspace): WorkspaceBuild { + val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP, null) + val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() + if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { + throw APIResponseException("stop workspace ${workspace.name}", url, buildResponse) + } + return buildResponse.body()!! + } + + /** + * Start the workspace with the latest template version. Best practice is + * to STOP a workspace before doing an update if it is started. + * 1. If the update changes parameters, the old template might be needed to + * correctly STOP with the existing parameter values. + * 2. The agent gets a new ID and token on each START build. Many template + * authors are not diligent about making sure the agent gets restarted + * with this information when we do two START builds in a row. + * @throws [APIResponseException]. + */ + fun updateWorkspace(workspace: Workspace): WorkspaceBuild { + val template = template(workspace.templateID) + val buildRequest = + CreateWorkspaceBuildRequest( + template.activeVersionID, + WorkspaceTransition.START, + WorkspaceBuildReason.JETBRAINS_CONNECTION + ) + val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() + if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { + throw APIResponseException("update workspace ${workspace.name}", url, buildResponse) + } + return buildResponse.body()!! + } + + private val iconCache = mutableMapOf, Icon>() + + fun loadIcon( + path: String, + workspaceName: String, + ): Icon { + var iconURL: URL? = null + if (path.startsWith("http")) { + iconURL = path.toURL() + } else if (!path.contains(":") && !path.contains("//")) { + iconURL = url.withPath(path) + } + + if (iconURL != null) { + val cachedIcon = iconCache[Pair(workspaceName, path)] + if (cachedIcon != null) { + return cachedIcon + } + val img = ImageLoader.loadFromUrl(iconURL) + if (img != null) { + val icon = toRetinaAwareIcon(Scalr.resize(ImageUtil.toBufferedImage(img), Scalr.Method.ULTRA_QUALITY, 32)) + iconCache[Pair(workspaceName, path)] = icon + return icon + } + } + + return CoderIcons.fromChar(workspaceName.lowercase().first()) + } +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt deleted file mode 100644 index 0e37889ca..000000000 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.coder.gateway.sdk - -import com.coder.gateway.sdk.convertors.InstantConverter -import com.coder.gateway.sdk.ex.AuthenticationResponseException -import com.coder.gateway.sdk.ex.WorkspaceResourcesResponseException -import com.coder.gateway.sdk.ex.WorkspaceResponseException -import com.coder.gateway.sdk.v2.CoderV2RestFacade -import com.coder.gateway.sdk.v2.models.BuildInfo -import com.coder.gateway.sdk.v2.models.User -import com.coder.gateway.sdk.v2.models.Workspace -import com.coder.gateway.sdk.v2.models.WorkspaceAgent -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.intellij.openapi.components.Service -import okhttp3.Cookie -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.JavaNetCookieJar -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.net.CookieManager -import java.net.URL -import java.time.Instant - -@Service(Service.Level.APP) -class CoderRestClientService { - private lateinit var retroRestClient: CoderV2RestFacade - private lateinit var sessionToken: String - lateinit var coderURL: URL - lateinit var me: User - lateinit var buildVersion: String - - /** - * This must be called before anything else. It will authenticate with coder and retrieve a session token - * @throws [AuthenticationResponseException] if authentication failed. - */ - fun initClientSession(url: URL, token: String): User { - val cookieUrl = url.toHttpUrlOrNull()!! - val cookieJar = JavaNetCookieJar(CookieManager()).apply { - saveFromResponse( - cookieUrl, - listOf(Cookie.parse(cookieUrl, "session_token=$token")!!) - ) - } - val gson: Gson = GsonBuilder() - .registerTypeAdapter(Instant::class.java, InstantConverter()) - .setPrettyPrinting() - .create() - - val interceptor = HttpLoggingInterceptor() - interceptor.setLevel(HttpLoggingInterceptor.Level.BODY) - retroRestClient = Retrofit.Builder() - .baseUrl(url.toString()) - .client( - OkHttpClient.Builder() - .addInterceptor(interceptor) - .cookieJar(cookieJar) - .build() - ) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(CoderV2RestFacade::class.java) - - val userResponse = retroRestClient.me().execute() - if (!userResponse.isSuccessful) { - throw AuthenticationResponseException("Could not retrieve information about logged user:${userResponse.code()}, reason: ${userResponse.message()}") - } - - coderURL = url - sessionToken = token - me = userResponse.body()!! - buildVersion = buildInfo().version - - return me - } - - /** - * Retrieves the available workspaces created by the user. - * @throws WorkspaceResponseException if workspaces could not be retrieved. - */ - fun workspaces(): List { - val workspacesResponse = retroRestClient.workspaces("owner:me").execute() - if (!workspacesResponse.isSuccessful) { - throw WorkspaceResponseException("Could not retrieve Coder Workspaces:${workspacesResponse.code()}, reason: ${workspacesResponse.message()}") - } - - return workspacesResponse.body()!! - } - - private fun buildInfo(): BuildInfo { - val buildInfoResponse = retroRestClient.buildInfo().execute() - if (!buildInfoResponse.isSuccessful) { - throw java.lang.IllegalStateException("Could not retrieve build information for Coder instance $coderURL, reason:${buildInfoResponse.message()}") - } - return buildInfoResponse.body()!! - } - - /** - * Retrieves the workspace agents. A workspace is a collection of objects like, VMs, containers, cloud DBs, etc... - * Agents run on compute hosts like VMs or containers. - * - * @throws WorkspaceResourcesResponseException if workspace resources could not be retrieved. - */ - fun workspaceAgents(workspace: Workspace): List { - val workspaceResourcesResponse = retroRestClient.workspaceResourceByBuild(workspace.latestBuild.id).execute() - if (!workspaceResourcesResponse.isSuccessful) { - throw WorkspaceResourcesResponseException("Could not retrieve agents for ${workspace.name} workspace :${workspaceResourcesResponse.code()}, reason: ${workspaceResourcesResponse.message()}") - } - - return workspaceResourcesResponse.body()!!.flatMap { it.agents ?: emptyList() } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/sdk/URLExtensions.kt b/src/main/kotlin/com/coder/gateway/sdk/URLExtensions.kt deleted file mode 100644 index b8005476a..000000000 --- a/src/main/kotlin/com/coder/gateway/sdk/URLExtensions.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.coder.gateway.sdk - -import java.net.URL - - -fun String.toURL(): URL { - return URL(this) -} - -fun URL.withPath(path: String): URL { - return URL(this.protocol, this.host, this.port, path) -} diff --git a/src/main/kotlin/com/coder/gateway/sdk/convertors/ArchConverter.kt b/src/main/kotlin/com/coder/gateway/sdk/convertors/ArchConverter.kt new file mode 100644 index 000000000..1ebf4bf27 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/sdk/convertors/ArchConverter.kt @@ -0,0 +1,14 @@ +package com.coder.gateway.sdk.convertors + +import com.coder.gateway.util.Arch +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson + +/** + * Serializer/deserializer for converting [Arch] objects. + */ +class ArchConverter { + @ToJson fun toJson(src: Arch?): String = src?.toString() ?: "" + + @FromJson fun fromJson(src: String): Arch? = Arch.from(src) +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/convertors/InstantConverter.kt b/src/main/kotlin/com/coder/gateway/sdk/convertors/InstantConverter.kt index 9dbe58468..a1a9f0850 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/convertors/InstantConverter.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/convertors/InstantConverter.kt @@ -1,65 +1,22 @@ package com.coder.gateway.sdk.convertors -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import com.google.gson.JsonPrimitive -import com.google.gson.JsonSerializationContext -import com.google.gson.JsonSerializer -import java.lang.reflect.Type +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson import java.time.Instant import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAccessor /** - * GSON serialiser/deserialiser for converting [Instant] objects. + * Serializer/deserializer for converting [Instant] objects. */ -class InstantConverter : JsonSerializer, JsonDeserializer { - /** - * Gson invokes this call-back method during serialization when it encounters a field of the - * specified type. - * - * - * - * In the implementation of this call-back method, you should consider invoking - * [JsonSerializationContext.serialize] method to create JsonElements for any - * non-trivial field of the `src` object. However, you should never invoke it on the - * `src` object itself since that will cause an infinite loop (Gson will call your - * call-back method again). - * - * @param src the object that needs to be converted to Json. - * @param typeOfSrc the actual type (fully genericized version) of the source object. - * @return a JsonElement corresponding to the specified object. - */ - override fun serialize(src: Instant?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { - return JsonPrimitive(FORMATTER.format(src)) - } +class InstantConverter { + @ToJson fun toJson(src: Instant?): String = FORMATTER.format(src) - /** - * Gson invokes this call-back method during deserialization when it encounters a field of the - * specified type. - * - * - * - * In the implementation of this call-back method, you should consider invoking - * [JsonDeserializationContext.deserialize] method to create objects - * for any non-trivial field of the returned object. However, you should never invoke it on the - * the same type passing `json` since that will cause an infinite loop (Gson will call your - * call-back method again). - * - * @param json The Json data being deserialized - * @param typeOfT The type of the Object to deserialize to - * @return a deserialized object of the specified type typeOfT which is a subclass of `T` - * @throws JsonParseException if json is not in the expected format of `typeOfT` - */ - @Throws(JsonParseException::class) - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Instant { - return FORMATTER.parse(json.asString) { temporal: TemporalAccessor? -> Instant.from(temporal) } + @FromJson fun fromJson(src: String): Instant? = FORMATTER.parse(src) { temporal: TemporalAccessor? -> + Instant.from(temporal) } companion object { - /** Formatter. */ private val FORMATTER = DateTimeFormatter.ISO_INSTANT } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/convertors/OSConverter.kt b/src/main/kotlin/com/coder/gateway/sdk/convertors/OSConverter.kt new file mode 100644 index 000000000..7a5674e2a --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/sdk/convertors/OSConverter.kt @@ -0,0 +1,14 @@ +package com.coder.gateway.sdk.convertors + +import com.coder.gateway.util.OS +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson + +/** + * Serializer/deserializer for converting [OS] objects. + */ +class OSConverter { + @ToJson fun toJson(src: OS?): String = src?.toString() ?: "" + + @FromJson fun fromJson(src: String): OS? = OS.from(src) +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/convertors/UUIDConverter.kt b/src/main/kotlin/com/coder/gateway/sdk/convertors/UUIDConverter.kt new file mode 100644 index 000000000..2bab5e9e6 --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/sdk/convertors/UUIDConverter.kt @@ -0,0 +1,14 @@ +package com.coder.gateway.sdk.convertors + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import java.util.UUID + +/** + * Serializer/deserializer for converting [UUID] objects. + */ +class UUIDConverter { + @ToJson fun toJson(src: UUID): String = src.toString() + + @FromJson fun fromJson(src: String): UUID = UUID.fromString(src) +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/ex/APIResponseException.kt b/src/main/kotlin/com/coder/gateway/sdk/ex/APIResponseException.kt new file mode 100644 index 000000000..eceb972fa --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/sdk/ex/APIResponseException.kt @@ -0,0 +1,15 @@ +package com.coder.gateway.sdk.ex + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +class APIResponseException(action: String, url: URL, res: retrofit2.Response<*>) : + IOException( + "Unable to $action: url=$url, code=${res.code()}, details=${ + res.errorBody()?.charStream()?.use { + it.readText() + } ?: "no details provided"}", + ) { + val isUnauthorized = res.code() == HttpURLConnection.HTTP_UNAUTHORIZED +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/ex/exceptions.kt b/src/main/kotlin/com/coder/gateway/sdk/ex/exceptions.kt deleted file mode 100644 index 983611b02..000000000 --- a/src/main/kotlin/com/coder/gateway/sdk/ex/exceptions.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.coder.gateway.sdk.ex - -import java.io.IOException - -class AuthenticationResponseException(reason: String) : IOException(reason) - -class WorkspaceResponseException(reason: String) : IOException(reason) - -class WorkspaceResourcesResponseException(reason: String) : IOException(reason) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/sdk/os.kt b/src/main/kotlin/com/coder/gateway/sdk/os.kt deleted file mode 100644 index c8682b325..000000000 --- a/src/main/kotlin/com/coder/gateway/sdk/os.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.coder.gateway.sdk - -fun getOS(): OS? { - return OS.from(System.getProperty("os.name")) -} - -fun getArch(): Arch? { - return Arch.from(System.getProperty("os.arch").toLowerCase()) -} - -enum class OS { - WINDOWS, LINUX, MAC; - - companion object { - fun from(os: String): OS? { - return when { - os.contains("win", true) -> { - WINDOWS - } - - os.contains("nix", true) || os.contains("nux", true) || os.contains("aix", true) -> { - LINUX - } - - os.contains("mac", true) -> { - MAC - } - - else -> null - } - } - } -} - -enum class Arch { - AMD64, ARM64, ARMV7; - - companion object { - fun from(arch: String): Arch? { - return when { - arch.contains("amd64", true) || arch.contains("x86_64", true) -> AMD64 - arch.contains("arm64", true) || arch.contains("aarch64", true) -> ARM64 - arch.contains("armv7", true) -> ARMV7 - else -> null - } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/gateway/sdk/v2/CoderV2RestFacade.kt index 0fcaa20e7..81976ed89 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/v2/CoderV2RestFacade.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/v2/CoderV2RestFacade.kt @@ -1,32 +1,64 @@ package com.coder.gateway.sdk.v2 import com.coder.gateway.sdk.v2.models.BuildInfo +import com.coder.gateway.sdk.v2.models.CreateWorkspaceBuildRequest +import com.coder.gateway.sdk.v2.models.Template import com.coder.gateway.sdk.v2.models.User import com.coder.gateway.sdk.v2.models.Workspace +import com.coder.gateway.sdk.v2.models.WorkspaceBuild import com.coder.gateway.sdk.v2.models.WorkspaceResource +import com.coder.gateway.sdk.v2.models.WorkspacesResponse import retrofit2.Call +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query import java.util.UUID interface CoderV2RestFacade { - /** * Retrieves details about the authenticated user. */ @GET("api/v2/users/me") fun me(): Call + /** + * Retrieves a specific workspace by owner and name. + */ + @GET("api/v2/users/{user}/workspace/{workspace}") + fun workspaceByOwnerAndName( + @Path("user") user: String, + @Path("workspace") workspace: String, + ): Call + /** * Retrieves all workspaces the authenticated user has access to. */ @GET("api/v2/workspaces") - fun workspaces(@Query("q") searchParams: String): Call> + fun workspaces( + @Query("q") searchParams: String, + ): Call @GET("api/v2/buildinfo") fun buildInfo(): Call - @GET("api/v2/workspacebuilds/{buildID}/resources") - fun workspaceResourceByBuild(@Path("buildID") build: UUID): Call> -} \ No newline at end of file + /** + * Queues a new build to occur for a workspace. + */ + @POST("api/v2/workspaces/{workspaceID}/builds") + fun createWorkspaceBuild( + @Path("workspaceID") workspaceID: UUID, + @Body createWorkspaceBuildRequest: CreateWorkspaceBuildRequest, + ): Call + + @GET("api/v2/templates/{templateID}") + fun template( + @Path("templateID") templateID: UUID, + ): Call