diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a55c794..cc1d4003 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,9 +20,9 @@ jobs: - windows-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v5 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v5 with: distribution: zulu java-version: 21 @@ -35,7 +35,7 @@ jobs: # Collect Tests Result of failed tests - if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: tests-result path: ${{ github.workspace }}/build/reports/tests @@ -50,11 +50,11 @@ jobs: steps: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 # Setup Java 21 environment for the next steps - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 21 @@ -79,13 +79,13 @@ jobs: # Store already-built plugin as an artifact for downloading - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: zip-artifacts path: ./build/distributions/*.zip - name: Upload Release Notes - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-notes path: RELEASE_NOTES.md @@ -101,7 +101,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5 # Remove old release drafts by using GitHub CLI - name: Remove Old Release Drafts @@ -113,7 +113,7 @@ jobs: | xargs -I '{}' gh api -X DELETE repos/${{ github.repository }}/releases/{} - name: Download Build Artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: zip-artifacts path: artifacts/ @@ -121,7 +121,7 @@ jobs: run: ls -R artifacts/ - name: Download Release Notes - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: release-notes path: notes/ diff --git a/.github/workflows/jetbrains-compliance.yml b/.github/workflows/jetbrains-compliance.yml new file mode 100644 index 00000000..40c2421f --- /dev/null +++ b/.github/workflows/jetbrains-compliance.yml @@ -0,0 +1,68 @@ +name: JetBrains Auto-Approval Compliance + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + compliance-check: + runs-on: ubuntu-latest + name: JetBrains Compliance Linting + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Run JetBrains Compliance Checks + run: | + echo "Running JetBrains auto-approval compliance checks with detekt..." + ./gradlew detekt + + - name: Upload detekt reports + uses: actions/upload-artifact@v5 + if: always() + with: + name: detekt-reports + path: | + build/reports/detekt/ + retention-days: 30 + + - name: Comment PR with compliance status + if: github.event_name == 'pull_request' && failure() + uses: actions/github-script@v8 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '⚠️ **JetBrains Auto-Approval Compliance Check Failed**\n\n' + + 'This PR contains code that violates JetBrains auto-approval requirements:\n\n' + + '- ❌ Do **not** use forbidden Kotlin experimental APIs\n' + + '- ❌ Do **not** add lambdas, handlers, or class handles to Java runtime hooks\n' + + '- ❌ Do **not** create threads manually (use coroutines or ensure cleanup in `CoderRemoteProvider#close()`)\n' + + '- ❌ Do **not** bundle libraries already provided by Toolbox\n' + + '- ❌ Do **not** perform ill-intentioned actions\n\n' + + 'Please check the workflow logs for detailed violations and fix them before merging.' + }) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e10f2d68..6918c4e6 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@v4.2.2 + uses: actions/checkout@v5 with: ref: ${{ github.event.release.tag_name }} # Setup Java 21 environment for the next steps - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: zulu java-version: 21 @@ -61,11 +61,11 @@ jobs: git config user.name "GitHub Action" git checkout -b $BRANCH - git commit -am "Changelog update - $VERSION" + git commit -am "chore: update changelog for $VERSION" git push --set-upstream origin $BRANCH gh pr create \ --title "Changelog update - \`$VERSION\`" \ --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ --base main \ - --head $BRANCH \ No newline at end of file + --head $BRANCH diff --git a/CHANGELOG.md b/CHANGELOG.md index 669afa11..40ad0740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,260 @@ ## Unreleased +### Added + +- application name can now be displayed as the main title page instead of the URL + +### Changed + +- workspaces are now started with the help of the CLI + +## 0.7.2 - 2025-11-03 + +### Changed + +- URI handling no longer waits for confirmation to use latest build if the provided build number is too old + +### Fixed + +- IDE is now launched when URI is handled by an already running Toolbox instance. + +## 0.7.1 - 2025-10-13 + +### Fixed + +- potential race condition that could cause crashes when settings are modified concurrently +- CLI download on some Windows versions + +## 0.7.0 - 2025-09-27 + +### Changed + +- simplified storage for last used url and token + +## 0.6.6 - 2025-09-24 + +### Changed + +- workspaces can no longer be removed by accident - users are now required to input the workspace name. + +### Fixed + +- relaxed SNI hostname resolution + +## 0.6.5 - 2025-09-16 + +### Fixed + +- token is no longer required when authentication is done via certificates +- errors while running actions are now reported + +## 0.6.4 - 2025-09-03 + +### Added + +- improved diagnose support + +### Fixed + +- NPE during error reporting +- relaxed `Content-Type` checks while downloading the CLI + +## 0.6.3 - 2025-08-25 + +### Added + +- progress reporting while handling URIs + +### Changed + +- workspaces status is now refresh every time Coder Toolbox becomes visible + +### Fixed + +- support for downloading the CLI when proxy is configured + +## 0.6.2 - 2025-08-14 + +### Changed + +- content-type is now enforced when downloading the CLI to accept only binary responses + +## 0.6.1 - 2025-08-11 + +### Added + +- support for skipping CLI signature verification + +### Changed + +- URL validation is stricter in the connection screen and URI protocol handler +- support for verbose logging a sanitized version of the REST API request and responses + +### Fixed + +- remote IDE reconnects automatically after plugin upgrade + +## 0.6.0 - 2025-07-25 + +### Changed + +- improved workflow when network connection is flaky + +## 0.5.2 - 2025-07-22 + +### Fixed + +- fix class cast exception during signature verification +- the correct CLI signature for Windows is now downloaded + +## 0.5.1 - 2025-07-21 + +### Added + +- support for certificate based authentication + +## 0.5.0 - 2025-07-17 + +### Added + +- support for matching workspace agent in the URI via the agent name +- support for checking if CLI is signed + +### Removed + +- dropped support for `agent_id` as a URI parameter + +## 0.4.0 - 2025-07-08 + +### Added + +- support for basic authentication for HTTP/HTTPS proxy +- support for Toolbox 2.7 release + +### Changed + +- improved message while loading the workspace + +### Fixed + +- URI protocol handler is now able to switch to the Coder provider even if the last opened provider was something else + +## 0.3.2 - 2025-06-25 + +### Changed + +- the logos and icons now match the new branding + +## 0.3.1 - 2025-06-19 + +### Added + +- visual text progress during Coder CLI downloading + +### Changed + +- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically + establish it after an expired token was refreshed. + +### Fixed + +- `Stop` action is now available for running workspaces that have an out of date template. +- outdated and stopped workspaces are now updated and started when handling URI +- show errors when the Toolbox is visible again after being minimized. +- URI handling now installs the exact build number if it is available for the workspace. + +## 0.3.0 - 2025-06-10 + +### Added + +- support for Toolbox 2.6.3 with improved URI handling + +## 0.2.3 - 2025-05-26 + +### Changed + +- improved workspace status reporting (icon and colors) when it is failed, stopping, deleting, stopped or when we are + establishing the SSH connection. + +### Fixed + +- url on the main page is now refreshed when switching between multiple deployments (via logout/login or URI handling) +- tokens are now remembered after switching between multiple deployments + +## 0.2.2 - 2025-05-21 + +- render network status in the Settings tab, under `Additional environment information` section. +- quick action for creating new workspaces from the web dashboard. + +### Fixed + +- `Open web terminal` action is no longer displayed when the workspace is stopped. +- URL links can now be opened in Windows + +## 0.2.1 - 2025-05-05 + +### Changed + +- ssh configuration is simplified, background hostnames have been discarded. + +### Fixed + +- rendering glitches when a Workspace is stopped while SSH connection is alive +- misleading message saying that there are no workspaces rendered during manual authentication +- Coder Settings can now be accessed from the authentication wizard + +## 0.2.0 - 2025-04-24 + +### Added + +- support for using proxies. Proxy authentication is not yet supported. + +### Changed + +- connections to the workspace are no longer established automatically after agent started with error. + +### Fixed + +- SSH connection will no longer fail with newer Coder deployments due to misconfiguration of hostname and proxy command. + +## 0.1.5 - 2025-04-14 + +### Fixed + +- login screen is shown instead of an empty list of workspaces when token expired + +### Changed + +- improved error handling during workspace polling + +## 0.1.4 - 2025-04-11 + +### Fixed + +- SSH connection to a Workspace is no longer established only once +- authorization wizard automatically goes to a previous screen when an error is encountered during connection to Coder + deployment + +### Changed + +- action buttons on the token input step were swapped to achieve better keyboard navigation +- URI `project_path` query parameter was renamed to `folder` + +## 0.1.3 - 2025-04-09 + +### Fixed + +- Toolbox remembers the authentication page that was last visible on the screen + +## 0.1.2 - 2025-04-04 + +### Fixed + +- after log out, user is redirected back to the initial log in screen + +## 0.1.1 - 2025-04-03 + ### Fixed - SSH config is regenerated correctly when Workspaces are added or removed diff --git a/JETBRAINS_COMPLIANCE.md b/JETBRAINS_COMPLIANCE.md new file mode 100644 index 00000000..91162edc --- /dev/null +++ b/JETBRAINS_COMPLIANCE.md @@ -0,0 +1,81 @@ +# JetBrains Auto-Approval Compliance + +This document describes the linting setup to ensure compliance with JetBrains auto-approval requirements for Toolbox plugins. + +## Overview + +JetBrains has enabled auto-approval for this plugin, which requires following specific guidelines to maintain the approval status. This repository includes automated checks to ensure compliance. + +## Requirements + +Based on communication with JetBrains team, the following requirements must be met: + +### ✅ Allowed +- **Coroutines**: Use `coroutineScope.launch` for concurrent operations +- **Library-managed threads**: Libraries like OkHttp with their own thread pools are acceptable +- **Some experimental coroutines APIs**: `kotlinx.coroutines.selects.select` and `kotlinx.coroutines.selects.onTimeout` are acceptable +- **Proper cleanup**: Ensure resources are released in `CoderRemoteProvider#close()` method + +### ❌ Forbidden +- **Kotlin experimental APIs**: Core Kotlin experimental APIs (not coroutines-specific ones) +- **Java runtime hooks**: No lambdas, handlers, or class handles to Java runtime hooks +- **Manual thread creation**: Avoid `Thread()`, `Executors.new*()`, `ThreadPoolExecutor`, etc. +- **Bundled libraries**: Don't bundle libraries already provided by Toolbox +- **Ill-intentioned actions**: No malicious or harmful code + +## Linting Setup + +### JetBrains Compliance with Detekt + +The primary compliance checking is done using Detekt with custom configuration in `detekt.yml`: + +```bash +./gradlew detekt +``` + +This configuration includes JetBrains-specific rules that check for: +- **ForbiddenAnnotation**: Detects forbidden experimental API usage +- **ForbiddenMethodCall**: Detects Java runtime hooks and manual thread creation +- **ForbiddenImport**: Detects potentially bundled libraries +- **Standard code quality rules**: Complexity, naming, performance, etc. + +## CI/CD Integration + +The GitHub Actions workflow `.github/workflows/jetbrains-compliance.yml` runs compliance checks on every PR and push. + +## Running Locally + +```bash +# Run JetBrains compliance and code quality check +./gradlew detekt + +# View HTML report +open build/reports/detekt/detekt.html +``` + +## Understanding Results + +### Compliance Check Results + +- **✅ No critical violations**: Code complies with JetBrains requirements +- **❌ Critical violations**: Must be fixed before auto-approval +- **⚠️ Warnings**: Should be reviewed but may be acceptable + +### Common Warnings + +1. **Manual thread creation**: If you see warnings about thread creation: + - Prefer coroutines: `coroutineScope.launch { ... }` + - If using libraries with threads, ensure cleanup in `close()` + +2. **Library imports**: If you see warnings about library imports: + - Verify the library isn't bundled in the final plugin + - Check that Toolbox doesn't already provide the library + +3. **GlobalScope usage**: If you see warnings about `GlobalScope`: + - Use the coroutine scope provided by Toolbox instead + +## Resources + +- [JetBrains Toolbox Plugin Development](https://plugins.jetbrains.com/docs/toolbox/) +- [Detekt Documentation](https://detekt.dev/) +- [Kotlin Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html) diff --git a/README.md b/README.md index 6823faeb..2d50806d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,431 @@ -# Toolbox Gateway plugin sample +# Coder Toolbox plugin -To load plugin into the provided Toolbox App, run `./gradlew build copyPlugin` +[!["Join us onDiscord"](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 Toolbox Plugin Build](https://github.com/coder/coder-jetbrains-toolbox/actions/workflows/build.yml/badge.svg)](https://github.com/coder/coder-jetbrains-toolbox/actions/workflows/build.yml) -or put files in the following directory: +Connects your JetBrains IDE to Coder workspaces -* Windows: `%LocalAppData%/JetBrains/Toolbox/cache/plugins/plugin-id` -* macOS: `~/Library/Caches/JetBrains/Toolbox/plugins/plugin-id` -* Linux: `~/.local/share/JetBrains/Toolbox/plugins/plugin-id` +## Getting Started -Put all required .jar files (do not include any dependencies already included with the Toolbox App to avoid possible resolution conflicts), -`extensions.json` and `icon.svg` in this directory. +To install this plugin using JetBrains Toolbox, follow the steps below. + +1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/). Make sure it's the `2.6.0.40632` release or + above. +2. Launch the Toolbox app and sign in with your JetBrains account (if needed). + +### Install Coder plugin via URI + +You can quickly install the plugin using this JetBrains hyperlink: + +👉 + +This will open JetBrains Toolbox and prompt you to install the Coder Toolbox plugin automatically. + +Alternatively, you can paste `jetbrains://gateway/com.coder.toolbox` into a browser. + +### Manual install + +There are two ways Coder Toolbox plugin can be installed. The first option is to manually download the plugin +artifact from [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/26968-coder/versions) +or from [Coder's Github Release page](https://github.com/coder/coder-jetbrains-toolbox/releases). + +The next step is to copy the zip content to one of the following locations, depending on your OS: + +* Windows: `%LocalAppData%/JetBrains/Toolbox/plugins/com.coder.toolbox` +* macOS: `~/Library/Caches/JetBrains/Toolbox/plugins/com.coder.toolbox` +* Linux: `~/.local/share/JetBrains/Toolbox/plugins/com.coder.toolbox` + +Alternatively, you can install it using the _Gradle_ tasks included in the project: + +```shell + +./gradlew cleanAll build copyPlugin +``` + +Make sure Toolbox is closed before running the command. + +## Connect to a Coder Workspace via JetBrains Toolbox URI + +You can use specially crafted JetBrains Gateway URIs to automatically: + +1. Open Toolbox + +2. Install the Coder Toolbox plugin (if not already installed) + +3. Connect to a specific Coder deployment using a URL and a token. + +4. Select a running workspace + +5. Install a specified JetBrains IDE on that Workspace + +6. Open a project folder directly in the remote IDE + +### Example URIs + +```text +jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fdev.coder.com&token=zeoX4SbSpP-j2qGpajkdwxR9jBdcekXS2&workspace=bobiverse-bob&agent_name=dev&ide_product_code=GO&ide_build_number=241.23774.119&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs + +jetbrains://gateway/coder?url=https%3A%2F%2Fj5gj2r1so5nbi.pit-1.try.coder.app%2F&token=gqEirOoI1U-FfCQ6uj8iOLtybBIk99rr8&workspace=bobiverse-riker&agent_name=dev&ide_product_code=RR&ide_build_number=243.26053.17&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs +``` + +### URI Breakdown + +```text +jetbrains://gateway/com.coder.toolbox + ?url=http(s):// + &token= + &workspace= + &agent_name= + &ide_product_code= + &ide_build_number= + &folder= +``` + +Starting from Toolbox 2.7, you can use `coder` as a shortcut in place of the full plugin ID. The URI can be simplified +as: + +```text +jetbrains://gateway/coder?url=http(s):// +``` + +| Query param | Description | Mandatory | +|------------------|------------------------------------------------------------------------------|-----------| +| url | Your Coder deployment URL (encoded) | Yes | +| token | Coder authentication token | Yes | +| workspace | Name of the Coder workspace to connect to. | Yes | +| agent_name | The name of the agent with the workspace | No | +| ide_product_code | JetBrains IDE product code (e.g., GO for GoLand, RR for Rider) | No | +| ide_build_number | Specific build number of the JetBrains IDE to install on the workspace | No | +| folder | Absolute path to the project folder to open in the remote IDE (URL-encoded) | No | + +> [!NOTE] +> If only a single agent is available, specifying an agent name is optional. However, if multiple agents exist, you must +> provide the +> agent name. Note that this version of the Coder Toolbox plugin does not automatically start agents if they +> are offline, so please ensure the selected agent is running before proceeding. + +If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open and highlight the workspace environment +page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable +experience, it’s recommended to ensure the workspace is running prior to initiating the connection. + +## GPG Signature Verification + +The Coder Toolbox plugin starting with version *0.5.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 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. + +## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy + +This section explains how to set up a local proxy and verify that +the plugin’s REST client works correctly when routed through it. + +We’ll use [mitmproxy](https://mitmproxy.org/) for this — it can act as both an HTTP and SOCKS5 proxy with SSL +interception. + +### Install mitmproxy + +1. Follow the [mitmproxy Install Guide](https://docs.mitmproxy.org/stable/overview-installation/) steps for your OS. +2. Start the proxy: + +```bash +mitmweb --ssl-insecure --set stream_large_bodies="10m" + ``` + +### Configure Mitmproxy + +mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other: + +1. Open http://127.0.0.1:8081 in browser; +2. Navigate to `Options -> Edit Options` +3. Update the `Mode` field to `regular` in order to activate HTTP/HTTPS or to `socks5` +4. Proxy authentication can be enabled by updating the `proxyauth` to `username:password` +5. Alternatively you can run the following commands: + +```bash +mitmweb --ssl-insecure --set stream_large_bodies="10m" --mode regular --proxyauth proxyUsername:proxyPassword +mitmweb --ssl-insecure --set stream_large_bodies="10m" --mode socks5 +``` + +### Configure Proxy in Toolbox + +1. Start Toolbox +2. From Toolbox hexagonal menu icon go to `Settings -> Proxy` +3. There are two options, to use system proxy settings or to manually configure the proxy details. +4. If we go manually, add `127.0.0.1` to the host and port `8080` for HTTP/HTTPS or `1080` for SOCKS5. +5. Before authenticating to the Coder deployment we need to tell the plugin where can we find mitmproxy + certificates. In Coder's Settings page, set the `TLS CA path` to `~/.mitmproxy/mitmproxy-ca-cert.pem` + +> [!NOTE] +> Coder Toolbox plugin handles only HTTP/HTTPS proxy authentication. +> SOCKS5 proxy authentication is currently not supported due to limitations +> described +> +in: https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 + +### Mitmproxy returns 502 Bad Gateway to the client + +When running traffic through mitmproxy, you may encounter 502 Bad Gateway errors that mention HTTP/2 protocol error: * +*Received header value surrounded by whitespace**. +This happens because some upstream servers (including dev.coder.com) send back headers such as Content-Security-Policy +with leading or trailing spaces. +While browsers and many HTTP clients accept these headers, mitmproxy enforces the stricter HTTP/2 and HTTP/1.1 RFCs, +which forbid whitespace around header values. +As a result, mitmproxy rejects the response and surfaces a 502 to the client. + +The workaround is to disable HTTP/2 in mitmproxy and force HTTP/1.1 on both the client and upstream sides. This avoids +the strict header validation path and allows +mitmproxy to pass responses through unchanged. You can do this by starting mitmproxy with: + +```bash +mitmproxy --set http2=false --set upstream_http_version=HTTP/1.1 +``` + +This ensures coder toolbox http client ↔ mitmproxy ↔ server connections all run over HTTP/1.1, preventing the whitespace +error. + +## Debugging and Reporting issues + +Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH +connections to the remote environment fail — it provides detailed output that includes SSH negotiation +and command execution, which is not visible at the default log level. + +If you encounter a problem with Coder's JetBrains Toolbox plugin, follow the steps below to gather more +information and help us diagnose and resolve it quickly. + +### Enable Debug Logging + +To help with troubleshooting or to gain more insight into the behavior of the plugin and the SSH connection to +the workspace, you can increase the log level to _DEBUG_. + +Steps to enable debug logging: + +1. Open Toolbox + +2. Navigate to the Toolbox App Menu (hexagonal menu icon) > Settings > Advanced. + +3. In the screen that appears, select _DEBUG_ for the `Log level:` section. + +4. Hit the back button at the top. + +There is no need to restart Toolbox, as it will begin logging at the __DEBUG__ level right away. + +> [!WARNING] +> Toolbox does not persist log level configuration between restarts. + +#### Viewing the Logs + +Once enabled, debug logs will be written to the Toolbox log files. You can access logs directly +via Toolbox App Menu > About > Show log files. + +Alternatively, you can generate a ZIP file using the Workspace action menu, available either on the main +Workspaces page in Coder or within the individual workspace view, under the option labeled _Collect logs_. + +### HTTP Request Logging + +The Coder Toolbox plugin includes comprehensive HTTP request logging capabilities to help diagnose API communication +issues with Coder deployments. +This feature allows you to monitor all HTTP requests and responses made by the plugin. + +#### Configuring HTTP Logging + +You can configure HTTP logging verbosity through the Coder Settings page: + +1. Navigate to the Coder Workspaces page +2. Click on the deployment action menu (three dots) +3. Select "Settings" +4. Find the "HTTP logging level" dropdown + +#### Available Logging Levels + +The plugin supports four levels of HTTP logging verbosity: + +- **None**: No HTTP request/response logging (default) +- **Basic**: Logs HTTP method, URL, and response status code +- **Headers**: Logs basic information plus sanitized request and response headers +- **Body**: Logs headers plus request and response body content + +#### Log Output Format + +HTTP logs follow this format: + +``` +request --> GET https://your-coder-deployment.com/api/v2/users/me +User-Agent: Coder Toolbox/1.0.0 (darwin; amd64) +Coder-Session-Token: + +response <-- 200 https://your-coder-deployment.com/api/v2/users/me +Content-Type: application/json +Content-Length: 245 + +{"id":"12345678-1234-1234-1234-123456789012","username":"coder","email":"coder@example.com"} +``` + +#### Use Cases + +HTTP logging is particularly useful for: + +- **API Debugging**: Diagnosing issues with Coder API communication +- **Authentication Problems**: Troubleshooting token or certificate authentication issues +- **Network Issues**: Identifying connectivity problems with Coder deployments +- **Performance Analysis**: Monitoring request/response times and payload sizes + +#### Troubleshooting with HTTP Logs + +When reporting issues, include HTTP logs to help diagnose: + +1. **Authentication Failures**: Check for 401/403 responses and token headers +2. **Network Connectivity**: Look for connection timeouts or DNS resolution issues +3. **API Compatibility**: Verify request/response formats match expected API versions +4. **Proxy Issues**: Monitor proxy authentication and routing problems + +## Coder Settings + +The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data +storage paths. The options can be configured from the plugin's main Workspaces page > deployment action menu > Settings. + +### CLI related settings + +- `Binary source` specifies the source URL or relative path from which the Coder CLI should be downloaded. + If a relative path is provided, it is resolved against the deployment domain. + +- `Enable downloads` allows automatic downloading of the CLI if the current version is missing or outdated. + +- `Binary directory` specifies the directory where CLI binaries are stored. If omitted, it defaults to the data + directory. + +- `Enable binary directory fallback` if enabled, falls back to the data directory when the specified binary + directory is not writable. + +- `Data directory` directory where plugin-specific data such as session tokens and binaries are stored if not + overridden by the binary directory setting. + +- `Header command` command that outputs additional HTTP headers. Each line of output must be in the format key=value. + The environment variable CODER_URL will be available to the command process. + +- `lastDeploymentURL` the last Coder deployment URL that Coder Toolbox successfully authenticated to. + +- `workspaceViewUrl` specifies the dashboard page full URL where users can view details about a workspace. + Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment workspace page. + This setting supports `$workspaceOwner` and `$workspaceName` as placeholders. + +- `workspaceCreateUrl` specifies the dashboard page full URL where users can create new workspaces. + Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment templates page. + This setting supports `$workspaceOwner` as placeholder with the replacing value being the username that logged in. + +### TLS settings + +The following options control the secure communication behavior of the plugin with Coder deployment and its available +API. + +- `TLS cert path` path to a client certificate file for TLS authentication with Coder deployment. + The certificate should be in X.509 PEM format. + +- `TLS key path` path to the private key corresponding to the TLS certificate from above. + The certificate should be in X.509 PEM format. + +- `TLS CA path` the path of a file containing certificates for an alternate certificate authority used to verify TLS + certs returned by the Coder deployment. The file should be in X.509 PEM format. This option can also be used to verify + proxy certificates. + +- `TLS alternate hostname` overrides the hostname used in TLS verification. This is useful when the hostname + used to connect to the Coder deployment does not match the hostname in the TLS certificate. + +### SSH settings + +The following options control the SSH behavior of the Coder CLI. + +- `Disable autostart` adds the --disable-autostart flag to the SSH proxy command, preventing the CLI from keeping + workspaces constantly active. + +- `Enable SSH wildcard config` enables or disables wildcard entries in the SSH configuration, which allow generic + rules for matching multiple workspaces. + +- `SSH proxy log directory` directory where SSH proxy logs are written. Useful for debugging SSH connection issues. + +- `SSH network metrics directory` directory where network information used by the SSH proxy is stored. + +- `Extra SSH options` additional options appended to the SSH configuration. Can be used to customize the behavior of + SSH connections. + +### Saving Changes + +Changes made in the settings page are saved by clicking the Save button. Some changes, like toggling SSH wildcard +support, may trigger regeneration of SSH configurations. + +### Security considerations + +> [!IMPORTANT] +> Token authentication is required when TLS certificates are not configured. + +## 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. +5. **Compliance Reminder for auto-approval** + JetBrains enabled auto-approval for the plugin, so we need to ensure we continue to meet the following requirements: + - do **not** use Kotlin experimental APIs. + - do **not** add any lambdas, handlers, or class handles to Java runtime hooks. + - do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in + the plugin's `CoderRemoteProvider#close()` method. + - do **not** bundle libraries that are already provided by Toolbox. + - do **not** perform any ill-intentioned actions. diff --git a/build.gradle.kts b/build.gradle.kts index bee647fd..cdfc5e85 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ plugins { alias(libs.plugins.gradle.wrapper) alias(libs.plugins.changelog) alias(libs.plugins.gettext) + alias(libs.plugins.detekt) } @@ -62,6 +63,7 @@ dependencies { ksp(libs.moshi.codegen) implementation(libs.retrofit) implementation(libs.retrofit.moshi) + implementation(libs.bundles.bouncycastle) testImplementation(kotlin("test")) testImplementation(libs.mokk) testImplementation(libs.bundles.toolbox.plugin.api) @@ -110,6 +112,24 @@ tasks.test { useJUnitPlatform() } +// Detekt configuration for JetBrains compliance and code quality +detekt { + config.setFrom("$projectDir/detekt.yml") + buildUponDefaultConfig = true + allRules = false +} + +// Configure detekt for JetBrains compliance and code quality +tasks.withType().configureEach { + jvmTarget = "21" + reports { + html.required.set(true) + xml.required.set(true) + } + // Fail build on detekt issues for JetBrains compliance + ignoreFailures = false +} + tasks.jar { archiveBaseName.set(extension.id) @@ -133,7 +153,7 @@ fun CopySpec.fromCompileDependencies() { } from("src/main/resources") { include("icon.svg") - rename("icon.svg", "pluginIcon.svg") + include("pluginIcon.svg") } // Copy dependencies, excluding those provided by Toolbox. @@ -191,8 +211,10 @@ private fun getPluginInstallDir(): Path { } / "JetBrains" / "Toolbox" val pluginsDir = when { - SystemInfoRt.isWindows -> toolboxCachesDir / "cache" - SystemInfoRt.isLinux || SystemInfoRt.isMac -> toolboxCachesDir + SystemInfoRt.isWindows || + SystemInfoRt.isLinux || + SystemInfoRt.isMac -> toolboxCachesDir + else -> error("Unknown os") } / "plugins" @@ -223,7 +245,7 @@ val publishPlugin by tasks.registering { pluginZip.outputs.files.singleFile, // do not change null, // do not change. Channels will be available later "Bug fixes and improvements", - true + false ) } } diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 00000000..5e5e6c8d --- /dev/null +++ b/detekt.yml @@ -0,0 +1,204 @@ +# Detekt configuration for JetBrains Toolbox Plugin Auto-Approval Compliance +# Based on clarified requirements from JetBrains team + +build: + maxIssues: 1000 # Allow many issues for code quality reporting + excludeCorrectable: false + +config: + validation: true + warningsAsErrors: false # Don't treat warnings as errors + checkExhaustiveness: false + +# CRITICAL: JetBrains Compliance Rules using detekt built-in rules +style: + active: true + + # JetBrains Auto-Approval Compliance: Forbidden experimental annotations + ForbiddenAnnotation: + active: true + annotations: + - reason: 'Forbidden for JetBrains auto-approval: Core Kotlin experimental APIs are not allowed' + value: 'kotlin.ExperimentalStdlibApi' + - reason: 'Forbidden for JetBrains auto-approval: Core Kotlin experimental APIs are not allowed' + value: 'kotlin.ExperimentalUnsignedTypes' + - reason: 'Forbidden for JetBrains auto-approval: Core Kotlin experimental APIs are not allowed' + value: 'kotlin.contracts.ExperimentalContracts' + - reason: 'Forbidden for JetBrains auto-approval: Core Kotlin experimental APIs are not allowed' + value: 'kotlin.experimental.ExperimentalTypeInference' + - reason: 'Forbidden for JetBrains auto-approval: Internal coroutines APIs should be avoided' + value: 'kotlinx.coroutines.InternalCoroutinesApi' + - reason: 'Forbidden for JetBrains auto-approval: Experimental time APIs are not allowed' + value: 'kotlin.time.ExperimentalTime' + # Note: ExperimentalCoroutinesApi, DelicateCoroutinesApi, FlowPreview are acceptable + # based on JetBrains feedback about select/onTimeout being OK + + # JetBrains Auto-Approval Compliance: Forbidden method calls + ForbiddenMethodCall: + active: true + methods: + # Java runtime hooks - forbidden + - reason: 'Forbidden for JetBrains auto-approval: Java runtime hooks are not allowed' + value: 'java.lang.Runtime.addShutdownHook' + - reason: 'Forbidden for JetBrains auto-approval: Java runtime hooks are not allowed' + value: 'java.lang.System.setSecurityManager' + - reason: 'Forbidden for JetBrains auto-approval: Java runtime hooks are not allowed' + value: 'java.lang.Thread.setUncaughtExceptionHandler' + - reason: 'Forbidden for JetBrains auto-approval: Java runtime hooks are not allowed' + value: 'java.lang.Thread.setDefaultUncaughtExceptionHandler' + # Manual thread creation - warnings (allowed with proper cleanup) + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.lang.Thread.' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.Executors.newFixedThreadPool' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.Executors.newCachedThreadPool' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.Executors.newSingleThreadExecutor' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.CompletableFuture.runAsync' + - reason: 'Warning for JetBrains auto-approval: Manual thread creation detected. Consider using coroutineScope.launch or ensure proper cleanup in CoderRemoteProvider#close()' + value: 'java.util.concurrent.CompletableFuture.supplyAsync' + + # JetBrains Auto-Approval Compliance: Forbidden imports + ForbiddenImport: + active: true + imports: + # Potentially bundled libraries - warnings + - reason: 'Warning for JetBrains auto-approval: Ensure slf4j is not bundled - it is provided by Toolbox' + value: 'org.slf4j.*' + - reason: 'Warning for JetBrains auto-approval: Ensure annotations library is not bundled - it is provided by Toolbox' + value: 'org.jetbrains.annotations.*' + # Runtime hook classes - forbidden + - reason: 'Forbidden for JetBrains auto-approval: Runtime hook classes are not allowed' + value: 'java.lang.Runtime' + - reason: 'Forbidden for JetBrains auto-approval: Security manager modifications are not allowed' + value: 'java.security.SecurityManager' + + # Other important style rules + MagicNumber: + active: true + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + + NewLineAtEndOfFile: + active: true + + WildcardImport: + active: true + +# Essential built-in rules for basic code quality +complexity: + active: true + CyclomaticComplexMethod: + active: true + threshold: 15 + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 7 + NestedBlockDepth: + active: true + threshold: 4 + +coroutines: + active: true + GlobalCoroutineUsage: + active: true + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + ObjectExtendsThrowable: + active: true + PrintStackTrace: + active: true + ReturnFromFinally: + active: true + SwallowedException: + active: true + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionsWithoutMessageOrCause: + active: true + TooGenericExceptionCaught: + active: true + TooGenericExceptionThrown: + active: true + +naming: + active: true + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + FunctionNaming: + active: true + functionPattern: '[a-z][a-zA-Z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.?[a-z][A-Za-z0-9]*)*' + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + +performance: + active: true + ArrayPrimitive: + active: true + ForEachOnRange: + active: true + SpreadOperator: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + InvalidRange: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + UnsafeCast: + active: true + WrongEqualsTypeParameter: + active: true diff --git a/gradle.properties b/gradle.properties index 01172dd5..447537eb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.1.1 +version=0.7.2 group=com.coder.toolbox -name=coder-toolbox +name=coder-toolbox \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8218959b..a40c6433 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,22 @@ [versions] -toolbox-plugin-api = "1.0.38881" -kotlin = "2.1.0" -coroutines = "1.10.1" -serialization = "1.8.0" +toolbox-plugin-api = "1.3.47293" +kotlin = "2.1.20" +coroutines = "1.10.2" +serialization = "1.8.1" okhttp = "4.12.0" -dependency-license-report = "2.9" -marketplace-client = "2.0.46" -gradle-wrapper = "0.14.0" +dependency-license-report = "3.0.1" +marketplace-client = "2.0.49" +gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" -ksp = "2.1.0-1.0.29" -retrofit = "2.11.0" -changelog = "2.2.1" +ksp = "2.1.20-2.0.1" +retrofit = "3.0.0" +changelog = "2.4.0" gettext = "0.7.0" -plugin-structure = "3.300" -mockk = "1.13.17" +plugin-structure = "3.320" +mockk = "1.14.6" +detekt = "1.23.8" +bouncycastle = "1.82" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } @@ -33,10 +35,13 @@ retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.re plugin-structure = { module = "org.jetbrains.intellij.plugins:structure-toolbox", version.ref = "plugin-structure" } mokk = { module = "io.mockk:mockk", version.ref = "mockk" } marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" } +bouncycastle-bcpg = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncycastle" } +bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } [bundles] serialization = ["serialization-core", "serialization-json", "serialization-json-okio"] toolbox-plugin-api = ["toolbox-core-api", "toolbox-ui-api", "toolbox-remote-dev-api"] +bouncycastle = ["bouncycastle-bcpg", "bouncycastle-bcprov"] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } @@ -45,4 +50,5 @@ dependency-license-report = { id = "com.github.jk1.dependency-license-report", v ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } gradle-wrapper = { id = "me.filippov.gradle.jvm.wrapper", version.ref = "gradle-wrapper" } changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } -gettext = { id = "name.kropp.kotlinx-gettext", version.ref = "gettext" } \ No newline at end of file +gettext = { id = "name.kropp.kotlinx-gettext", version.ref = "gettext" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index ccdf6226..4b9c6073 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -1,30 +1,48 @@ package com.coder.toolbox -import com.coder.toolbox.browser.BrowserUtil +import com.coder.toolbox.browser.browse +import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.cli.SshCommandProcessHandle import com.coder.toolbox.models.WorkspaceAndAgentStatus import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException +import com.coder.toolbox.sdk.v2.models.NetworkMetrics import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.util.waitForFalseWithTimeout import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action +import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.EnvironmentView -import com.jetbrains.toolbox.api.remoteDev.DeleteEnvironmentConfirmationParams +import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.remoteDev.AfterDisconnectHook +import com.jetbrains.toolbox.api.remoteDev.BeforeConnectionHook import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState import com.jetbrains.toolbox.api.ui.actions.ActionDescription +import com.jetbrains.toolbox.api.ui.components.TextType +import com.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import java.io.File +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +private val POLL_INTERVAL = 5.seconds + /** * Represents an agent and workspace combination. * @@ -33,77 +51,223 @@ import kotlin.time.Duration.Companion.seconds class CoderRemoteEnvironment( private val context: CoderToolboxContext, private val client: CoderRestClient, + private val cli: CoderCLIManager, private var workspace: Workspace, private var agent: WorkspaceAgent, -) : RemoteProviderEnvironment("${workspace.name}.${agent.name}") { +) : RemoteProviderEnvironment("${workspace.name}.${agent.name}"), BeforeConnectionHook, AfterDisconnectHook { private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) override var name: String = "${workspace.name}.${agent.name}" + private var isConnected: MutableStateFlow = MutableStateFlow(false) + override val connectionRequest: MutableStateFlow = MutableStateFlow(false) + override val state: MutableStateFlow = MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context)) override val description: MutableStateFlow = MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))) - + override val additionalEnvironmentInformation: MutableMap = mutableMapOf() override val actionsList: MutableStateFlow> = MutableStateFlow(getAvailableActions()) + private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) + private val proxyCommandHandle = SshCommandProcessHandle(context) + private var pollJob: Job? = null + private val startIsInProgress = AtomicBoolean(false) + + init { + if (context.settingsStore.shouldAutoConnect(id)) { + context.logger.info("resuming SSH connection to $id — last session was still active.") + startSshConnection() + } + } + + fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) + private fun getAvailableActions(): List { - val actions = mutableListOf( - Action(context.i18n.ptrl("Open web terminal")) { - context.cs.launch { - BrowserUtil.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { - context.ui.showErrorInfoPopup(it) - } + val actions = mutableListOf() + if (wsRawStatus.canStop()) { + actions.add(Action(context, "Open web terminal") { + context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { + context.ui.showErrorInfoPopup(it) } - }, - Action(context.i18n.ptrl("Open in dashboard")) { - context.cs.launch { - BrowserUtil.browse(client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()) { - context.ui.showErrorInfoPopup(it) - } + } + ) + } + actions.add( + Action(context, "Open in dashboard") { + val urlTemplate = context.settingsStore.workspaceViewUrl + ?: client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + val url = urlTemplate + .replace("\$workspaceOwner", "${workspace.ownerName}") + .replace("\$workspaceName", workspace.name) + context.desktop.browse( + url + ) { + context.ui.showErrorInfoPopup(it) } - }, + } + ) - Action(context.i18n.ptrl("View template")) { - context.cs.launch { - BrowserUtil.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { - context.ui.showErrorInfoPopup(it) - } - } - }) + actions.add(Action(context, "View template") { + context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + context.ui.showErrorInfoPopup(it) + } + } + ) if (wsRawStatus.canStart()) { if (workspace.outdated) { - actions.add(Action(context.i18n.ptrl("Update and start")) { + actions.add(Action(context, "Update and start") { val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) - }) + } + ) } else { - actions.add(Action(context.i18n.ptrl("Start")) { - val build = client.startWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - }) + actions.add(Action(context, "Start") { + try { + // needed in order to make sure Queuing is not overridden by the + // general polling loop with the `Stopped` state + startIsInProgress.set(true) + val startJob = context.cs + .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { + cli.startWorkspace(workspace.ownerName, workspace.name) + } + // cli takes 15 seconds to move the workspace in queueing/starting state + // while the user won't see anything happening in TBX after start is clicked + // During those 15 seconds we work around by forcing a `Queuing` state + while (startJob.isActive && client.workspace(workspace.id).latestBuild.status.isNotStarted()) { + state.update { + WorkspaceAndAgentStatus.QUEUED.toRemoteEnvironmentState(context) + } + delay(1.seconds) + } + startIsInProgress.set(false) + // retrieve the status again and update the status + update(client.workspace(workspace.id), agent) + } finally { + startIsInProgress.set(false) + } + } + ) } } if (wsRawStatus.canStop()) { if (workspace.outdated) { - actions.add(Action(context.i18n.ptrl("Update and restart")) { + actions.add(Action(context, "Update and restart") { val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) - }) - } else { - actions.add(Action(context.i18n.ptrl("Stop")) { - val build = client.stopWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - }) + } + ) + } + actions.add(Action(context, "Stop") { + tryStopSshConnection() + + val build = client.stopWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) } + ) } + actions.add(CoderDelimiter(context.i18n.pnotr(""))) + actions.add(Action(context, "Delete workspace", highlightInRed = true) { + context.cs.launch(CoroutineName("Delete Workspace Action")) { + var dialogText = + if (wsRawStatus.canStop()) "This will close the workspace and remove all its information, including files, unsaved changes, history, and usage data." + else "This will remove all information from the workspace, including files, unsaved changes, history, and usage data." + dialogText += "\n\nType \"${workspace.name}\" below to confirm:" + + val confirmation = context.ui.showTextInputPopup( + if (wsRawStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl("Delete workspace?"), + context.i18n.pnotr(dialogText), + context.i18n.ptrl("Workspace name"), + TextType.General, + context.i18n.ptrl("OK"), + context.i18n.ptrl("Cancel") + ) + if (confirmation != workspace.name) { + return@launch + } + deleteWorkspace() + } + }) return actions } + private suspend fun tryStopSshConnection() { + if (isConnected.value) { + connectionRequest.update { + false + } + + if (isConnected.waitForFalseWithTimeout(10.seconds) == null) { + context.logger.warn("The SSH connection to workspace $name could not be dropped in time, going to stop the workspace while the SSH connection is live") + } + } + } + + override fun getBeforeConnectionHooks(): List = listOf(this) + + override fun getAfterDisconnectHooks(): List = listOf(this) + + override fun beforeConnection() { + context.logger.info("Connecting to $id...") + isConnected.update { true } + context.settingsStore.updateAutoConnect(this.id, true) + pollJob = pollNetworkMetrics() + } + + private fun pollNetworkMetrics(): Job = context.cs.launch(CoroutineName("Network Metrics Poller")) { + context.logger.info("Starting the network metrics poll job for $id") + while (isActive) { + context.logger.debug("Searching SSH command's PID for workspace $id...") + val pid = proxyCommandHandle.findByWorkspaceAndAgent(workspace, agent) + if (pid == null) { + context.logger.debug("No SSH command PID was found for workspace $id") + delay(POLL_INTERVAL) + continue + } + + val metricsFile = Path.of(context.settingsStore.networkInfoDir, "$pid.json").toFile() + if (metricsFile.doesNotExists()) { + context.logger.debug("No metrics file found at ${metricsFile.absolutePath} for $id") + delay(POLL_INTERVAL) + continue + } + context.logger.debug("Loading metrics from ${metricsFile.absolutePath} for $id") + try { + val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) ?: return@launch + context.logger.debug("$id metrics: $metrics") + additionalEnvironmentInformation[context.i18n.ptrl("Network Status")] = metrics.toPretty() + } catch (e: Exception) { + context.logger.error( + e, + "Error encountered while trying to load network metrics from ${metricsFile.absolutePath} for $id" + ) + } + delay(POLL_INTERVAL) + } + } + + private fun File.doesNotExists(): Boolean = !this.exists() + + override fun afterDisconnect(isManual: Boolean) { + context.logger.info("Stopping the network metrics poll job for $id") + pollJob?.cancel() + this.connectionRequest.update { false } + isConnected.update { false } + if (isManual) { + // if the user manually disconnects the ssh connection we should not connect automatically + context.settingsStore.updateAutoConnect(this.id, false) + } + context.logger.info("Disconnected from $id") + } + /** * Update the workspace/agent status to the listeners, if it has changed. */ fun update(workspace: Workspace, agent: WorkspaceAgent) { + if (startIsInProgress.get()) { + context.logger.info("Skipping update for $id - workspace start is in progress") + return + } this.workspace = workspace this.agent = agent wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) @@ -112,7 +276,7 @@ class CoderRemoteEnvironment( actionsList.update { getAvailableActions() } - context.cs.launch { + context.cs.launch(CoroutineName("Workspace Status Updater")) { state.update { wsRawStatus.toRemoteEnvironmentState(context) } @@ -123,73 +287,71 @@ class CoderRemoteEnvironment( * The contents are provided by the SSH view provided by Toolbox, all we * have to do is provide it a host name. */ - override suspend - fun getContentsView(): EnvironmentContentsView = EnvironmentView( - context.settingsStore.readOnly(), + override suspend fun getContentsView(): EnvironmentContentsView = EnvironmentView( client.url, + cli, workspace, agent ) - override val connectionRequest: MutableStateFlow? = MutableStateFlow(false) - /** - * Does nothing. In theory, we could do something like start the workspace - * when you click into the workspace, but you would still need to press - * "connect" anyway before the content is populated so there does not seem - * to be much value. + * Automatically launches the SSH connection if the workspace is visible, is ready and there is no + * connection already established. */ override fun setVisible(visibilityState: EnvironmentVisibilityState) { - if (wsRawStatus.ready() && visibilityState.contentsVisible == true && visibilityState.isBackendConnected == false) { - context.logger.info("Connecting to $id...") - context.cs.launch { - connectionRequest?.update { + if (visibilityState.contentsVisible) { + startSshConnection() + } + } + + /** + * Launches the SSH connection if the workspace is ready and there is no connection already established. + * + * Returns true if the SSH connection was scheduled to start, false otherwise. + */ + fun startSshConnection(): Boolean { + if (wsRawStatus.ready() && !isConnected.value) { + context.cs.launch(CoroutineName("SSH Connection Trigger")) { + connectionRequest.update { true } } + return true } + return false } - override fun getDeleteEnvironmentConfirmationParams(): DeleteEnvironmentConfirmationParams? { - return object : DeleteEnvironmentConfirmationParams { - override val cancelButtonText: String = "Cancel" - override val confirmButtonText: String = "Delete" - override val message: String = - if (wsRawStatus.canStop()) "Workspace will be closed and all the information will be lost, including all files, unsaved changes, historical info and usage data." - else "All the information in this workspace will be lost, including all files, unsaved changes, historical info and usage data." - override val title: String = if (wsRawStatus.canStop()) "Delete running workspace?" else "Delete workspace?" - } - } + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow(null) - override fun onDelete() { - context.cs.launch { - try { - client.removeWorkspace(workspace) - // mark the env as deleting otherwise we will have to - // wait for the poller to update the status in the next 5 seconds - state.update { - WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context) - } + suspend fun deleteWorkspace() { + try { + client.removeWorkspace(workspace) + // mark the env as deleting otherwise we will have to + // wait for the poller to update the status in the next 5 seconds + state.update { + WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context) + } - context.cs.launch { - withTimeout(5.minutes) { - var workspaceStillExists = true - while (context.cs.isActive && workspaceStillExists) { - if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) { - workspaceStillExists = false - context.envPageManager.showPluginEnvironmentsPage() - } else { - delay(1.seconds) - } + context.cs.launch(CoroutineName("Workspace Deletion Poller")) { + withTimeout(5.minutes) { + var workspaceStillExists = true + while (context.cs.isActive && workspaceStillExists) { + if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) { + workspaceStillExists = false + context.envPageManager.showPluginEnvironmentsPage() + } else { + delay(1.seconds) } } } - } catch (e: APIResponseException) { - context.ui.showErrorInfoPopup(e) } + } catch (e: APIResponseException) { + context.ui.showErrorInfoPopup(e) } } + fun isConnected(): Boolean = isConnected.value + /** * An environment is equal if it has the same ID. */ diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 99396454..6084880e 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -1,25 +1,31 @@ package com.coder.toolbox +import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.WorkspaceStatus -import com.coder.toolbox.settings.SettingSource import com.coder.toolbox.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi +import com.coder.toolbox.util.waitForTrue +import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action +import com.coder.toolbox.views.CoderCliSetupWizardPage +import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.CoderSettingsPage -import com.coder.toolbox.views.ConnectPage import com.coder.toolbox.views.NewEnvironmentPage -import com.coder.toolbox.views.SignInPage -import com.coder.toolbox.views.TokenPage +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.core.util.LoadableState +import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProvider -import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -30,9 +36,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select -import java.net.SocketTimeoutException import java.net.URI -import java.net.URL import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -51,134 +55,165 @@ class CoderRemoteProvider( private val settings = context.settingsStore.readOnly() - // Create our services from the Toolbox ones. private val triggerSshConfig = Channel(Channel.CONFLATED) - private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) + private val triggerProviderVisible = Channel(Channel.CONFLATED) private val dialogUi = DialogUi(context) // The REST client, if we are signed in private var client: CoderRestClient? = null - // If we have an error in the polling we store it here before going back to - // sign-in page, so we can display it there. This is mainly because there - // does not seem to be a mechanism to show errors on the environment list. - private var pollError: Exception? = null - // On the first load, automatically log in if we can. private var firstRun = true + private val isInitialized: MutableStateFlow = MutableStateFlow(false) - private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: "")) - private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized) - override val environments: MutableStateFlow>> = MutableStateFlow( - LoadableState.Value(emptyList()) + private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) + private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) { + client?.let { restClient -> + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + } + } + } + private val visibilityState = MutableStateFlow( + ProviderVisibilityState( + applicationVisible = false, + providerVisible = false + ) + ) + private val linkHandler = CoderProtocolHandler(context, dialogUi, settingsPage, visibilityState, isInitialized) + + override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") + override val environments: MutableStateFlow>> = MutableStateFlow( + LoadableState.Loading ) + private val errorBuffer = mutableListOf() + /** * With the provided client, start polling for workspaces. Every time a new * workspace is added, reconfigure SSH using the provided cli (including the * first time). */ - private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = context.cs.launch { - var lastPollTime = TimeSource.Monotonic.markNow() - while (isActive) { - try { - context.logger.debug("Fetching workspace agents from ${client.url}") - val resolvedEnvironments = client.workspaces().flatMap { ws -> - // Agents are not included in workspaces that are off - // so fetch them separately. - when (ws.latestBuild.status) { - WorkspaceStatus.RUNNING -> ws.latestBuild.resources - else -> emptyList() - }.ifEmpty { - client.resources(ws) - }.flatMap { resource -> - resource.agents?.distinctBy { - // There can be duplicates with coder_agent_instance. - // TODO: Can we just choose one or do they hold - // different information? - it.name - }?.map { agent -> - // If we have an environment already, update that. - val env = CoderRemoteEnvironment(context, client, ws, agent) - lastEnvironments.firstOrNull { it == env }?.let { - it.update(ws, agent) - it - } ?: env - } ?: emptyList() - } - }.toSet() + private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = + context.cs.launch(CoroutineName("Workspace Poller")) { + var lastPollTime = TimeSource.Monotonic.markNow() + while (isActive) { + try { + context.logger.debug("Fetching workspace agents from ${client.url}") + val resolvedEnvironments = client.workspaces().flatMap { ws -> + // Agents are not included in workspaces that are off + // so fetch them separately. + when (ws.latestBuild.status) { + WorkspaceStatus.RUNNING -> ws.latestBuild.resources + else -> emptyList() + }.ifEmpty { + client.resources(ws) + }.flatMap { resource -> + resource.agents?.distinctBy { + // There can be duplicates with coder_agent_instance. + // TODO: Can we just choose one or do they hold + // different information? + it.name + }?.map { agent -> + // If we have an environment already, update that. + val env = CoderRemoteEnvironment(context, client, cli, ws, agent) + lastEnvironments.firstOrNull { it == env }?.let { + it.update(ws, agent) + it + } ?: env + } ?: emptyList() + } + }.toSet() - // In case we logged out while running the query. - if (!isActive) { - return@launch - } + // In case we logged out while running the query. + if (!isActive) { + return@launch + } + // Reconfigure if environments changed. + if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { + context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") + cli.configSsh(resolvedEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) + } - // Reconfigure if environments changed. - if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { - context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") - cli.configSsh(resolvedEnvironments.map { it.name }.toSet()) - } + environments.update { + LoadableState.Value(resolvedEnvironments.toList()) + } + if (!isInitialized.value) { + context.logger.info("Environments for ${client.url} are now initialized") + isInitialized.update { + true + } + } + lastEnvironments.apply { + clear() + addAll(resolvedEnvironments.sortedBy { it.id }) + } - environments.update { - LoadableState.Value(resolvedEnvironments.toList()) - } - if (isInitialized.value == false) { - context.logger.info("Environments for ${client.url} are now initialized") - isInitialized.update { - true + if (WorkspaceConnectionManager.shouldEstablishWorkspaceConnections) { + WorkspaceConnectionManager.allConnected().forEach { wsId -> + val env = lastEnvironments.firstOrNull() { it.id == wsId } + if (env != null && !env.isConnected()) { + context.logger.info("Establishing lost SSH connection for workspace with id $wsId") + if (!env.startSshConnection()) { + context.logger.info("Can't establish lost SSH connection for workspace with id $wsId") + } + } + } + WorkspaceConnectionManager.reset() } - } - lastEnvironments.apply { - clear() - addAll(resolvedEnvironments.sortedBy { it.id }) - } - } catch (_: CancellationException) { - context.logger.debug("${client.url} polling loop canceled") - break - } catch (ex: SocketTimeoutException) { - val elapsed = lastPollTime.elapsedNow() - if (elapsed > POLL_INTERVAL * 2) { - context.logger.info("wake-up from an OS sleep was detected, going to re-initialize the http client...") - client.setupSession() - } else { - context.logger.error(ex, "workspace polling error encountered") - pollError = ex - logout() + + WorkspaceConnectionManager.collectStatuses(lastEnvironments) + } catch (_: CancellationException) { + context.logger.debug("${client.url} polling loop canceled") break + } catch (ex: Exception) { + val elapsed = lastPollTime.elapsedNow() + if (elapsed > POLL_INTERVAL * 2) { + context.logger.info("wake-up from an OS sleep was detected") + } else { + context.logger.error(ex, "workspace polling error encountered") + if (ex is APIResponseException && ex.isTokenExpired) { + WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true + close() + context.envPageManager.showPluginEnvironmentsPage() + errorBuffer.add(ex) + break + } + } } - } catch (ex: Exception) { - context.logger.error(ex, "workspace polling error encountered") - pollError = ex - logout() - break - } - // TODO: Listening on a web socket might be better? - select { - onTimeout(POLL_INTERVAL) { - context.logger.trace("workspace poller waked up by the $POLL_INTERVAL timeout") - } - triggerSshConfig.onReceive { shouldTrigger -> - if (shouldTrigger) { - context.logger.trace("workspace poller waked up because it should reconfigure the ssh configurations") - cli.configSsh(lastEnvironments.map { it.name }.toSet()) + select { + onTimeout(POLL_INTERVAL) { + context.logger.debug("workspace poller waked up by the $POLL_INTERVAL timeout") + } + triggerSshConfig.onReceive { shouldTrigger -> + if (shouldTrigger) { + context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations") + cli.configSsh(lastEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet()) + } + } + triggerProviderVisible.onReceive { isCoderProviderVisible -> + if (isCoderProviderVisible) { + context.logger.debug("workspace poller waked up by Coder Toolbox which is currently visible, fetching latest workspace statuses") + } } } + lastPollTime = TimeSource.Monotonic.markNow() } - lastPollTime = TimeSource.Monotonic.markNow() } - } /** * Stop polling, clear the client and environments, then go back to the * first page. */ private fun logout() { - // Keep the URL and token to make it easy to log back in, but set - // rememberMe to false so we do not try to automatically log in. - context.secrets.rememberMe = "false" + context.logger.info("Logging out ${client?.me?.username}...") + WorkspaceConnectionManager.reset() close() + context.logger.info("User ${client?.me?.username} logged out successfully") } /** @@ -187,14 +222,27 @@ class CoderRemoteProvider( override fun getAccountDropDown(): DropDownMenu? { val username = client?.me?.username if (username != null) { - return dropDownFactory(context.i18n.pnotr(username), { logout() }) + return dropDownFactory(context.i18n.pnotr(username)) { + logout() + context.envPageManager.showPluginEnvironmentsPage() + } } return null } override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( - Action(context.i18n.ptrl("Settings")) { + Action(context, "Create workspace") { + val url = context.settingsStore.workspaceCreateUrl ?: client?.url?.withPath("/templates").toString() + context.desktop.browse( + url + .replace("\$workspaceOwner", client?.me?.username ?: "") + ) { + context.ui.showErrorInfoPopup(it) + } + }, + CoderDelimiter(context.i18n.pnotr("")), + Action(context, "Settings") { context.ui.showUiPage(settingsPage) }, ) @@ -206,11 +254,20 @@ class CoderRemoteProvider( * Also called as part of our own logout. */ override fun close() { - pollJob?.cancel() - client?.close() + pollJob?.let { + it.cancel() + context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") + } + client?.let { + it.close() + context.logger.info("REST API client closed and resources released") + } + client = null lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } + CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Coder plugin is now closed") } override val svgIcon: SvgIcon = @@ -258,138 +315,137 @@ class CoderRemoteProvider( * a place to put a timer ("last updated 10 seconds ago" for example) * and a manual refresh button. */ - override fun setVisible(visibilityState: ProviderVisibilityState) {} + override fun setVisible(visibility: ProviderVisibilityState) { + visibilityState.update { + visibility + } + if (visibility.providerVisible) { + context.cs.launch(CoroutineName("Notify Plugin Visibility")) { + triggerProviderVisible.send(true) + } + } + } /** * Handle incoming links (like from the dashboard). */ override suspend fun handleUri(uri: URI) { - linkHandler.handle(uri, shouldDoAutoLogin()) { restClient, cli -> - // stop polling and de-initialize resources - close() - // start initialization with the new settings - this@CoderRemoteProvider.client = restClient - coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) - pollJob = poll(restClient, cli) + try { + linkHandler.handle( + uri, + shouldDoAutoSetup() + ) { restClient, cli -> + context.logger.info("Stopping workspace polling and de-initializing resources") + close() + isInitialized.update { + false + } + context.logger.info("Starting initialization with the new settings") + this@CoderRemoteProvider.client = restClient + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + } + environments.showLoadingMessage() + pollJob = poll(restClient, cli) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri") + isInitialized.waitForTrue() + } + } catch (ex: Exception) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + context.logAndShowError( + "Error encountered while handling Coder URI", + textError ?: "" + ) + context.envPageManager.showPluginEnvironmentsPage() } } - /** - * Make Toolbox ask for the page again. Use any time we need to change the - * root page (for example, sign-in or the environment list). - * - * When moving between related pages, instead use ui.showUiPage() and - * ui.hideUiPage() which stacks and has built-in back navigation, rather - * than using multiple root pages. - */ - private fun goToEnvironmentsPage() { - context.envPageManager.showPluginEnvironmentsPage() - } - /** * Return the sign-in page if we do not have a valid client. - * Otherwise return null, which causes Toolbox to display the environment + * Otherwise, return null, which causes Toolbox to display the environment * list. */ override fun getOverrideUiPage(): UiPage? { - // Show sign in page if we have not configured the client yet. + // Show the setup page if we have not configured the client yet. if (client == null) { - // When coming back to the application, authenticate immediately. - val autologin = shouldDoAutoLogin() - var autologinEx: Exception? = null - context.secrets.lastToken.let { lastToken -> - context.secrets.lastDeploymentURL.let { lastDeploymentURL -> - if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { - try { - return createConnectPage(URL(lastDeploymentURL), lastToken) - } catch (ex: Exception) { - autologinEx = ex - } + // When coming back to the application, initializeSession immediately. + if (shouldDoAutoSetup()) { + try { + CoderCliSetupContext.apply { + url = context.deploymentUrl + token = context.secrets.tokenFor(context.deploymentUrl) } + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + return CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = false, + onConnect = ::onConnect + ) + } catch (ex: Exception) { + errorBuffer.add(ex) + } finally { + firstRun = false } } - firstRun = false // Login flow. - val signInPage = - SignInPage(context, getDeploymentURL()) { deploymentURL -> - context.ui.showUiPage( - TokenPage( - context, - deploymentURL, - getToken(deploymentURL) - ) { selectedToken -> - context.ui.showUiPage(createConnectPage(deploymentURL, selectedToken)) - }, - ) - } - - // We might have tried and failed to automatically log in. - autologinEx?.let { signInPage.notify("Error logging in", it) } + val setupWizardPage = + CoderCliSetupWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect) // We might have navigated here due to a polling error. - pollError?.let { signInPage.notify("Error fetching workspaces", it) } - - return signInPage + errorBuffer.forEach { + setupWizardPage.notify("Error encountered", it) + } + errorBuffer.clear() + // and now reset the errors, otherwise we show it every time on the screen + return setupWizardPage } return null } - private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == "true" - /** - * Create a connect page that starts polling and resets the UI on success. + * Auto-login only on first the firs run if there is a url & token configured or the auth + * should be done via certificates. */ - private fun createConnectPage(deploymentURL: URL, token: String?): ConnectPage = ConnectPage( - context, - deploymentURL, - token, - ::goToEnvironmentsPage, - ) { client, cli -> + private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requireTokenAuth) + + fun canAutoLogin(): Boolean = !context.secrets.tokenFor(context.deploymentUrl).isNullOrBlank() + + private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. - context.secrets.lastDeploymentURL = client.url.toString() - context.secrets.lastToken = client.token ?: "" - // Currently we always remember, but this could be made an option. - context.secrets.rememberMe = "true" + context.settingsStore.updateLastUsedUrl(client.url) + if (context.settingsStore.requireTokenAuth) { + context.secrets.storeTokenFor(client.url, client.token ?: "") + context.logger.info("Deployment URL and token were stored and will be available for automatic connection") + } else { + context.logger.info("Deployment URL was stored and will be available for automatic connection") + } this.client = client - pollError = null - pollJob?.cancel() - pollJob = poll(client, cli) - goToEnvironmentsPage() - } - - /** - * Try to find a token. - * - * Order of preference: - * - * 1. Last used token, if it was for this deployment. - * 2. Token on disk for this deployment. - * 3. Global token for Coder, if it matches the deployment. - */ - private fun getToken(deploymentURL: URL): Pair? = context.secrets.lastToken.let { - if (it.isNotBlank() && context.secrets.lastDeploymentURL == deploymentURL.toString()) { - it to SettingSource.LAST_USED + pollJob?.let { + it.cancel() + context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one") + } + environments.showLoadingMessage() + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(client.appName)) } else { - settings.token(deploymentURL) + coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) } + context.logger.info("Displaying ${client.url} in the UI") + pollJob = poll(client, cli) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created") } - /** - * Try to find a URL. - * - * In order of preference: - * - * 1. Last used URL. - * 2. URL in settings. - * 3. CODER_URL. - * 4. URL in global cli config. - */ - private fun getDeploymentURL(): Pair? = context.secrets.lastDeploymentURL.let { - if (it.isNotBlank()) { - it to SettingSource.LAST_USED - } else { - context.settingsStore.defaultURL() + private fun MutableStateFlow>>.showLoadingMessage() { + this.update { + LoadableState.Loading } } -} +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 7e70d15b..ac3cbcc7 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -2,22 +2,93 @@ package com.coder.toolbox import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.util.toURL import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope +import java.net.URL +import java.util.UUID +@Suppress("UnstableApiUsage") data class CoderToolboxContext( val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, - val ideOrchestrator: ClientHelper, + val remoteIdeOrchestrator: RemoteToolsHelper, + val jbClientOrchestrator: ClientHelper, + val desktop: LocalDesktopManager, val cs: CoroutineScope, val logger: Logger, val i18n: LocalizableStringFactory, val settingsStore: CoderSettingsStore, - val secrets: CoderSecretsStore -) + val secrets: CoderSecretsStore, + val proxySettings: ToolboxProxySettings, +) { + /** + * Try to find a URL. + * + * In order of preference: + * + * 1. Last used URL from the settings. + * 2. Last used URL from the secrets store. + * 3. Default URL + */ + val deploymentUrl: URL + get() { + return settingsStore.lastDeploymentURL?.takeIf { it.isNotBlank() }?.toURL() + ?: secrets.lastDeploymentURL.takeIf { it.isNotBlank() }?.toURL() + ?: settingsStore.defaultURL.toURL() + } + + suspend fun logAndShowError(title: String, error: String) { + logger.error(error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowError(title: String, error: String, exception: Exception) { + logger.error(exception, error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowWarning(title: String, warning: String) { + logger.warn(warning) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(warning), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowInfo(title: String, info: String) { + logger.info(info) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(info), + i18n.ptrl("OK") + ) + } + + fun popupPluginMainPage() { + this.ui.showWindow() + this.envPageManager.showPluginEnvironmentsPage(true) + } +} diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index a310ee04..5cfcd11f 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -7,10 +7,14 @@ import com.jetbrains.toolbox.api.core.PluginSecretStore import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.ServiceLocator import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.core.getService +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi @@ -25,15 +29,18 @@ class CoderToolboxExtension : RemoteDevExtension { val logger = serviceLocator.getService(Logger::class.java) return CoderRemoteProvider( CoderToolboxContext( - serviceLocator.getService(ToolboxUi::class.java), - serviceLocator.getService(EnvironmentUiPageManager::class.java), - serviceLocator.getService(EnvironmentStateColorPalette::class.java), - serviceLocator.getService(ClientHelper::class.java), - serviceLocator.getService(CoroutineScope::class.java), - serviceLocator.getService(Logger::class.java), - serviceLocator.getService(LocalizableStringFactory::class.java), - CoderSettingsStore(serviceLocator.getService(PluginSettingsStore::class.java), Environment(), logger), - CoderSecretsStore(serviceLocator.getService(PluginSecretStore::class.java)), + serviceLocator.getService(), + serviceLocator.getService(), + serviceLocator.getService(), + serviceLocator.getService(), + serviceLocator.getService(), + serviceLocator.getService(), + serviceLocator.getService(), + serviceLocator.getService(), + serviceLocator.getService(), + CoderSettingsStore(serviceLocator.getService(), Environment(), logger), + CoderSecretsStore(serviceLocator.getService()), + serviceLocator.getService() ) ) } diff --git a/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt b/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt new file mode 100644 index 00000000..91967291 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt @@ -0,0 +1,22 @@ +package com.coder.toolbox + +object WorkspaceConnectionManager { + private val workspaceConnectionState = mutableMapOf() + + var shouldEstablishWorkspaceConnections = false + + fun allConnected(): Set = workspaceConnectionState.filter { it.value }.map { it.key }.toSet() + + fun collectStatuses(workspaces: Set) { + workspaces.forEach { register(it.id, it.isConnected()) } + } + + private fun register(wsId: String, isConnected: Boolean) { + workspaceConnectionState[wsId] = isConnected + } + + fun reset() { + workspaceConnectionState.clear() + shouldEstablishWorkspaceConnections = false + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt b/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt index 000263c8..37918b78 100644 --- a/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt +++ b/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt @@ -1,66 +1,19 @@ package com.coder.toolbox.browser -import com.coder.toolbox.util.OS -import com.coder.toolbox.util.getOS -import org.zeroturnaround.exec.ProcessExecutor +import com.coder.toolbox.util.toURL +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager -class BrowserUtil { - companion object { - suspend fun browse(url: String, errorHandler: suspend (BrowserException) -> Unit) { - val os = getOS() - if (os == null) { - errorHandler(BrowserException("Failed to open the URL because we can't detect the OS")) - return - } - when (os) { - OS.LINUX -> linuxBrowse(url, errorHandler) - OS.MAC -> macBrowse(url, errorHandler) - OS.WINDOWS -> windowsBrowse(url, errorHandler) - } - } - private suspend fun linuxBrowse(url: String, errorHandler: suspend (BrowserException) -> Unit) { - try { - if (OS.LINUX.getDesktopEnvironment()?.uppercase()?.contains("GNOME") == true) { - exec("gnome-open", url) - } else { - exec("xdg-open", url) - } - } catch (e: Exception) { - errorHandler( - BrowserException( - "Failed to open URL because an error was encountered. Please make sure xdg-open from package xdg-utils is available!", - e - ) - ) - } - } - - private suspend fun macBrowse(url: String, errorHandler: suspend (BrowserException) -> Unit) { - try { - exec("open", url) - } catch (e: Exception) { - errorHandler(BrowserException("Failed to open URL because an error was encountered.", e)) - } - } - - private suspend fun windowsBrowse(url: String, errorHandler: suspend (BrowserException) -> Unit) { - try { - exec("cmd", "start \"$url\"") - } catch (e: Exception) { - errorHandler(BrowserException("Failed to open URL because an error was encountered.", e)) - } - } - - private fun exec(vararg args: String): String { - val stdout = - ProcessExecutor() - .command(*args) - .exitValues(0) - .readOutput(true) - .execute() - .outputUTF8() - return stdout - } +suspend fun LocalDesktopManager.browse(rawUrl: String, errorHandler: suspend (BrowserException) -> Unit) { + try { + val url = rawUrl.toURL() + this.openUrl(url) + } catch (e: Exception) { + errorHandler( + BrowserException( + "Failed to open $rawUrl because an error was encountered", + e + ) + ) } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index d97caf8a..eb289af6 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -1,40 +1,41 @@ package com.coder.toolbox.cli import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.downloader.CoderDownloadApi +import com.coder.toolbox.cli.downloader.CoderDownloadService +import com.coder.toolbox.cli.downloader.DownloadResult +import com.coder.toolbox.cli.downloader.DownloadResult.Downloaded import com.coder.toolbox.cli.ex.MissingVersionException -import com.coder.toolbox.cli.ex.ResponseException import com.coder.toolbox.cli.ex.SSHConfigFormatException +import com.coder.toolbox.cli.ex.UnsignedBinaryExecutionDeniedException +import com.coder.toolbox.cli.gpg.GPGVerifier +import com.coder.toolbox.cli.gpg.VerificationResult +import com.coder.toolbox.cli.gpg.VerificationResult.Failed +import com.coder.toolbox.cli.gpg.VerificationResult.Invalid +import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder +import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent -import com.coder.toolbox.settings.ReadOnlyCoderSettings -import com.coder.toolbox.util.CoderHostnameVerifier +import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW import com.coder.toolbox.util.InvalidVersionException -import com.coder.toolbox.util.OS import com.coder.toolbox.util.SemVer -import com.coder.toolbox.util.coderSocketFactory import com.coder.toolbox.util.escape import com.coder.toolbox.util.escapeSubcommand -import com.coder.toolbox.util.getHeaders -import com.coder.toolbox.util.getOS import com.coder.toolbox.util.safeHost -import com.coder.toolbox.util.sha1 -import com.jetbrains.toolbox.api.core.diagnostics.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 org.zeroturnaround.exec.ProcessExecutor +import retrofit2.Retrofit import java.io.EOFException -import java.io.FileInputStream import java.io.FileNotFoundException -import java.net.ConnectException -import java.net.HttpURLConnection import java.net.URL import java.nio.file.Files import java.nio.file.Path -import java.nio.file.StandardCopyOption -import java.util.zip.GZIPInputStream -import javax.net.ssl.HttpsURLConnection /** * Version output from the CLI's version command. @@ -44,6 +45,7 @@ internal data class Version( @Json(name = "version") val version: String, ) + /** * Do as much as possible to get a valid, up-to-date CLI. * @@ -56,13 +58,19 @@ internal data class Version( * 6. Since the binary directory can be read-only, if downloading fails, start * from step 2 with the data directory. */ -fun ensureCLI( +suspend fun ensureCLI( context: CoderToolboxContext, deploymentURL: URL, buildVersion: String, + showTextProgress: (String) -> Unit ): CoderCLIManager { + fun reportProgress(msg: String) { + showTextProgress(msg) + context.logger.info(msg) + } + val settings = context.settingsStore.readOnly() - val cli = CoderCLIManager(deploymentURL, context.logger, settings) + val cli = CoderCLIManager(context, deploymentURL) // Short-circuit if we already have the expected version. This // lets us bypass the 304 which is slower and may not be @@ -71,14 +79,15 @@ fun ensureCLI( // the 304 method. val cliMatches = cli.matchesVersion(buildVersion) if (cliMatches == true) { + reportProgress("Local CLI version matches server version: $buildVersion") return cli } // If downloads are enabled download the new version. if (settings.enableDownloads) { - context.logger.info("Downloading Coder CLI...") + reportProgress("Downloading Coder CLI...") try { - cli.download() + cli.download(buildVersion, showTextProgress) return cli } catch (e: java.nio.file.AccessDeniedException) { // Might be able to fall back to the data directory. @@ -91,15 +100,16 @@ fun ensureCLI( } // Try falling back to the data directory. - val dataCLI = CoderCLIManager(deploymentURL, context.logger, settings, true) + val dataCLI = CoderCLIManager(context, deploymentURL, true) val dataCLIMatches = dataCLI.matchesVersion(buildVersion) if (dataCLIMatches == true) { + reportProgress("Local CLI version from data directory matches server version: $buildVersion") return dataCLI } if (settings.enableDownloads) { - context.logger.info("Downloading Coder CLI...") - dataCLI.download() + reportProgress("Downloading Coder CLI to the data directory...") + dataCLI.download(buildVersion, showTextProgress) return dataCLI } @@ -115,98 +125,176 @@ 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( + private val context: CoderToolboxContext, // The URL of the deployment this CLI is for. private val deploymentURL: URL, - private val logger: Logger, - // Plugin configuration. - private val settings: ReadOnlyCoderSettings, // If the binary directory is not writable, this can be used to force the // manager to download to the data directory instead. - forceDownloadToData: Boolean = false, + private val forceDownloadToData: Boolean = false, ) { - val remoteBinaryURL: URL = settings.binSource(deploymentURL) - val localBinaryPath: Path = settings.binPath(deploymentURL, forceDownloadToData) - val coderConfigPath: Path = settings.dataDir(deploymentURL).resolve("config") + private val downloader = createDownloadService() + private val gpgVerifier = GPGVerifier(context) + + val remoteBinaryURL: URL = context.settingsStore.binSource(deploymentURL) + val localBinaryPath: Path = context.settingsStore.binPath(deploymentURL, forceDownloadToData) + val coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config") + + private fun createDownloadService(): CoderDownloadService { + val interceptors = buildList { + add((Interceptors.userAgent(PluginManager.pluginInfo.version))) + add(Interceptors.logging(context)) + } + val okHttpClient = CoderHttpClientBuilder.build( + context, + interceptors + ) + + val retrofit = Retrofit.Builder() + .baseUrl(deploymentURL.toString()) + .client(okHttpClient) + .build() + + val service = retrofit.create(CoderDownloadApi::class.java) + return CoderDownloadService(context, service, deploymentURL, forceDownloadToData) + } /** * Download the CLI from the deployment if necessary. */ - fun download(): Boolean { - val eTag = getBinaryETag() - val conn = remoteBinaryURL.openConnection() as HttpURLConnection - if (!settings.headerCommand.isNullOrBlank()) { - val headersFromHeaderCommand = getHeaders(deploymentURL, settings.headerCommand) - for ((key, value) in headersFromHeaderCommand) { - conn.setRequestProperty(key, value) + suspend fun download(buildVersion: String, showTextProgress: (String) -> Unit): 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 Downloaded + } } - } - if (eTag != null) { - logger.info("Found existing binary at $localBinaryPath; calculated hash as $eTag") - conn.setRequestProperty("If-None-Match", "\"$eTag\"") - } - conn.setRequestProperty("Accept-Encoding", "gzip") - if (conn is HttpsURLConnection) { - conn.sslSocketFactory = coderSocketFactory(settings.tls) - conn.hostnameVerifier = CoderHostnameVerifier(settings.tls.altHostname) - } - try { - conn.connect() - logger.info("GET ${conn.responseCode} $remoteBinaryURL") - when (conn.responseCode) { - HttpURLConnection.HTTP_OK -> { - logger.info("Downloading binary to $localBinaryPath") - Files.createDirectories(localBinaryPath.parent) - conn.inputStream.use { - Files.copy( - if (conn.contentEncoding == "gzip") GZIPInputStream(it) else it, - localBinaryPath, - StandardCopyOption.REPLACE_EXISTING, + if (context.settingsStore.disableSignatureVerification) { + downloader.commit() + context.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 (context.settingsStore.fallbackOnCoderForSignatures == ALLOW) { + context.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 = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("Could not fetch any signatures for ${cliResult.source} from releases.coder.com. Would you like to run it anyway?"), + context.i18n.ptrl("Accept"), + context.i18n.ptrl("Abort"), ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") + } } - if (getOS() != OS.WINDOWS) { - localBinaryPath.toFile().setExecutable(true) + } 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 = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("No signatures were found for ${cliResult.source} and fallback to releases.coder.com is not allowed. Would you like to run it anyway?"), + context.i18n.ptrl("Accept"), + context.i18n.ptrl("Abort"), + ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") } - return true } + } + + // we have the cli, and signature is downloaded, let's verify the signature + signatureResult = signatureResult as Downloaded + gpgVerifier.verifySignature(cliResult.dst, signatureResult.dst).let { result -> + when { + result.isValid() -> { + downloader.commit() + return true + } - HttpURLConnection.HTTP_NOT_MODIFIED -> { - logger.info("Using cached binary at $localBinaryPath") - return false + else -> { + logFailure(result, cliResult, signatureResult) + // prompt the user if he wants to accept the risk + val shouldRunAnyway = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("Could not verify the authenticity of the ${cliResult.source}, it may be tampered with. Would you like to run it anyway?"), + context.i18n.ptrl("Run anyway"), + context.i18n.ptrl("Abort"), + ) + + if (shouldRunAnyway) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unverified CLI from ${cliResult.source} was denied by the user") + } + } } } - } catch (e: ConnectException) { - // Add the URL so this is more easily debugged. - throw ConnectException("${e.message} to $remoteBinaryURL") } finally { - conn.disconnect() + downloader.cleanup() } - throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode) } - /** - * Return the entity tag for the binary on disk, if any. - */ - private fun getBinaryETag(): String? = try { - sha1(FileInputStream(localBinaryPath.toFile())) - } catch (e: FileNotFoundException) { - null - } catch (e: Exception) { - logger.warn(e, "Unable to calculate hash for $localBinaryPath") - null + private fun logFailure( + result: VerificationResult, + cliResult: Downloaded, + signatureResult: Downloaded + ) { + when { + result.isInvalid() -> { + val reason = (result as Invalid).reason + context.logger.error("Signature of ${cliResult.dst} is invalid." + reason?.let { " Reason: $it" } + .orEmpty()) + } + + result.signatureIsNotFound() -> { + context.logger.error("Can't verify signature of ${cliResult.dst} because ${signatureResult.dst} does not exist") + } + + else -> { + val failure = result as Failed + UnsignedBinaryExecutionDeniedException(result.error.message) + context.logger.error(failure.error, "Failed to verify signature for ${cliResult.dst}") + } + } } /** - * Use the provided token to authenticate the CLI. + * Use the provided token to initializeSession the CLI. */ fun login(token: String): String { - logger.info("Storing CLI credentials in $coderConfigPath") + context.logger.info("Storing CLI credentials in $coderConfigPath") return exec( "login", deploymentURL.toString(), @@ -217,25 +305,45 @@ class CoderCLIManager( ) } + /** + * 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()) + } + /** * Configure SSH to use this binary. * * This can take supported features for testing purposes only. */ fun configSsh( - workspaceNames: Set, + wsWithAgents: Set>, feats: Features = features, ) { - logger.info("Configuring SSH config at ${settings.sshConfigPath}") - writeSSHConfig(modifySSHConfig(readSSHConfig(), workspaceNames, feats)) + context.logger.info("Configuring SSH config at ${context.settingsStore.sshConfigPath}") + writeSSHConfig(modifySSHConfig(readSSHConfig(), wsWithAgents, feats)) + context.logger.info("Finished configuring SSH config") } /** * Return the contents of the SSH config or null if it does not exist. */ private fun readSSHConfig(): String? = try { - Path.of(settings.sshConfigPath).toFile().readText() - } catch (e: FileNotFoundException) { + Path.of(context.settingsStore.sshConfigPath).toFile().readText() + } catch (_: FileNotFoundException) { null } @@ -249,13 +357,13 @@ class CoderCLIManager( */ private fun modifySSHConfig( contents: String?, - workspaceNames: Set, + wsWithAgents: Set>, feats: Features, ): String? { val host = deploymentURL.safeHost() val startBlock = "# --- START CODER JETBRAINS TOOLBOX $host" val endBlock = "# --- END CODER JETBRAINS TOOLBOX $host" - val isRemoving = workspaceNames.isEmpty() + val isRemoving = wsWithAgents.isEmpty() val baseArgs = listOfNotNull( escape(localBinaryPath.toString()), @@ -266,25 +374,22 @@ class CoderCLIManager( // always use the correct URL. "--url", escape(deploymentURL.toString()), - if (!settings.headerCommand.isNullOrBlank()) "--header-command" else null, - if (!settings.headerCommand.isNullOrBlank()) escapeSubcommand(settings.headerCommand!!) else null, + context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { "--header-command" }, + context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { escapeSubcommand(it) }, "ssh", "--stdio", - if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, + if (context.settingsStore.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, + "--network-info-dir ${escape(context.settingsStore.networkInfoDir)}" ) val proxyArgs = baseArgs + listOfNotNull( - if (!settings.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, - if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory!!) else null, + context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let { "--log-dir" }, + context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let { escape(it) }, 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.isNullOrBlank()) { - "\n" + settings.sshConfigOptions!!.prependIndent(" ") - } else { - "" - } + val extraConfig = context.settingsStore.sshConfigOptions + ?.takeIf { it.isNotBlank() } + ?.let { "\n" + it.prependIndent(" ") } + ?: "" val options = """ ConnectTimeout 0 StrictHostKeyChecking no @@ -293,7 +398,7 @@ class CoderCLIManager( SetEnv CODER_SSH_SESSION_TYPE=JetBrains """.trimIndent() - val blockContent = if (settings.isSshWildcardConfigEnabled && feats.wildcardSsh) { + val blockContent = if (context.settingsStore.isSshWildcardConfigEnabled && feats.wildcardSsh) { startBlock + System.lineSeparator() + """ Host ${getHostnamePrefix(deploymentURL)}--* @@ -301,47 +406,29 @@ class CoderCLIManager( """.trimIndent() .plus("\n" + options.prependIndent(" ")) .plus(extraConfig) - .plus("\n\n") - .plus( - """ - Host ${getHostnamePrefix(deploymentURL)}-bg--* - ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${ - getHostnamePrefix( - deploymentURL - ) - }-bg-- %h - """.trimIndent() - .plus("\n" + options.prependIndent(" ")) - .plus(extraConfig), - ).replace("\n", System.lineSeparator()) + + .plus("\n") + .replace("\n", System.lineSeparator()) + System.lineSeparator() + endBlock } else { - workspaceNames.joinToString( + wsWithAgents.joinToString( System.lineSeparator(), startBlock + System.lineSeparator(), System.lineSeparator() + endBlock, transform = { """ - Host ${getHostName(deploymentURL, it)} - ProxyCommand ${proxyArgs.joinToString(" ")} $it + Host ${getHostname(deploymentURL, it.workspace(), it.agent())} + ProxyCommand ${proxyArgs.joinToString(" ")} ${getWsByOwner(it.workspace(), it.agent())} """.trimIndent() .plus("\n" + options.prependIndent(" ")) .plus(extraConfig) .plus("\n") - .plus( - """ - Host ${getBackgroundHostName(deploymentURL, it)} - ProxyCommand ${backgroundProxyArgs.joinToString(" ")} $it - """.trimIndent() - .plus("\n" + options.prependIndent(" ")) - .plus(extraConfig), - ).replace("\n", System.lineSeparator()) + .replace("\n", System.lineSeparator()) }, ) } if (contents == null) { - logger.info("No existing SSH config to modify") + context.logger.info("No existing SSH config to modify") return blockContent + System.lineSeparator() } @@ -349,12 +436,12 @@ class CoderCLIManager( val end = "$endBlock(\\s*)".toRegex().find(contents) if (start == null && end == null && isRemoving) { - logger.info("No workspaces and no existing config blocks to remove") + context.logger.info("No workspaces and no existing config blocks to remove") return null } if (start == null && end == null) { - logger.info("Appending config block") + context.logger.info("Appending config block") val toAppend = if (contents.isEmpty()) { blockContent @@ -378,7 +465,7 @@ class CoderCLIManager( } if (isRemoving) { - logger.info("No workspaces; removing config block") + context.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 @@ -389,7 +476,7 @@ class CoderCLIManager( ).joinToString("") } - logger.info("Replacing existing config block") + context.logger.info("Replacing existing config block") return listOf( contents.substring(0, start.range.first), start.groupValues[1], // Leading newline(s). @@ -404,14 +491,14 @@ class CoderCLIManager( */ private fun writeSSHConfig(contents: String?) { if (contents != null) { - if (!settings.sshConfigPath.isNullOrBlank()) { - val sshConfPath = Path.of(settings.sshConfigPath) + if (context.settingsStore.sshConfigPath.isNotBlank()) { + val sshConfPath = Path.of(context.settingsStore.sshConfigPath) sshConfPath.parent.toFile().mkdirs() sshConfPath.toFile().writeText(contents) } // The Coder cli will *not* create the log directory. - if (!settings.sshLogDirectory.isNullOrBlank()) { - Path.of(settings.sshLogDirectory).toFile().mkdirs() + if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) { + Path.of(context.settingsStore.sshLogDirectory).toFile().mkdirs() } } } @@ -429,9 +516,9 @@ class CoderCLIManager( throw MissingVersionException("No version found in output") } return SemVer.parse(json.version) - } catch (exception: JsonDataException) { + } catch (_: JsonDataException) { throw MissingVersionException("No version found in output") - } catch (exception: EOFException) { + } catch (_: EOFException) { throw MissingVersionException("No version found in output") } } @@ -444,14 +531,14 @@ class CoderCLIManager( } catch (e: Exception) { when (e) { is InvalidVersionException -> { - logger.info("Got invalid version from $localBinaryPath: ${e.message}") + context.logger.info("Got invalid version from $localBinaryPath: ${e.message}") } else -> { - // An error here most likely means the CLI does not exist or + // 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}") + context.logger.info("Unable to determine $localBinaryPath version: ${e.message}") } } null @@ -464,17 +551,18 @@ class CoderCLIManager( * version could not be parsed. */ fun matchesVersion(rawBuildVersion: String): Boolean? { + if (Files.notExists(localBinaryPath)) return null val cliVersion = tryVersion() ?: return null val buildVersion = try { SemVer.parse(rawBuildVersion) - } catch (e: InvalidVersionException) { - logger.info("Got invalid build version: $rawBuildVersion") + } catch (_: InvalidVersionException) { + context.logger.info("Got invalid build version: $rawBuildVersion") return null } val matches = cliVersion == buildVersion - logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches") + context.logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches") return matches } @@ -482,13 +570,13 @@ class CoderCLIManager( val stdout = ProcessExecutor() .command(localBinaryPath.toString(), *args) - .environment("CODER_HEADER_COMMAND", settings.headerCommand) + .environment("CODER_HEADER_COMMAND", context.settingsStore.headerCommand) .exitValues(0) .readOutput(true) .execute() .outputUTF8() val redactedArgs = listOf(*args).joinToString(" ").replace(tokenRegex, "--token ") - logger.info("`$localBinaryPath $redactedArgs`: $stdout") + context.logger.info("`$localBinaryPath $redactedArgs`: $stdout") return stdout } @@ -501,30 +589,30 @@ class CoderCLIManager( Features( disableAutostart = version >= SemVer(2, 5, 0), reportWorkspaceUsage = version >= SemVer(2, 13, 0), - version >= SemVer(2, 19, 0), + wildcardSsh = version >= SemVer(2, 19, 0), + buildReason = version >= SemVer(2, 25, 0), ) } } + fun getHostname(url: URL, ws: Workspace, agent: WorkspaceAgent): String { + return if (context.settingsStore.isSshWildcardConfigEnabled && features.wildcardSsh) { + "${getHostnamePrefix(url)}--${ws.ownerName}--${ws.name}.${agent.name}" + } else { + "coder-jetbrains-toolbox--${ws.ownerName}--${ws.name}.${agent.name}--${url.safeHost()}" + } + } + companion object { private val tokenRegex = "--token [^ ]+".toRegex() - fun getHostnamePrefix(url: URL): String = "coder-jetbrains-toolbox-${url.safeHost()}" - - fun getWildcardHostname(url: URL, workspace: Workspace, agent: WorkspaceAgent): String = - "${getHostnamePrefix(url)}-bg--${workspace.name}.${agent.name}" + private fun getHostnamePrefix(url: URL): String = "coder-jetbrains-toolbox-${url.safeHost()}" - fun getHostname(url: URL, workspace: Workspace, agent: WorkspaceAgent) = - getHostName(url, "${workspace.name}.${agent.name}") + private fun getWsByOwner(ws: Workspace, agent: WorkspaceAgent): String = + "${ws.ownerName}/${ws.name}.${agent.name}" - fun getHostName( - url: URL, - workspaceName: String, - ): String = "coder-jetbrains-toolbox-$workspaceName--${url.safeHost()}" + private fun Pair.workspace() = this.first - fun getBackgroundHostName( - url: URL, - workspaceName: String, - ): String = getHostName(url, workspaceName) + "--bg" + private fun Pair.agent() = this.second } } diff --git a/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt b/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt new file mode 100644 index 00000000..1d36e4f7 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt @@ -0,0 +1,42 @@ +package com.coder.toolbox.cli + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.v2.models.Workspace +import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import kotlin.jvm.optionals.getOrNull + +/** + * Identifies the PID for the SSH Coder command spawned by Toolbox. + */ +class SshCommandProcessHandle(private val ctx: CoderToolboxContext) { + + /** + * Finds the PID of a Coder (not the proxy command) ssh cmd associated with the specified workspace and agent. + * Null is returned when no ssh command process was found. + * + * Implementation Notes: + * An iterative DFS approach where we start with Toolbox's direct children, grep the command + * and if nothing is found we continue with the processes children. Toolbox spawns an ssh command + * as a separate command which in turns spawns another child for the proxy command. + */ + fun findByWorkspaceAndAgent(ws: Workspace, agent: WorkspaceAgent): Long? { + val stack = ArrayDeque(ProcessHandle.current().children().toList()) + while (stack.isNotEmpty()) { + val processHandle = stack.removeLast() + val cmdLine = processHandle.info().commandLine().getOrNull() + ctx.logger.debug("SSH command PID: ${processHandle.pid()} Command: $cmdLine") + if (cmdLine != null && cmdLine.isSshCommandFor(ws, agent)) { + ctx.logger.debug("SSH command with PID: ${processHandle.pid()} and Command: $cmdLine matches ${ws.name}.${agent.name}") + return processHandle.pid() + } else { + stack.addAll(processHandle.children().toList()) + } + } + return null + } + + private fun String.isSshCommandFor(ws: Workspace, agent: WorkspaceAgent): Boolean { + // usage-app is present only in the ProxyCommand + return !this.contains("--usage-app=jetbrains") && this.contains("${ws.name}.${agent.name}") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt new file mode 100644 index 00000000..4e27569d --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt @@ -0,0 +1,29 @@ +package com.coder.toolbox.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/toolbox/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt new file mode 100644 index 00000000..2c2e87c6 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -0,0 +1,253 @@ +package com.coder.toolbox.cli.downloader + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.ex.ResponseException +import com.coder.toolbox.util.OS +import com.coder.toolbox.util.SemVer +import com.coder.toolbox.util.getHeaders +import com.coder.toolbox.util.getOS +import com.coder.toolbox.util.sha1 +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 + +private val SUPPORTED_BIN_MIME_TYPES = listOf( + "application/octet-stream", + "application/exe", + "application/dos-exe", + "application/msdos-windows", + "application/x-exe", + "application/x-msdownload", + "application/x-winexe", + "application/x-msdos-program", + "application/x-msdos-executable", + "application/x-ms-dos-executable", + "application/vnd.microsoft.portable-executable" +) +/** + * Handles the download steps of Coder CLI + */ +class CoderDownloadService( + private val context: CoderToolboxContext, + private val downloadApi: CoderDownloadApi, + private val deploymentUrl: URL, + forceDownloadToData: Boolean, +) { + private val remoteBinaryURL: URL = context.settingsStore.binSource(deploymentUrl) + private val cliFinalDst: Path = context.settingsStore.binPath(deploymentUrl, forceDownloadToData) + private val cliTempDst: Path = cliFinalDst.resolveSibling("${cliFinalDst.name}.tmp") + + suspend fun downloadCli(buildVersion: String, showTextProgress: (String) -> Unit): DownloadResult { + val eTag = calculateLocalETag() + if (eTag != null) { + context.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 -> { + val contentType = response.headers()["Content-Type"]?.lowercase() + if (contentType !in SUPPORTED_BIN_MIME_TYPES) { + throw ResponseException( + "Invalid content type '$contentType' when downloading CLI from $remoteBinaryURL. Expected application/octet-stream.", + response.code() + ) + } + context.logger.info("Downloading binary to temporary $cliTempDst") + response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable() + DownloadResult.Downloaded(remoteBinaryURL, cliTempDst) + } + + HTTP_NOT_MODIFIED -> { + context.logger.info("Using cached binary at $cliFinalDst") + showTextProgress("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) { + context.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 -> + context.logger.warn(ex, "Failed to delete temporary CLI file: $cliTempDst") + } + } + } + + private fun calculateLocalETag(): String? { + return try { + if (cliFinalDst.notExists()) { + return null + } + sha1(FileInputStream(cliFinalDst.toFile())) + } catch (e: Exception) { + context.logger.warn(e, "Unable to calculate hash for $cliFinalDst") + null + } + } + + private fun getRequestHeaders(): Map { + return if (context.settingsStore.headerCommand.isNullOrBlank()) { + emptyMap() + } else { + getHeaders(deploymentUrl, context.settingsStore.headerCommand) + } + } + + private fun Response.saveToDisk( + localPath: Path, + showTextProgress: (String) -> Unit, + 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( + "$binaryName $prettyBuildVersion - ${totalRead.toHumanReadableSize()} downloaded" + ) + } + } + } + return cliFinalDst + } + + + private fun Path.makeExecutable() { + if (getOS() != OS.WINDOWS) { + context.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: (String) -> Unit): DownloadResult { + return downloadSignature(remoteBinaryURL, showTextProgress, getRequestHeaders()) + } + + private suspend fun downloadSignature( + url: URL, + showTextProgress: (String) -> Unit, + headers: Map = emptyMap() + ): DownloadResult { + val signatureURL = url.toURI().resolve(context.settingsStore.defaultSignatureNameByOsAndArch).toURL() + val localSignaturePath = cliFinalDst.parent.resolve(context.settingsStore.defaultSignatureNameByOsAndArch) + context.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 -> { + context.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: (String) -> Unit + ): DownloadResult { + val semVer = SemVer.parse(buildVersion) + return downloadSignature( + URI.create("https://releases.coder.com/coder-cli/${semVer.major}.${semVer.minor}.${semVer.patch}/").toURL(), + showTextProgress + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt new file mode 100644 index 00000000..29d4fda4 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt @@ -0,0 +1,23 @@ +package com.coder.toolbox.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/toolbox/cli/ex/Exceptions.kt b/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt index d3ca3a48..9fdff549 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt @@ -5,3 +5,5 @@ 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) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt b/src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt new file mode 100644 index 00000000..490b48ef --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt @@ -0,0 +1,137 @@ +package com.coder.toolbox.cli.gpg + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.gpg.VerificationResult.Failed +import com.coder.toolbox.cli.gpg.VerificationResult.Invalid +import com.coder.toolbox.cli.gpg.VerificationResult.SignatureNotFound +import com.coder.toolbox.cli.gpg.VerificationResult.Valid +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 context: CoderToolboxContext, +) { + + suspend fun verifySignature( + cli: Path, + signature: Path, + ): VerificationResult { + return try { + if (!Files.exists(signature)) { + context.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) { + context.logger.error(e, "GPG signature verification failed") + 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() + context.logger.info("GPG signature verification result: $isValid") + if (isValid) { + return Valid + } + return Invalid() + } catch (e: Exception) { + context.logger.error(e, "GPG signature verification failed") + 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt b/src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt new file mode 100644 index 00000000..eafafcd6 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt @@ -0,0 +1,15 @@ +package com.coder.toolbox.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/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index 35997821..1f48a10e 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -7,10 +7,13 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.jetbrains.toolbox.api.core.ui.color.StateColor -import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState +import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentStateV2 import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState + +private val CircularSpinner: EnvironmentStateIcons = EnvironmentStateIcons.Connecting + /** * WorkspaceAndAgentStatus represents the combined status of a single agent and * its workspace (or just the workspace if there are no agents). @@ -58,43 +61,43 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { * Note that a reachable environment will always display "connected" or * "disconnected" regardless of the label we give that status. */ - fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentState { - return CustomRemoteEnvironmentState( - label, - getStateColor(context), - ready(), // reachable + fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr(label), + color = getStateColor(context), + isReachable = ready() || unhealthy(), // TODO@JB: How does this work? Would like a spinner for pending states. - getStateIcon() + iconId = getStateIcon().id, + isPriorityShow = true ) } private fun getStateColor(context: CoderToolboxContext): StateColor { - return if (ready()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Active) - else if (canStart()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Failed) - else if (pending()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating) + return if (this == FAILED) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.FailedToStart) else if (this == DELETING) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleting) else if (this == DELETED) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleted) + else if (ready()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Active) + else if (unhealthy()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unhealthy) + else if (canStart() || this == STOPPING) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Hibernating) + else if (pending()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating) else context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unreachable) } private fun getStateIcon(): EnvironmentStateIcons { - return if (ready()) EnvironmentStateIcons.Active - else if (canStart()) EnvironmentStateIcons.Hibernated - else if (pending()) EnvironmentStateIcons.Connecting - else if (this == DELETING || this == DELETED) EnvironmentStateIcons.Offline + return if (this == FAILED) EnvironmentStateIcons.Error + else if (pending() || this == DELETING || this == DELETED || this == STOPPING) CircularSpinner + else if (ready() || unhealthy()) EnvironmentStateIcons.Active + else if (canStart()) EnvironmentStateIcons.Offline else EnvironmentStateIcons.NoIcon } /** * 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) + fun ready(): Boolean = this == READY + + fun unhealthy(): Boolean { + return listOf(START_ERROR, START_TIMEOUT_READY) .contains(this) } @@ -103,7 +106,7 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { */ fun pending(): Boolean { // See ready() for why `CREATED` is not in this list. - return listOf(CONNECTING, TIMEOUT, AGENT_STARTING, START_TIMEOUT, QUEUED, STARTING) + return listOf(CREATED, CONNECTING, TIMEOUT, AGENT_STARTING, START_TIMEOUT, QUEUED, STARTING) .contains(this) } @@ -116,7 +119,7 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { /** * Return true if the workspace can be stopped. */ - fun canStop(): Boolean = ready() || pending() + fun canStop(): Boolean = ready() || pending() || unhealthy() // We want to check that the workspace is `running`, the agent is // `connected`, and the agent lifecycle state is `ready` to ensure the best diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt new file mode 100644 index 00000000..86474d9c --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt @@ -0,0 +1,56 @@ +package com.coder.toolbox.sdk + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.CoderHostnameVerifier +import com.coder.toolbox.util.coderSocketFactory +import com.coder.toolbox.util.coderTrustManagers +import com.jetbrains.toolbox.api.remoteDev.connection.ProxyAuth +import okhttp3.Credentials +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import javax.net.ssl.X509TrustManager + +object CoderHttpClientBuilder { + fun build( + context: CoderToolboxContext, + interceptors: List + ): OkHttpClient { + val settings = context.settingsStore.readOnly() + + val socketFactory = coderSocketFactory(settings.tls) + val trustManagers = coderTrustManagers(settings.tls.caPath) + var builder = OkHttpClient.Builder() + + context.proxySettings.getProxy()?.let { proxy -> + context.logger.info("proxy: $proxy") + builder.proxy(proxy) + } ?: context.proxySettings.getProxySelector()?.let { proxySelector -> + context.logger.info("proxy selector: $proxySelector") + builder.proxySelector(proxySelector) + } + + // Note: This handles only HTTP/HTTPS proxy authentication. + // SOCKS5 proxy authentication is currently not supported due to limitations described in: + // https://youtrack.jetbrains.com/issue/TBX-14532/Missing-proxy-authentication-settings#focus=Comments-27-12265861.0-0 + builder.proxyAuthenticator { _, response -> + val proxyAuth = context.proxySettings.getProxyAuth() + if (proxyAuth == null || proxyAuth !is ProxyAuth.Basic) { + return@proxyAuthenticator null + } + val credentials = Credentials.basic(proxyAuth.username, proxyAuth.password) + response.request.newBuilder() + .header("Proxy-Authorization", credentials) + .build() + } + + builder.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) + .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) + .retryOnConnectionFailure(true) + + interceptors.forEach { interceptor -> + builder.addInterceptor(interceptor) + + } + return builder.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 3b107be9..7023c764 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -3,44 +3,31 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.convertors.ArchConverter import com.coder.toolbox.sdk.convertors.InstantConverter +import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.sdk.convertors.OSConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException +import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.CoderV2RestFacade +import com.coder.toolbox.sdk.v2.models.ApiErrorResponse +import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template import com.coder.toolbox.sdk.v2.models.User import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceBuild +import com.coder.toolbox.sdk.v2.models.WorkspaceBuildReason import com.coder.toolbox.sdk.v2.models.WorkspaceResource import com.coder.toolbox.sdk.v2.models.WorkspaceTransition -import com.coder.toolbox.util.CoderHostnameVerifier -import com.coder.toolbox.util.coderSocketFactory -import com.coder.toolbox.util.coderTrustManagers -import com.coder.toolbox.util.getArch -import com.coder.toolbox.util.getHeaders -import com.coder.toolbox.util.getOS import com.squareup.moshi.Moshi -import okhttp3.Credentials import okhttp3.OkHttpClient +import retrofit2.Response 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 - -/** - * 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. @@ -48,100 +35,68 @@ data class ProxyValues( * The token can be omitted if some other authentication mechanism is in use. */ open class CoderRestClient( - context: CoderToolboxContext, + private val context: CoderToolboxContext, val url: URL, val token: String?, - private val proxyValues: ProxyValues? = null, private val pluginVersion: String = "development", ) { - private val settings = context.settingsStore.readOnly() + private lateinit var moshi: Moshi private lateinit var httpClient: OkHttpClient private lateinit var retroRestClient: CoderV2RestFacade lateinit var me: User lateinit var buildVersion: String + lateinit var appName: String init { setupSession() } - fun setupSession() { - val moshi = + private fun setupSession() { + 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 = 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() - ) + val interceptors = buildList { + if (context.settingsStore.requireTokenAuth) { + if (token.isNullOrBlank()) { + throw IllegalStateException("Token is required for $url deployment") + } + add(Interceptors.tokenAuth(token)) } + add((Interceptors.userAgent(pluginVersion))) + add(Interceptors.externalHeaders(context, url)) + add(Interceptors.logging(context)) } - httpClient = - builder - .sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) - .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) - .retryOnConnectionFailure(true) - .addInterceptor { - it.proceed( - it.request().newBuilder().addHeader( - "User-Agent", - "Coder Toolbox/$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) - } - .build() + httpClient = CoderHttpClientBuilder.build( + context, + interceptors + ) retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(moshi) + ) + ) .build().create(CoderV2RestFacade::class.java) } /** - * Authenticate and load information about the current user and the build - * version. + * Load information about the current user and the build version. * * @throws [APIResponseException]. */ - fun authenticate(): User { + suspend fun initializeSession(): User { me = me() buildVersion = buildInfo().version + appName = appearance().applicationName return me } @@ -149,53 +104,79 @@ open class CoderRestClient( * Retrieve the current user. * @throws [APIResponseException]. */ - fun me(): User { - val userResponse = retroRestClient.me().execute() + internal suspend fun me(): User { + val userResponse = retroRestClient.me() if (!userResponse.isSuccessful) { - throw APIResponseException("authenticate", url, userResponse) + throw APIResponseException( + "initializeSession", + url, + userResponse.code(), + userResponse.parseErrorBody(moshi) + ) } - return userResponse.body()!! + return requireNotNull(userResponse.body()) { + "Successful response returned null body or user" + } } /** - * Retrieves the available workspaces created by the user. - * @throws [APIResponseException]. + * Retrieves the visual dashboard configuration. */ - fun workspaces(): List { - val workspacesResponse = retroRestClient.workspaces("owner:me").execute() - if (!workspacesResponse.isSuccessful) { - throw APIResponseException("retrieve workspaces", url, workspacesResponse) + internal suspend fun appearance(): Appearance { + val appearanceResponse = retroRestClient.appearance() + if (!appearanceResponse.isSuccessful) { + throw APIResponseException( + "initializeSession", + url, + appearanceResponse.code(), + appearanceResponse.parseErrorBody(moshi) + ) } - return workspacesResponse.body()!!.workspaces + return requireNotNull(appearanceResponse.body()) { + "Successful response returned null body for visual dashboard configuration" + } } /** - * Retrieves a workspace with the provided id. + * Retrieves the available workspaces created by the user. * @throws [APIResponseException]. */ - fun workspace(workspaceID: UUID): Workspace { - val workspacesResponse = retroRestClient.workspace(workspaceID).execute() + suspend fun workspaces(): List { + val workspacesResponse = retroRestClient.workspaces("owner:me") if (!workspacesResponse.isSuccessful) { - throw APIResponseException("retrieve workspace", url, workspacesResponse) + throw APIResponseException( + "retrieve workspaces", + url, + workspacesResponse.code(), + workspacesResponse.parseErrorBody(moshi) + ) } - return workspacesResponse.body()!! + return requireNotNull(workspacesResponse.body()?.workspaces) { + "Successful response returned null body or workspaces" + } } /** - * Retrieves all the agent names for all workspaces, including those that - * are off. Meant to be used when configuring SSH. + * Retrieves a workspace with the provided id. + * @throws [APIResponseException]. */ - fun agentNames(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 -> - resources(ws).filter { it.agents != null }.flatMap { it.agents!! }.map { - "${ws.name}.${it.name}" - } - }.toSet() + suspend fun workspace(workspaceID: UUID): Workspace { + val workspaceResponse = retroRestClient.workspace(workspaceID) + if (!workspaceResponse.isSuccessful) { + throw APIResponseException( + "retrieve workspace", + url, + workspaceResponse.code(), + workspaceResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(workspaceResponse.body()) { + "Successful response returned null body or workspace" + } } /** @@ -205,65 +186,116 @@ open class CoderRestClient( * removing hosts from the SSH config when they are off). * @throws [APIResponseException]. */ - fun resources(workspace: Workspace): List { + suspend fun resources(workspace: Workspace): List { val resourcesResponse = - retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID).execute() + retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID) if (!resourcesResponse.isSuccessful) { - throw APIResponseException("retrieve resources for ${workspace.name}", url, resourcesResponse) + throw APIResponseException( + "retrieve resources for ${workspace.name}", + url, + resourcesResponse.code(), + resourcesResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(resourcesResponse.body()) { + "Successful response returned null body or workspace resources" } - return resourcesResponse.body()!! } - fun buildInfo(): BuildInfo { - val buildInfoResponse = retroRestClient.buildInfo().execute() + suspend fun buildInfo(): BuildInfo { + val buildInfoResponse = retroRestClient.buildInfo() if (!buildInfoResponse.isSuccessful) { - throw APIResponseException("retrieve build information", url, buildInfoResponse) + throw APIResponseException( + "retrieve build information", + url, + buildInfoResponse.code(), + buildInfoResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(buildInfoResponse.body()) { + "Successful response returned null body or build info" } - return buildInfoResponse.body()!! } /** * @throws [APIResponseException]. */ - private fun template(templateID: UUID): Template { - val templateResponse = retroRestClient.template(templateID).execute() + private suspend fun template(templateID: UUID): Template { + val templateResponse = retroRestClient.template(templateID) if (!templateResponse.isSuccessful) { - throw APIResponseException("retrieve template with ID $templateID", url, templateResponse) + throw APIResponseException( + "retrieve template with ID $templateID", + url, + templateResponse.code(), + templateResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(templateResponse.body()) { + "Successful response returned null body or template" } - return templateResponse.body()!! } /** * @throws [APIResponseException]. */ - fun startWorkspace(workspace: Workspace): WorkspaceBuild { - val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START) - val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() + @Deprecated(message = "This operation needs to be delegated to the CLI") + suspend fun startWorkspace(workspace: Workspace): WorkspaceBuild { + val buildRequest = CreateWorkspaceBuildRequest( + null, + WorkspaceTransition.START, + null, + WorkspaceBuildReason.JETBRAINS_CONNECTION + ) + val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { - throw APIResponseException("start workspace ${workspace.name}", url, buildResponse) + throw APIResponseException( + "start workspace ${workspace.name}", + url, + buildResponse.code(), + buildResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" } - return buildResponse.body()!! } /** */ - fun stopWorkspace(workspace: Workspace): WorkspaceBuild { + suspend fun stopWorkspace(workspace: Workspace): WorkspaceBuild { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP) - val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() + val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { - throw APIResponseException("stop workspace ${workspace.name}", url, buildResponse) + throw APIResponseException( + "stop workspace ${workspace.name}", + url, + buildResponse.code(), + buildResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" } - return buildResponse.body()!! } /** * @throws [APIResponseException] if issues are encountered during deletion */ - fun removeWorkspace(workspace: Workspace) { + suspend fun removeWorkspace(workspace: Workspace) { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.DELETE, false) - val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() + val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { - throw APIResponseException("delete workspace ${workspace.name}", url, buildResponse) + throw APIResponseException( + "delete workspace ${workspace.name}", + url, + buildResponse.code(), + buildResponse.parseErrorBody(moshi) + ) } } @@ -277,15 +309,23 @@ open class CoderRestClient( * with this information when we do two START builds in a row. * @throws [APIResponseException]. */ - fun updateWorkspace(workspace: Workspace): WorkspaceBuild { + suspend fun updateWorkspace(workspace: Workspace): WorkspaceBuild { val template = template(workspace.templateID) val buildRequest = CreateWorkspaceBuildRequest(template.activeVersionID, WorkspaceTransition.START) - val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute() + val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest) if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) { - throw APIResponseException("update workspace ${workspace.name}", url, buildResponse) + throw APIResponseException( + "update workspace ${workspace.name}", + url, + buildResponse.code(), + buildResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" } - return buildResponse.body()!! } fun close() { @@ -296,3 +336,13 @@ open class CoderRestClient( } } } + +private fun Response<*>.parseErrorBody(moshi: Moshi): ApiErrorResponse? { + val errorBody = this.errorBody() ?: return null + return try { + val adapter = moshi.adapter(ApiErrorResponse::class.java) + adapter.fromJson(errorBody.string()) + } catch (e: Exception) { + null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt new file mode 100644 index 00000000..839d7530 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.CoderToolboxContext +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type + +class LoggingConverterFactory private constructor( + private val context: CoderToolboxContext, + private val delegate: Converter.Factory, +) : Converter.Factory() { + + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter? { + // Get the delegate converter + val delegateConverter = delegate.responseBodyConverter(type, annotations, retrofit) + ?: return null + + @Suppress("UNCHECKED_CAST") + return LoggingMoshiConverter(context, delegateConverter as Converter) + } + + override fun requestBodyConverter( + type: Type, + parameterAnnotations: Array, + methodAnnotations: Array, + retrofit: Retrofit + ): Converter<*, RequestBody>? { + return delegate.requestBodyConverter(type, parameterAnnotations, methodAnnotations, retrofit) + } + + override fun stringConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter<*, String>? { + return delegate.stringConverter(type, annotations, retrofit) + } + + companion object { + fun wrap( + context: CoderToolboxContext, + delegate: Converter.Factory, + ): LoggingConverterFactory { + return LoggingConverterFactory(context, delegate) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt new file mode 100644 index 00000000..9cc548ab --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt @@ -0,0 +1,34 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.CoderToolboxContext +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.Converter + +class LoggingMoshiConverter( + private val context: CoderToolboxContext, + private val delegate: Converter +) : Converter { + + override fun convert(value: ResponseBody): Any? { + val bodyString = value.string() + + return try { + // Parse with Moshi + delegate.convert(bodyString.toResponseBody(value.contentType())) + } catch (e: Exception) { + // Log the raw content that failed to parse + context.logger.error( + """ + |Moshi parsing failed: + |Content-Type: ${value.contentType()} + |Content: $bodyString + |Error: ${e.message} + """.trimMargin() + ) + + // Re-throw so the onFailure callback still gets called + throw e + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt index 2540ca8d..d109c75d 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt @@ -1,26 +1,91 @@ package com.coder.toolbox.sdk.ex +import com.coder.toolbox.sdk.v2.models.ApiErrorResponse 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=${ - when (res.code()) { - HttpURLConnection.HTTP_NOT_FOUND -> "The requested resource could not be found" - else -> res.errorBody()?.charStream()?.use { - val text = it.readText() - // Be careful with the length because if you try to show a - // notification in Toolbox that is too large it crashes the - // application. - if (text.length > 500) { - "${text.substring(0, 500)}…" - } else { - text - } - } ?: "no details provided" - }}", - ) { - val isUnauthorized = res.code() == HttpURLConnection.HTTP_UNAUTHORIZED +class APIResponseException(action: String, url: URL, code: Int, errorResponse: ApiErrorResponse?) : + IOException(formatToPretty(action, url, code, errorResponse)) { + + val reason = errorResponse?.detail + val isUnauthorized = HttpURLConnection.HTTP_UNAUTHORIZED == code + val isTokenExpired = isUnauthorized && reason?.contains("API key expired") == true + + companion object { + private fun formatToPretty( + action: String, + url: URL, + code: Int, + errorResponse: ApiErrorResponse?, + ): String { + return if (errorResponse == null) { + "Unable to $action: url=$url, code=$code, details=${HttpErrorStatusMapper.getMessage(code)}" + } else { + var msg = "Unable to $action: url=$url, code=$code, message=${errorResponse.message}" + if (errorResponse.detail?.isNotEmpty() == true) { + msg += ", reason=${errorResponse.detail}" + } + + // Be careful with the length because if you try to show a + // notification in Toolbox that is too large it crashes the + // application. + if (msg.length > 500) { + msg = "${msg.substring(0, 500)}…" + } + msg + } + } + } +} + +private object HttpErrorStatusMapper { + private val errorStatusMap = mapOf( + // 4xx: Client Errors + 400 to "Bad Request", + 401 to "Unauthorized", + 402 to "Payment Required", + 403 to "Forbidden", + 404 to "Not Found", + 405 to "Method Not Allowed", + 406 to "Not Acceptable", + 407 to "Proxy Authentication Required", + 408 to "Request Timeout", + 409 to "Conflict", + 410 to "Gone", + 411 to "Length Required", + 412 to "Precondition Failed", + 413 to "Payload Too Large", + 414 to "URI Too Long", + 415 to "Unsupported Media Type", + 416 to "Range Not Satisfiable", + 417 to "Expectation Failed", + 418 to "I'm a teapot", + 421 to "Misdirected Request", + 422 to "Unprocessable Entity", + 423 to "Locked", + 424 to "Failed Dependency", + 425 to "Too Early", + 426 to "Upgrade Required", + 428 to "Precondition Required", + 429 to "Too Many Requests", + 431 to "Request Header Fields Too Large", + 451 to "Unavailable For Legal Reasons", + + // 5xx: Server Errors + 500 to "Internal Server Error", + 501 to "Not Implemented", + 502 to "Bad Gateway", + 503 to "Service Unavailable", + 504 to "Gateway Timeout", + 505 to "HTTP Version Not Supported", + 506 to "Variant Also Negotiates", + 507 to "Insufficient Storage", + 508 to "Loop Detected", + 510 to "Not Extended", + 511 to "Network Authentication Required" + ) + + fun getMessage(code: Int): String = + errorStatusMap[code] ?: "Unknown Error Status" } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt new file mode 100644 index 00000000..9c9f3ee6 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/Interceptors.kt @@ -0,0 +1,64 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.getArch +import com.coder.toolbox.util.getHeaders +import com.coder.toolbox.util.getOS +import okhttp3.Interceptor +import java.net.URL + +/** + * Factory of okhttp interceptors + */ +object Interceptors { + + /** + * Creates a token authentication interceptor + */ + fun tokenAuth(token: String): Interceptor { + return Interceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .addHeader("Coder-Session-Token", token) + .build() + ) + } + } + + /** + * Creates a User-Agent header interceptor + */ + fun userAgent(pluginVersion: String): Interceptor { + return Interceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .addHeader("User-Agent", "Coder Toolbox/$pluginVersion (${getOS()}; ${getArch()})") + .build() + ) + } + } + + /** + * Adds headers generated by executing a native command + */ + fun externalHeaders(context: CoderToolboxContext, url: URL): Interceptor { + val settings = context.settingsStore.readOnly() + return Interceptor { chain -> + var request = chain.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() + } + chain.proceed(request) + } + } + + /** + * Creates a logging interceptor + */ + fun logging(context: CoderToolboxContext): Interceptor { + return LoggingInterceptor(context) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt new file mode 100644 index 00000000..4bbb1b96 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt @@ -0,0 +1,120 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.HttpLoggingVerbosity +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import java.nio.charset.StandardCharsets + +private val SENSITIVE_HEADERS = setOf("Coder-Session-Token", "Proxy-Authorization") + +class LoggingInterceptor(private val context: CoderToolboxContext) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val logLevel = context.settingsStore.httpClientLogLevel + if (logLevel == HttpLoggingVerbosity.NONE) { + return chain.proceed(chain.request()) + } + + val request = chain.request() + logRequest(request, logLevel) + + val response = chain.proceed(request) + logResponse(response, request, logLevel) + + return response + } + + private fun logRequest(request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("request --> ${request.method} ${request.url}") + + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${request.headers.sanitized()}") + } + + if (logLevel == HttpLoggingVerbosity.BODY) { + request.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } + } + + context.logger.info(log) + } + + private fun logResponse(response: Response, request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("response <-- ${response.code} ${response.message} ${request.url}") + + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${response.headers.sanitized()}") + } + + if (logLevel == HttpLoggingVerbosity.BODY) { + response.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } + } + + context.logger.info(log) + } +} + +// Extension functions for cleaner code +private fun Headers.sanitized(): String = buildString { + this@sanitized.forEach { (name, value) -> + val displayValue = if (name in SENSITIVE_HEADERS) "" else value + append("$name: $displayValue\n") + } +} + +private fun RequestBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" + } + + return try { + val buffer = Buffer() + writeTo(buffer) + buffer.readString(contentType()?.charset() ?: StandardCharsets.UTF_8) + } catch (e: Exception) { + "[Error reading body: ${e.message}]" + } +} + +private fun ResponseBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" + } + + return try { + val source = source() + source.request(Long.MAX_VALUE) + source.buffer.clone().readString(contentType()?.charset() ?: StandardCharsets.UTF_8) + } catch (e: Exception) { + "[Error reading body: ${e.message}]" + } +} + +private fun MediaType?.isPrintable(): Boolean = when { + this == null -> false + type == "text" -> true + subtype == "json" || subtype.endsWith("+json") -> true + else -> false +} + +private fun Long.formatBytes(): String = when { + this < 0 -> "unknown" + this < 1024 -> "${this}B" + this < 1024 * 1024 -> "${this / 1024}KB" + this < 1024 * 1024 * 1024 -> "${this / (1024 * 1024)}MB" + else -> "${this / (1024 * 1024 * 1024)}GB" +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt index ae29746d..5e7fc133 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.sdk.v2 +import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template @@ -8,7 +9,7 @@ import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceBuild import com.coder.toolbox.sdk.v2.models.WorkspaceResource import com.coder.toolbox.sdk.v2.models.WorkspacesResponse -import retrofit2.Call +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -21,43 +22,49 @@ interface CoderV2RestFacade { * Retrieves details about the authenticated user. */ @GET("api/v2/users/me") - fun me(): Call + suspend fun me(): Response + + /** + * Returns the configuration of the visual dashboard. + */ + @GET("api/v2/appearance") + suspend fun appearance(): Response /** * Retrieves all workspaces the authenticated user has access to. */ @GET("api/v2/workspaces") - fun workspaces( + suspend fun workspaces( @Query("q") searchParams: String, - ): Call + ): Response /** * Retrieves a workspace with the provided id. */ @GET("api/v2/workspaces/{workspaceID}") - fun workspace( + suspend fun workspace( @Path("workspaceID") workspaceID: UUID - ): Call + ): Response @GET("api/v2/buildinfo") - fun buildInfo(): Call + suspend fun buildInfo(): Response /** * Queues a new build to occur for a workspace. */ @POST("api/v2/workspaces/{workspaceID}/builds") - fun createWorkspaceBuild( + suspend fun createWorkspaceBuild( @Path("workspaceID") workspaceID: UUID, @Body createWorkspaceBuildRequest: CreateWorkspaceBuildRequest, - ): Call + ): Response @GET("api/v2/templates/{templateID}") - fun template( + suspend fun template( @Path("templateID") templateID: UUID, - ): Call