diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a55c79..323b3d5 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 @@ -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 @@ -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@v5 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@v5 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 0000000..ff28fe5 --- /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@v4 + 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@v7 + 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 44fb3be..6918c4e 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db69cb..63b71d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,197 @@ ## Unreleased +## 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 -- SSH connection to a Workspace is no longer established only once +- 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 -- action buttons on the token input step were swapped to achieve better keyboard navigation +- improved message while loading the workspace ### Fixed -- authorization wizard automatically goes to a previous screen when an error is encountered during connection to Coder deployment +- 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 diff --git a/JETBRAINS_COMPLIANCE.md b/JETBRAINS_COMPLIANCE.md new file mode 100644 index 0000000..91162ed --- /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 6823fae..74e9cd5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,421 @@ -# 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. + +### 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 e715675..cdfc5e8 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" diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 0000000..5e5e6c8 --- /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 d69a4d7..3b06eb4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.1.3 +version=0.6.4 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 299cb17..5d03af3 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" +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.304" -mockk = "1.13.17" +plugin-structure = "3.316" +mockk = "1.14.5" +detekt = "1.23.8" +bouncycastle = "1.81" [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 fe5f039..d4531a6 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -1,14 +1,19 @@ 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.EnvironmentView +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.DeleteEnvironmentConfirmationParams @@ -18,15 +23,23 @@ 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.squareup.moshi.Moshi +import kotlinx.coroutines.CoroutineName +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 kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +private val POLL_INTERVAL = 5.seconds + /** * Represents an agent and workspace combination. * @@ -35,55 +48,77 @@ 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}"), 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 + + 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()) { + val actions = mutableListOf() + if (wsRawStatus.canStop()) { + actions.add(Action(context.i18n.ptrl("Open web terminal")) { + context.cs.launch(CoroutineName("Open Web Terminal Action")) { + context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { context.ui.showErrorInfoPopup(it) } } - }, + }) + } + actions.add( Action(context.i18n.ptrl("Open in dashboard")) { - context.cs.launch { - BrowserUtil.browse(client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()) { + context.cs.launch(CoroutineName("Open in Dashboard Action")) { + context.desktop.browse( + client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + ) { 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.i18n.ptrl("View template")) { + context.cs.launch(CoroutineName("View Template Action")) { + 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")) { - context.cs.launch { + context.cs.launch(CoroutineName("Update and Start Action")) { val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } }) } else { actions.add(Action(context.i18n.ptrl("Start")) { - context.cs.launch { + context.cs.launch(CoroutineName("Start Action")) { val build = client.startWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) @@ -94,35 +129,90 @@ class CoderRemoteEnvironment( if (wsRawStatus.canStop()) { if (workspace.outdated) { actions.add(Action(context.i18n.ptrl("Update and restart")) { - context.cs.launch { + context.cs.launch(CoroutineName("Update and Restart Action")) { val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } }) - } else { - actions.add(Action(context.i18n.ptrl("Stop")) { - context.cs.launch { - val build = client.stopWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } - }) } + actions.add(Action(context.i18n.ptrl("Stop")) { + context.cs.launch(CoroutineName("Stop Action")) { + tryStopSshConnection() + + val build = client.stopWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } + }) } 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...") - this.isConnected = true + 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) + } } - override fun afterDisconnect() { + 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 } - this.isConnected = 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") } @@ -138,7 +228,7 @@ class CoderRemoteEnvironment( actionsList.update { getAvailableActions() } - context.cs.launch { + context.cs.launch(CoroutineName("Workspace Status Updater")) { state.update { wsRawStatus.toRemoteEnvironmentState(context) } @@ -149,31 +239,38 @@ 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 ) - private var isConnected = false - 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 && isConnected == false) { - context.cs.launch { + 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? { @@ -187,8 +284,8 @@ class CoderRemoteEnvironment( } } - override fun onDelete() { - context.cs.launch { + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow { + context.cs.launch(CoroutineName("Delete Workspace Action")) { try { client.removeWorkspace(workspace) // mark the env as deleting otherwise we will have to @@ -197,7 +294,7 @@ class CoderRemoteEnvironment( WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context) } - context.cs.launch { + context.cs.launch(CoroutineName("Workspace Deletion Poller")) { withTimeout(5.minutes) { var workspaceStillExists = true while (context.cs.isActive && workspaceStillExists) { @@ -216,6 +313,8 @@ class CoderRemoteEnvironment( } } + 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 942ffa3..8c6dcd3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -1,24 +1,32 @@ 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.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi +import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.waitForTrue +import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action -import com.coder.toolbox.views.AuthWizardPage +import com.coder.toolbox.views.CoderCliSetupWizardPage import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage -import com.coder.toolbox.views.state.AuthWizardState +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.ActionDelimiter 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 @@ -29,7 +37,6 @@ 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 kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds @@ -49,133 +56,153 @@ class CoderRemoteProvider( private val settings = context.settingsStore.readOnly() - // Create our services from the Toolbox ones. private val triggerSshConfig = Channel(Channel.CONFLATED) + private val triggerProviderVisible = Channel(Channel.CONFLATED) private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) 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(context.deploymentUrl?.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 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 + WorkspaceConnectionManager.reset() close() } @@ -195,6 +222,14 @@ class CoderRemoteProvider( override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( + Action(context.i18n.ptrl("Create workspace")) { + context.cs.launch(CoroutineName("Create Workspace Action")) { + context.desktop.browse(client?.url?.withPath("/templates").toString()) { + context.ui.showErrorInfoPopup(it) + } + } + }, + CoderDelimiter(context.i18n.pnotr("")), Action(context.i18n.ptrl("Settings")) { context.ui.showUiPage(settingsPage) }, @@ -213,7 +248,7 @@ class CoderRemoteProvider( environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } client = null - AuthWizardState.resetSteps() + CoderCliSetupWizardState.goToFirstStep() } override val svgIcon: SvgIcon = @@ -261,32 +296,51 @@ 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 -> + // stop polling and de-initialize resources + close() + isInitialized.update { + false + } + // start initialization with the new settings + this@CoderRemoteProvider.client = restClient + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) - /** - * 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() + environments.showLoadingMessage() + pollJob = poll(restClient, cli) + 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() + } } /** @@ -296,49 +350,72 @@ class CoderRemoteProvider( * 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 { - AuthWizardState.goToStep(WizardStep.LOGIN) - return AuthWizardPage(context, true, ::onConnect) - } catch (ex: Exception) { - autologinEx = ex - } + // When coming back to the application, initializeSession immediately. + if (shouldDoAutoSetup()) { + try { + CoderCliSetupContext.apply { + url = context.secrets.lastDeploymentURL.toURL() + token = context.secrets.lastToken } + 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 authWizard = AuthWizardPage(context, false, ::onConnect) - // We might have tried and failed to automatically log in. - autologinEx?.let { authWizard.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 { authWizard.notify("Error fetching workspaces", it) } - - return authWizard + 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 + /** + * 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 shouldDoAutoSetup(): Boolean = firstRun && (context.secrets.canAutoLogin || !settings.requireTokenAuth) 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.secrets.storeTokenFor(client.url, context.secrets.lastToken) + context.logger.info("Deployment URL and token were stored and will be available for automatic connection") this.client = client - pollError = null - pollJob?.cancel() + pollJob?.let { + it.cancel() + context.logger.info("Workspace poll job with reference ${pollJob} was canceled") + } + environments.showLoadingMessage() + coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + context.logger.info("Displaying ${client.url} in the UI") pollJob = poll(client, cli) - goToEnvironmentsPage() + context.logger.info("Workspace poll job created with reference $pollJob") + } + + private fun MutableStateFlow>>.showLoadingMessage() { + this.update { + LoadableState.Loading + } } } + +private class CoderDelimiter(override val label: LocalizableString) : ActionDelimiter \ 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 b3f6f60..baac820 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -1,27 +1,35 @@ package com.coder.toolbox -import com.coder.toolbox.settings.SettingSource 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. @@ -33,32 +41,56 @@ data class CoderToolboxContext( * 3. CODER_URL. * 4. URL in global cli config. */ - val deploymentUrl: Pair? - get() = this.secrets.lastDeploymentURL.let { - if (it.isNotBlank()) { - it to SettingSource.LAST_USED - } else { - this.settingsStore.defaultURL() + val deploymentUrl: URL + get() { + if (this.secrets.lastDeploymentURL.isNotBlank()) { + return this.secrets.lastDeploymentURL.toURL() } + return this.settingsStore.defaultURL.toURL() } - /** - * 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. - */ - fun getToken(deploymentURL: String?): Pair? = this.secrets.lastToken.let { - if (it.isNotBlank() && this.secrets.lastDeploymentURL == deploymentURL) { - it to SettingSource.LAST_USED - } else { - if (deploymentURL != null) { - this.settingsStore.token(deploymentURL.toURL()) - } else null - } + 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 a310ee0..5cfcd11 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 0000000..9196729 --- /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 000263c..37918b7 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 d97caf8..9b058e5 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 } @@ -121,92 +131,169 @@ data class Features( * 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 } + } - HttpURLConnection.HTTP_NOT_MODIFIED -> { - logger.info("Using cached binary at $localBinaryPath") - return false + // 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 + } + + 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(), @@ -223,19 +310,20 @@ class CoderCLIManager( * 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 +337,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,22 +354,21 @@ 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, + if (!context.settingsStore.headerCommand.isNullOrBlank()) "--header-command" else null, + if (!context.settingsStore.headerCommand.isNullOrBlank()) escapeSubcommand(context.settingsStore.headerCommand!!) else null, "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, + if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, + if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) escape(context.settingsStore.sshLogDirectory!!) else null, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) - val backgroundProxyArgs = - baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null) val extraConfig = - if (!settings.sshConfigOptions.isNullOrBlank()) { - "\n" + settings.sshConfigOptions!!.prependIndent(" ") + if (!context.settingsStore.sshConfigOptions.isNullOrBlank()) { + "\n" + context.settingsStore.sshConfigOptions!!.prependIndent(" ") } else { "" } @@ -293,7 +380,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 +388,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 +418,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 +447,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 +458,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 +473,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 +498,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 +513,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 +533,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 +552,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 } @@ -506,25 +576,24 @@ class CoderCLIManager( } } + 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 0000000..1d36e4f --- /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 0000000..4e27569 --- /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 0000000..468bfd8 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -0,0 +1,252 @@ +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/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 0000000..29d4fda --- /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 d3ca3a4..9fdff54 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 0000000..490b48e --- /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 0000000..eafafcd --- /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 3599782..1f48a10 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 0000000..f80d60c --- /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() + + if (context.proxySettings.getProxy() != null) { + context.logger.info("proxy: ${context.proxySettings.getProxy()}") + builder.proxy(context.proxySettings.getProxy()) + } else if (context.proxySettings.getProxySelector() != null) { + context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}") + builder.proxySelector(context.proxySettings.getProxySelector()!!) + } + + // 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 2f87e41..803472c 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -3,44 +3,32 @@ 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.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.WorkspaceAgent 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.WorkspaceStatus 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,13 +36,12 @@ 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 @@ -65,81 +52,48 @@ open class CoderRestClient( 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]. */ - suspend fun authenticate(): User { + suspend fun initializeSession(): User { me = me() buildVersion = buildInfo().version return me @@ -152,7 +106,12 @@ open class CoderRestClient( 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()!! @@ -165,7 +124,12 @@ open class CoderRestClient( suspend fun workspaces(): List { val workspacesResponse = retroRestClient.workspaces("owner:me") if (!workspacesResponse.isSuccessful) { - throw APIResponseException("retrieve workspaces", url, workspacesResponse) + throw APIResponseException( + "retrieve workspaces", + url, + workspacesResponse.code(), + workspacesResponse.parseErrorBody(moshi) + ) } return workspacesResponse.body()!!.workspaces @@ -178,22 +142,29 @@ open class CoderRestClient( suspend fun workspace(workspaceID: UUID): Workspace { val workspacesResponse = retroRestClient.workspace(workspaceID) if (!workspacesResponse.isSuccessful) { - throw APIResponseException("retrieve workspace", url, workspacesResponse) + throw APIResponseException( + "retrieve workspace", + url, + workspacesResponse.code(), + workspacesResponse.parseErrorBody(moshi) + ) } return workspacesResponse.body()!! } /** - * Retrieves all the agent names for all workspaces, including those that - * are off. Meant to be used when configuring SSH. + * Maps the available workspaces to the associated agents. */ - suspend fun agentNames(workspaces: List): Set { + suspend fun workspacesByAgents(): 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}" + return workspaces().flatMap { ws -> + when (ws.latestBuild.status) { + WorkspaceStatus.RUNNING -> ws.latestBuild.resources + else -> resources(ws) + }.filter { it.agents != null }.flatMap { it.agents!! }.map { + ws to it } }.toSet() } @@ -209,7 +180,12 @@ open class CoderRestClient( val resourcesResponse = 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 resourcesResponse.body()!! } @@ -217,7 +193,12 @@ open class CoderRestClient( 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 buildInfoResponse.body()!! } @@ -228,7 +209,12 @@ open class CoderRestClient( 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 templateResponse.body()!! } @@ -237,10 +223,20 @@ open class CoderRestClient( * @throws [APIResponseException]. */ suspend fun startWorkspace(workspace: Workspace): WorkspaceBuild { - val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START) + 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 buildResponse.body()!! } @@ -251,7 +247,12 @@ open class CoderRestClient( val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP) 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 buildResponse.body()!! } @@ -263,7 +264,12 @@ open class CoderRestClient( val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.DELETE, false) 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) + ) } } @@ -283,7 +289,12 @@ open class CoderRestClient( CreateWorkspaceBuildRequest(template.activeVersionID, WorkspaceTransition.START) 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 buildResponse.body()!! } @@ -296,3 +307,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 0000000..839d753 --- /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 0000000..9cc548a --- /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 2540ca8..d109c75 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 0000000..9c9f3ee --- /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 0000000..4bbb1b9 --- /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/models/ApiErrorResponse.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/ApiErrorResponse.kt new file mode 100644 index 0000000..167e020 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/ApiErrorResponse.kt @@ -0,0 +1,10 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ApiErrorResponse( + @Json(name = "message") val message: String, + @Json(name = "detail") val detail: String?, +) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt index a2f1ca2..53c70c8 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/CreateWorkspaceBuildRequest.kt @@ -10,7 +10,8 @@ data class CreateWorkspaceBuildRequest( @Json(name = "template_version_id") val templateVersionID: UUID?, // Use to start, stop and delete the workspace. @Json(name = "transition") val transition: WorkspaceTransition, - @Json(name = "orphan") var orphan: Boolean? = null + @Json(name = "orphan") var orphan: Boolean? = null, + @Json(name = "reason") var reason: WorkspaceBuildReason? = null ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -21,6 +22,7 @@ data class CreateWorkspaceBuildRequest( if (templateVersionID != other.templateVersionID) return false if (transition != other.transition) return false if (orphan != other.orphan) return false + if (reason != other.reason) return false return true } @@ -28,6 +30,7 @@ data class CreateWorkspaceBuildRequest( var result = orphan?.hashCode() ?: 0 result = 31 * result + (templateVersionID?.hashCode() ?: 0) result = 31 * result + transition.hashCode() + result = 31 * result + (reason?.hashCode() ?: 0) return result } } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetrics.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetrics.kt new file mode 100644 index 0000000..cb7d235 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetrics.kt @@ -0,0 +1,49 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.text.DecimalFormat + +private val formatter = DecimalFormat("#.00") + +/** + * Coder ssh network metrics. All properties are optional + * because Coder Connect only populates `using_coder_connect` + * while p2p doesn't populate this property. + */ +@JsonClass(generateAdapter = true) +data class NetworkMetrics( + @Json(name = "p2p") + val p2p: Boolean?, + + @Json(name = "latency") + val latency: Double?, + + @Json(name = "preferred_derp") + val preferredDerp: String?, + + @Json(name = "derp_latency") + val derpLatency: Map?, + + @Json(name = "upload_bytes_sec") + val uploadBytesSec: Long?, + + @Json(name = "download_bytes_sec") + val downloadBytesSec: Long?, + + @Json(name = "using_coder_connect") + val usingCoderConnect: Boolean? +) { + fun toPretty(): String { + if (usingCoderConnect == true) { + return "You're connected using Coder Connect" + } + return if (p2p == true) { + "Direct (${formatter.format(latency)}ms). You're connected peer-to-peer" + } else { + val derpLatency = derpLatency!![preferredDerp] + val workspaceLatency = latency!!.minus(derpLatency!!) + "You ↔ $preferredDerp (${formatter.format(derpLatency)}ms) ↔ Workspace (${formatter.format(workspaceLatency)}ms). You are connected through a relay" + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt new file mode 100644 index 0000000..390c934 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuildReason.kt @@ -0,0 +1,7 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json + +enum class WorkspaceBuildReason { + @Json(name = "jetbrains_connection") JETBRAINS_CONNECTION, +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 25568d3..0000ea6 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -2,6 +2,7 @@ package com.coder.toolbox.settings import java.net.URL import java.nio.file.Path +import java.util.Locale.getDefault /** * Read-only interface for accessing Coder settings @@ -10,7 +11,7 @@ interface ReadOnlyCoderSettings { /** * The default URL to show in the connection window. */ - val defaultURL: String? + val defaultURL: String /** * Used to download the Coder CLI which is necessary to proxy SSH @@ -27,6 +28,21 @@ interface ReadOnlyCoderSettings { */ val binaryDirectory: String? + /** + * Controls whether we verify the cli signature + */ + val disableSignatureVerification: Boolean + + /** + * Controls whether we fall back on release.coder.com for signatures if signature validation is enabled + */ + val fallbackOnCoderForSignatures: SignatureFallbackStrategy + + /** + * Controls the logging for the rest client. + */ + val httpClientLogLevel: HttpLoggingVerbosity + /** * Default CLI binary name based on OS and architecture */ @@ -37,6 +53,11 @@ interface ReadOnlyCoderSettings { */ val binaryName: String + /** + * Default CLI signature name based on OS and architecture + */ + val defaultSignatureNameByOsAndArch: String + /** * Where to save plugin data like the Coder binary (if not configured with * binaryDirectory) and the deployment URL and session token. @@ -110,15 +131,11 @@ interface ReadOnlyCoderSettings { */ val sshConfigOptions: String? - /** - * The default URL to show in the connection window. - */ - fun defaultURL(): Pair? /** - * Given a deployment URL, try to find a token for it if required. + * The path where network information for SSH hosts are stored */ - fun token(deploymentURL: URL): Pair? + val networkInfoDir: String /** * Where the specified deployment should put its data. @@ -139,6 +156,11 @@ interface ReadOnlyCoderSettings { * Return the URL and token from the config, if they exist. */ fun readConfig(dir: Path): Pair + + /** + * Returns whether the SSH connection should be automatically established. + */ + fun shouldAutoConnect(workspaceId: String): Boolean } /** @@ -171,4 +193,60 @@ interface ReadOnlyTLSSettings { * Coder service does not match the hostname in the TLS certificate. */ val altHostname: String? +} + +enum class SignatureFallbackStrategy { + /** + * User has not yet decided whether he wants to fallback on releases.coder.com for signatures + */ + NOT_CONFIGURED, + + /** + * Can fall back on releases.coder.com for signatures. + */ + ALLOW, + + /** + * Can't fall back on releases.coder.com for signatures. + */ + FORBIDDEN; + + fun isAllowed(): Boolean = this == ALLOW + + companion object { + fun fromValue(value: String?): SignatureFallbackStrategy = when (value?.lowercase(getDefault())) { + "not_configured" -> NOT_CONFIGURED + "allow" -> ALLOW + "forbidden" -> FORBIDDEN + else -> NOT_CONFIGURED + } + } +} + +enum class HttpLoggingVerbosity { + NONE, + + /** + * Logs URL, method, and status + */ + BASIC, + + /** + * Logs BASIC + sanitized headers + */ + HEADERS, + + /** + * Logs HEADERS + body content + */ + BODY; + + companion object { + fun fromValue(value: String?): HttpLoggingVerbosity = when (value?.lowercase(getDefault())) { + "basic" -> BASIC + "headers" -> HEADERS + "body" -> BODY + else -> NONE + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index e82402e..a807b69 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.store import com.jetbrains.toolbox.api.core.PluginSecretStore +import java.net.URL /** @@ -23,7 +24,12 @@ class CoderSecretsStore(private val store: PluginSecretStore) { var lastToken: String get() = get("last-token") set(value) = set("last-token", value) - var rememberMe: Boolean - get() = get("remember-me").toBoolean() - set(value) = set("remember-me", value.toString()) + val canAutoLogin: Boolean + get() = lastDeploymentURL.isNotBlank() && lastToken.isNotBlank() + + fun tokenFor(url: URL): String? = store[url.host] + + fun storeTokenFor(url: URL, token: String) { + store[url.host] = token + } } diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 92c08d0..f770da8 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -1,9 +1,10 @@ package com.coder.toolbox.store import com.coder.toolbox.settings.Environment +import com.coder.toolbox.settings.HttpLoggingVerbosity import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.coder.toolbox.settings.ReadOnlyTLSSettings -import com.coder.toolbox.settings.SettingSource +import com.coder.toolbox.settings.SignatureFallbackStrategy import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS import com.coder.toolbox.util.expand @@ -35,11 +36,18 @@ class CoderSettingsStore( ) : ReadOnlyTLSSettings // Properties implementation - override val defaultURL: String? get() = store[DEFAULT_URL] + override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] + override val disableSignatureVerification: Boolean + get() = store[DISABLE_SIGNATURE_VALIDATION]?.toBooleanStrictOrNull() ?: false + override val fallbackOnCoderForSignatures: SignatureFallbackStrategy + get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES]) + override val httpClientLogLevel: HttpLoggingVerbosity + get() = HttpLoggingVerbosity.fromValue(store[HTTP_CLIENT_LOG_LEVEL]) override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch()) + override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch()) override val dataDirectory: String? get() = store[DATA_DIRECTORY] override val globalDataDirectory: String get() = getDefaultGlobalDataDir().normalize().toString() override val globalConfigDir: String get() = getDefaultGlobalConfigDir().normalize().toString() @@ -65,48 +73,11 @@ class CoderSettingsStore( override val sshLogDirectory: String? get() = store[SSH_LOG_DIR] override val sshConfigOptions: String? get() = store[SSH_CONFIG_OPTIONS].takeUnless { it.isNullOrEmpty() } ?: env.get(CODER_SSH_CONFIG_OPTIONS) - - /** - * The default URL to show in the connection window. - */ - override fun defaultURL(): Pair? { - val envURL = env.get(CODER_URL) - if (!defaultURL.isNullOrEmpty()) { - return defaultURL!! to SettingSource.SETTINGS - } else if (envURL.isNotBlank()) { - return envURL to SettingSource.ENVIRONMENT - } else { - val (configUrl, _) = readConfig(Path.of(globalConfigDir)) - if (!configUrl.isNullOrBlank()) { - return configUrl to SettingSource.CONFIG - } - } - return null - } - - /** - * Given a deployment URL, try to find a token for it if required. - */ - override fun token(deploymentURL: URL): Pair? { - // No need to bother if we do not need token auth anyway. - if (!requireTokenAuth) { - return null - } - // Try the deployment's config directory. This could exist if someone - // has entered a URL that they are not currently connected to, but have - // connected to in the past. - val (_, deploymentToken) = readConfig(dataDir(deploymentURL).resolve("config")) - if (!deploymentToken.isNullOrBlank()) { - return deploymentToken to SettingSource.DEPLOYMENT_CONFIG - } - // Try the global config directory, in case they previously set up the - // CLI with this URL. - val (configUrl, configToken) = readConfig(Path.of(globalConfigDir)) - if (configUrl == deploymentURL.toString() && !configToken.isNullOrBlank()) { - return configToken to SettingSource.CONFIG - } - return null - } + override val networkInfoDir: String + get() = store[NETWORK_INFO_DIR].takeUnless { it.isNullOrEmpty() } ?: getDefaultGlobalDataDir() + .resolve("ssh-network-metrics") + .normalize() + .toString() /** * Where the specified deployment should put its data. @@ -176,6 +147,10 @@ class CoderSettingsStore( } } + override fun shouldAutoConnect(workspaceId: String): Boolean { + return store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"]?.toBooleanStrictOrNull() ?: false + } + // a readonly cast fun readOnly(): ReadOnlyCoderSettings = this @@ -196,6 +171,22 @@ class CoderSettingsStore( store[ENABLE_DOWNLOADS] = shouldEnableDownloads.toString() } + fun updateDisableSignatureVerification(shouldDisableSignatureVerification: Boolean) { + store[DISABLE_SIGNATURE_VALIDATION] = shouldDisableSignatureVerification.toString() + } + + fun updateSignatureFallbackStrategy(fallback: Boolean) { + store[FALLBACK_ON_CODER_FOR_SIGNATURES] = when (fallback) { + true -> SignatureFallbackStrategy.ALLOW.toString() + else -> SignatureFallbackStrategy.FORBIDDEN.toString() + } + } + + fun updateHttpClientLogLevel(level: HttpLoggingVerbosity?) { + if (level == null) return + store[HTTP_CLIENT_LOG_LEVEL] = level.toString() + } + fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) { store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString() } @@ -232,10 +223,18 @@ class CoderSettingsStore( store[SSH_LOG_DIR] = path } + fun updateNetworkInfoDir(path: String) { + store[NETWORK_INFO_DIR] = path + } + fun updateSshConfigOptions(options: String) { store[SSH_CONFIG_OPTIONS] = options } + fun updateAutoConnect(workspaceId: String, autoConnect: Boolean) { + store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"] = autoConnect.toString() + } + private fun getDefaultGlobalDataDir(): Path { return when (getOS()) { OS.WINDOWS -> Paths.get(env.get("LOCALAPPDATA"), "coder-toolbox") @@ -271,41 +270,37 @@ class CoderSettingsStore( } /** - * Return the name of the binary (with extension) for the provided OS and - * architecture. + * Return the name of the binary (with extension) for the provided OS and architecture. */ - private fun getCoderCLIForOS( - os: OS?, - arch: Arch?, - ): String { - logger.info("Resolving binary for $os $arch") - if (os == null) { - logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") - return "coder-windows-amd64.exe" + private fun getCoderCLIForOS(os: OS?, arch: Arch?): String { + logger.debug("Resolving binary for $os $arch") + + val (osName, extension) = when (os) { + OS.WINDOWS -> "windows" to ".exe" + OS.LINUX -> "linux" to "" + OS.MAC -> "darwin" to "" + null -> { + logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") + return "coder-windows-amd64.exe" + } } - return when (os) { - OS.WINDOWS -> - when (arch) { - Arch.AMD64 -> "coder-windows-amd64.exe" - Arch.ARM64 -> "coder-windows-arm64.exe" - else -> "coder-windows-amd64.exe" - } - - OS.LINUX -> - when (arch) { - Arch.AMD64 -> "coder-linux-amd64" - Arch.ARM64 -> "coder-linux-arm64" - Arch.ARMV7 -> "coder-linux-armv7" - else -> "coder-linux-amd64" - } - OS.MAC -> - when (arch) { - Arch.AMD64 -> "coder-darwin-amd64" - Arch.ARM64 -> "coder-darwin-arm64" - else -> "coder-darwin-amd64" - } + val archName = when (arch) { + Arch.AMD64 -> "amd64" + Arch.ARM64 -> "arm64" + Arch.ARMV7 -> "armv7" + else -> "amd64" // default fallback } + + return "coder-$osName-$archName$extension" + } + + /** + * Return the name of the signature file (.asc) for the provided OS and architecture. + */ + private fun getCoderSignatureForOS(os: OS?, arch: Arch?): String { + logger.debug("Resolving signature for $os $arch") + return "${getCoderCLIForOS(os, arch)}.asc" } /** diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 35040e3..5f8f5af 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -10,6 +10,12 @@ internal const val BINARY_SOURCE = "binarySource" internal const val BINARY_DIRECTORY = "binaryDirectory" +internal const val DISABLE_SIGNATURE_VALIDATION = "disableSignatureValidation" + +internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy" + +internal const val HTTP_CLIENT_LOG_LEVEL = "httpClientLogLevel" + internal const val BINARY_NAME = "binaryName" internal const val DATA_DIRECTORY = "dataDirectory" @@ -38,3 +44,7 @@ internal const val SSH_LOG_DIR = "sshLogDir" internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions" +internal const val NETWORK_INFO_DIR = "networkInfoDir" + +internal const val SSH_AUTO_CONNECT_PREFIX = "ssh_auto_connect_" + diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index c8b8e51..a4c0b48 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -9,23 +9,37 @@ import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus -import com.jetbrains.toolbox.api.localization.LocalizableString +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.views.CoderCliSetupWizardPage +import com.coder.toolbox.views.CoderSettingsPage +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.remoteDev.ProviderVisibilityState +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout -import java.net.HttpURLConnection import java.net.URI -import java.net.URL +import java.util.UUID +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" + +@Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, private val dialogUi: DialogUi, + private val settingsPage: CoderSettingsPage, + private val visibilityState: MutableStateFlow, private val isInitialized: StateFlow, ) { private val settings = context.settingsStore.readOnly() @@ -43,99 +57,285 @@ open class CoderProtocolHandler( reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { val params = uri.toQueryParameters() + if (params.isEmpty()) { + // probably a plugin installation scenario + context.logAndShowInfo("URI will not be handled", "No query parameters were provided") + return + } + // this switches to the main plugin screen, even + // if last opened provider was not Coder + context.envPageManager.showPluginEnvironmentsPage() + if (shouldWaitForAutoLogin) { + isInitialized.waitForTrue() + } + + context.logger.info("Handling $uri...") + val deploymentURL = resolveDeploymentUrl(params) ?: return + val token = if (!context.settingsStore.requireTokenAuth) null else resolveToken(params) ?: return + val workspaceName = resolveWorkspaceName(params) ?: return + + suspend fun onConnect( + restClient: CoderRestClient, + cli: CoderCLIManager + ) { + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) + if (workspace == null) { + context.envPageManager.showPluginEnvironmentsPage() + return + } + reInitialize(restClient, cli) + context.envPageManager.showPluginEnvironmentsPage() + if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + // we resolve the agent after the workspace is started otherwise we can get misleading + // errors like: no agent available while workspace is starting or stopping + // we also need to retrieve the workspace again to have the latest resources (ex: agent) + // attached to the workspace. + val agent: WorkspaceAgent = resolveAgent( + params, + restClient.workspace(workspace.id) + ) ?: return + if (!ensureAgentIsReady(workspace, agent)) return + delay(2.seconds) + val environmentId = "${workspace.name}.${agent.name}" + context.showEnvironmentPage(environmentId) + + val productCode = params.ideProductCode() + val buildNumber = params.ideBuildNumber() + val projectFolder = params.projectFolder() + + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + launchIde(environmentId, productCode, buildNumber, projectFolder) + } + } + + CoderCliSetupContext.apply { + url = deploymentURL.toURL() + CoderCliSetupContext.token = token + } + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + context.ui.showUiPage( + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, true, + jumpToMainPageOnError = true, + onConnect = ::onConnect + ) + ) + } + private suspend fun resolveDeploymentUrl(params: Map): String? { val deploymentURL = params.url() ?: askUrl() if (deploymentURL.isNullOrBlank()) { - context.logger.error("Query parameter \"$URL\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$URL\" is missing")) - return + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI") + return null + } + val validationResult = deploymentURL.validateStrictWebUrl() + if (validationResult is Invalid) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}") + return null } + return deploymentURL + } - val queryToken = params.token() - val restClient = try { - authenticate(deploymentURL, queryToken) - } catch (ex: Exception) { - context.logger.error(ex, "Query parameter \"$TOKEN\" is missing from URI $uri") - context.showErrorPopup(IllegalStateException(humanizeConnectionError(deploymentURL.toURL(), true, ex))) - return + private suspend fun resolveToken(params: Map): String? { + val token = params.token() + if (token.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI") + return null } + return token + } - // TODO: Show a dropdown and ask for the workspace if missing. Right now it's not possible because dialogs are quite limited - val workspaceName = params.workspace() - if (workspaceName.isNullOrBlank()) { - context.logger.error("Query parameter \"$WORKSPACE\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$WORKSPACE\" is missing")) - return + private suspend fun resolveWorkspaceName(params: Map): String? { + val workspace = params.workspace() + if (workspace.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$WORKSPACE\" is missing from URI") + return null + } + return workspace + } + + private suspend fun buildRestClient(deploymentURL: String, token: String?): CoderRestClient? { + try { + return authenticate(deploymentURL, token) + } catch (ex: Exception) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, humanizeConnectionError(deploymentURL.toURL(), true, ex)) + return null } + } - val workspaces = restClient.workspaces() - val workspace = workspaces.firstOrNull { it.name == workspaceName } + /** + * Returns an authenticated Coder CLI. + */ + private suspend fun authenticate(deploymentURL: String, token: String?): CoderRestClient { + val client = CoderRestClient( + context, + deploymentURL.toURL(), + token, + PluginManager.pluginInfo.version + ) + client.initializeSession() + return client + } + + private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { + val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { - context.logger.error("There is no workspace with name $workspaceName on $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace with name $workspaceName does not exist")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "There is no workspace with name $workspaceName on $deploymentURL" + ) + return null } + return workspace + } + private suspend fun prepareWorkspace( + workspace: Workspace, + restClient: CoderRestClient, + workspaceName: String, + deploymentURL: String + ): Boolean { when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be ready on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be ready on time")) - return + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be ready on time" + ) + return false } WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> { if (settings.disableAutostart) { - context.logger.warn("$workspaceName from $deploymentURL is not started and autostart is disabled.") - context.showInfoPopup( - context.i18n.pnotr("$workspaceName is not running"), - context.i18n.ptrl("Can't handle URI because workspace is not running and autostart is disabled. Please start the workspace manually and execute the URI again."), - context.i18n.ptrl("OK") + context.logAndShowWarning( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL is not running and autostart is disabled" ) - return + return false } try { - restClient.startWorkspace(workspace) + if (workspace.outdated) { + restClient.updateWorkspace(workspace) + } else { + restClient.startWorkspace(workspace) + } } catch (e: Exception) { - context.logger.error( - e, - "$workspaceName from $deploymentURL could not be started while handling URI" + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started", + e ) - context.showErrorPopup(MissingArgumentException("Can't handle URI because an error was encountered while trying to start workspace $workspaceName")) - return + return false } - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be started on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be started on time")) - return + + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started on time", + ) + return false } } WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { - context.logger.error("Unable to connect to $workspaceName from $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because because we're unable to connect to workspace $workspaceName")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to connect to $workspaceName from $deploymentURL" + ) + return false } - WorkspaceStatus.RUNNING -> Unit // All is well + WorkspaceStatus.RUNNING -> return true // All is well + } + return true + } + + private suspend fun resolveAgent( + params: Map, + workspace: Workspace + ): WorkspaceAgent? { + try { + return getMatchingAgent(params, workspace) + } catch (e: IllegalArgumentException) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't resolve an agent for workspace ${workspace.name}", + e + ) + return null + } + } + + /** + * Return the agent matching the provided agent ID or name in the parameters. + * + * @throws [IllegalArgumentException] + */ + internal suspend fun getMatchingAgent( + parameters: Map, + workspace: Workspace, + ): WorkspaceAgent? { + val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } + if (agents.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "The workspace \"${workspace.name}\" has no agents") + return null + } + + // If the agent is missing and the workspace has only one, use that. + val agent = if (!parameters.agentName().isNullOrBlank()) { + agents.firstOrNull { it.name == parameters.agentName() } + } else if (agents.size == 1) { + agents.first() + } else { + null } - // TODO: Show a dropdown and ask for an agent if missing. - val agent = getMatchingAgent(params, workspace) + if (agent == null) { + if (!parameters.agentName().isNullOrBlank()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "The workspace \"${workspace.name}\" does not have an agent with name \"${parameters.agentName()}\"" + ) + return null + } else { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to determine which agent to connect to; \"$AGENT_NAME\" must be set because the workspace \"${workspace.name}\" has more than one agent" + ) + return null + } + } + return agent + } + + private suspend fun ensureAgentIsReady( + workspace: Workspace, + agent: WorkspaceAgent + ): Boolean { val status = WorkspaceAndAgentStatus.from(workspace, agent) if (!status.ready()) { - context.logger.error("Agent ${agent.name} for workspace $workspaceName from $deploymentURL is not started") - context.showErrorPopup(MissingArgumentException("Can't handle URI because agent ${agent.name} for workspace $workspaceName from $deploymentURL is not started")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Agent ${agent.name} for workspace ${workspace.name} is not ready" + ) + return false } + return true + } + private suspend fun configureCli( + deploymentURL: String, + restClient: CoderRestClient, + progressReporter: (String) -> Unit + ): CoderCLIManager { val cli = ensureCLI( context, deploymentURL.toURL(), - restClient.buildInfo().version + restClient.buildInfo().version, + progressReporter ) // We only need to log in if we are using token-based auth. @@ -145,31 +345,93 @@ open class CoderProtocolHandler( } context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.agentNames(workspaces)) + cli.configSsh(restClient.workspacesByAgents()) + return cli + } - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() + private fun launchIde( + environmentId: String, + productCode: String, + buildNumber: String, + projectFolder: String? + ) { + context.cs.launch(CoroutineName("Launch Remote IDE")) { + val selectedIde = selectAndInstallRemoteIde(productCode, buildNumber, environmentId) ?: return@launch + context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") + installJBClient(selectedIde, environmentId).join() + launchJBClient(selectedIde, environmentId, projectFolder) } - reInitialize(restClient, cli) + } - val environmentId = "${workspace.name}.${agent.name}" - context.popupPluginMainPage() - context.envPageManager.showEnvironmentPage(environmentId, false) - val productCode = params.ideProductCode() - val buildNumber = params.ideBuildNumber() - val projectPath = params.projectPath() - if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { - context.cs.launch { - val ideVersion = "$productCode-$buildNumber" - context.logger.info("installing $ideVersion on $environmentId") - val job = context.cs.launch { - context.ideOrchestrator.prepareClient(environmentId, ideVersion) - } - job.join() - context.logger.info("launching $ideVersion on $environmentId") - context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectPath) + private suspend fun selectAndInstallRemoteIde( + productCode: String, + buildNumber: String, + environmentId: String + ): String? { + val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + + var selectedIde = "$productCode-$buildNumber" + if (installedIdes.firstOrNull { it.contains(buildNumber) } != null) { + context.logger.info("$selectedIde is already installed on $environmentId") + return selectedIde + } + + selectedIde = resolveAvailableIde(environmentId, productCode, buildNumber) ?: return null + + // needed otherwise TBX will install it again + if (!installedIdes.contains(selectedIde)) { + context.logger.info("Installing $selectedIde on $environmentId...") + context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) + + if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { + context.logger.info("Successfully installed $selectedIde on $environmentId...") + return selectedIde + } else { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$selectedIde could not be installed"), + context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), + context.i18n.ptrl("OK") + ) + return null } + } else { + context.logger.info("$selectedIde is already present on $environmentId...") + return selectedIde + } + } + + private suspend fun resolveAvailableIde(environmentId: String, productCode: String, buildNumber: String): String? { + val availableVersions = context + .remoteIdeOrchestrator + .getAvailableRemoteTools(environmentId, productCode) + + if (availableVersions.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "$productCode is not available on $environmentId") + return null + } + + val buildNumberIsNotAvailable = availableVersions.firstOrNull { it.contains(buildNumber) } == null + if (buildNumberIsNotAvailable) { + val selectedIde = availableVersions.maxOf { it } + context.logAndShowInfo( + "$productCode-$buildNumber not available", + "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" + ) + return selectedIde + } + return "$productCode-$buildNumber" + } + + private fun installJBClient(selectedIde: String, environmentId: String): Job = + context.cs.launch(CoroutineName("JBClient Installer")) { + context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") + context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) } + + private fun launchJBClient(selectedIde: String, environmentId: String, projectFolder: String?) { + context.logger.info("Launching $selectedIde on $environmentId") + context.jbClientOrchestrator.connectToIde(environmentId, selectedIde, projectFolder) } private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean { @@ -187,6 +449,25 @@ open class CoderProtocolHandler( } } + private suspend fun RemoteToolsHelper.waitForIdeToBeInstalled( + environmentId: String, + ideHint: String, + waitTime: Duration = 2.minutes + ): Boolean { + var isInstalled = false + try { + withTimeout(waitTime.toJavaDuration()) { + while (!isInstalled) { + delay(5.seconds) + isInstalled = getInstalledRemoteTools(environmentId, ideHint).isNotEmpty() + } + } + return true + } catch (_: TimeoutCancellationException) { + return false + } + } + private suspend fun askUrl(): String? { context.popupPluginMainPage() return dialogUi.ask( @@ -194,140 +475,11 @@ open class CoderProtocolHandler( context.i18n.ptrl("Enter the full URL of your Coder deployment") ) } - - /** - * Return an authenticated Coder CLI, asking for the token. - * Throw MissingArgumentException if the user aborts. Any network or invalid - * token error may also be thrown. - */ - private suspend fun authenticate( - deploymentURL: String, - tryToken: String? - ): CoderRestClient { - val token = - if (settings.requireTokenAuth) { - // Try the provided token immediately on the first attempt. - if (!tryToken.isNullOrBlank()) { - tryToken - } else { - context.popupPluginMainPage() - // Otherwise ask for a new token, showing the previous token. - dialogUi.askToken(deploymentURL.toURL()) - } - } else { - null - } - - if (settings.requireTokenAuth && token == null) { // User aborted. - throw MissingArgumentException("Token is required") - } - // The http client Toolbox gives us is already set up with the - // proxy config, so we do net need to explicitly add it. - val client = CoderRestClient( - context, - deploymentURL.toURL(), - token, - proxyValues = null, // TODO - not sure the above comment applies as we are creating our own http client - PluginManager.pluginInfo.version - ) - client.authenticate() - return client - } - -} - -/** - * Follow a URL's redirects to its final destination. - */ -internal fun resolveRedirects(url: URL): URL { - var location = url - val maxRedirects = 10 - for (i in 1..maxRedirects) { - val conn = location.openConnection() as HttpURLConnection - conn.instanceFollowRedirects = false - conn.connect() - val code = conn.responseCode - val nextLocation = conn.getHeaderField("Location") - conn.disconnect() - // Redirects are triggered by any code starting with 3 plus a - // location header. - if (code < 300 || code >= 400 || nextLocation.isNullOrBlank()) { - return location - } - // Location headers might be relative. - location = URL(location, nextLocation) - } - throw Exception("Too many redirects") -} - -/** - * Return the agent matching the provided agent ID or name in the parameters. - * The name is ignored if the ID is set. If neither was supplied and the - * workspace has only one agent, return that. Otherwise throw an error. - * - * @throws [MissingArgumentException, IllegalArgumentException] - */ -internal fun getMatchingAgent( - parameters: Map, - workspace: Workspace, -): WorkspaceAgent { - val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } - if (agents.isEmpty()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" has no agents") - } - - // If the agent is missing and the workspace has only one, use that. - // Prefer the ID over the name if both are set. - val agent = - if (!parameters.agentID().isNullOrBlank()) { - agents.firstOrNull { it.id.toString() == parameters.agentID() } - } else if (!parameters.agentName().isNullOrBlank()) { - agents.firstOrNull { it.name == parameters.agentName() } - } else if (agents.size == 1) { - agents.first() - } else { - null - } - - if (agent == null) { - if (!parameters.agentID().isNullOrBlank()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"") - } else if (!parameters.agentName().isNullOrBlank()) { - throw IllegalArgumentException( - "The workspace \"${workspace.name}\"does not have an agent named \"${parameters.agentName()}\"", - ) - } else { - throw MissingArgumentException( - "Unable to determine which agent to connect to; one of \"$AGENT_NAME\" or \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent", - ) - } - } - - return agent -} - -private suspend fun CoderToolboxContext.showErrorPopup(error: Throwable) { - popupPluginMainPage() - this.ui.showErrorInfoPopup(error) } -private suspend fun CoderToolboxContext.showInfoPopup( - title: LocalizableString, - message: LocalizableString, - okLabel: LocalizableString -) { - popupPluginMainPage() - this.ui.showInfoPopup(title, message, okLabel) -} - -private fun CoderToolboxContext.popupPluginMainPage() { +private suspend fun CoderToolboxContext.showEnvironmentPage(envId: String) { this.ui.showWindow() - this.envPageManager.showPluginEnvironmentsPage(true) + this.envPageManager.showEnvironmentPage(envId, false) } -/** - * Suspends the coroutine until first true value is received. - */ -suspend fun StateFlow.waitForTrue() = this.first { it } - class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex) diff --git a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt index 44a3dfb..3678813 100644 --- a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt +++ b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt @@ -1,10 +1,8 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.browser.BrowserUtil import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.TextType -import java.net.URL /** * Dialog implementation for standalone Gateway. @@ -23,47 +21,7 @@ class DialogUi(private val context: CoderToolboxContext) { placeholder: LocalizableString? = null, ): String? { return context.ui.showTextInputPopup( - title, - description, - placeholder, - TextType.General, - context.i18n.ptrl("OK"), - context.i18n.ptrl("Cancel") - ) - } - - suspend fun askPassword( - title: LocalizableString, - description: LocalizableString, - placeholder: LocalizableString? = null, - ): String? { - return context.ui.showTextInputPopup( - title, - description, - placeholder, - TextType.Password, - context.i18n.ptrl("OK"), - context.i18n.ptrl("Cancel") - ) - } - - private suspend fun openUrl(url: URL) { - BrowserUtil.browse(url.toString()) { - context.ui.showErrorInfoPopup(it) - } - } - - /** - * Open a dialog for providing the token. - */ - suspend fun askToken( - url: URL, - ): String? { - openUrl(url.withPath("/login?redirect=%2Fcli-auth")) - return askPassword( - title = context.i18n.ptrl("Session Token"), - description = context.i18n.pnotr("Please paste the session token from the web-page"), - placeholder = context.i18n.pnotr("") + title, description, placeholder, TextType.General, context.i18n.ptrl("OK"), context.i18n.ptrl("Cancel") ) } } diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index 9e2ef49..a343e14 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -1,20 +1,12 @@ package com.coder.toolbox.util -// These are keys that we support in our Gateway links and must not be changed. -private const val TYPE = "type" const val URL = "url" const val TOKEN = "token" const val WORKSPACE = "workspace" -const val AGENT_NAME = "agent" -const val AGENT_ID = "agent_id" +const val AGENT_NAME = "agent_name" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" -private const val PROJECT_PATH = "project_path" - -// Helper functions for reading from the map. Prefer these to directly -// interacting with the map. - -fun Map.isCoder(): Boolean = this[TYPE] == "coder" +private const val FOLDER = "folder" fun Map.url() = this[URL] @@ -24,10 +16,8 @@ fun Map.workspace() = this[WORKSPACE] fun Map.agentName() = this[AGENT_NAME] -fun Map.agentID() = this[AGENT_ID] - fun Map.ideProductCode() = this[IDE_PRODUCT_CODE] fun Map.ideBuildNumber() = this[IDE_BUILD_NUMBER] -fun Map.projectPath() = this[PROJECT_PATH] +fun Map.projectFolder() = this[FOLDER] diff --git a/src/main/kotlin/com/coder/toolbox/util/OS.kt b/src/main/kotlin/com/coder/toolbox/util/OS.kt index 32abd5e..ba39204 100644 --- a/src/main/kotlin/com/coder/toolbox/util/OS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/OS.kt @@ -1,30 +1,19 @@ package com.coder.toolbox.util -import java.util.* +import java.util.Locale fun getOS(): OS? = OS.from(System.getProperty("os.name")) -fun getArch(): Arch? = Arch.from(System.getProperty("os.arch").lowercase(Locale.getDefault())) +fun getArch(): Arch? = Arch.from(System.getProperty("os.arch")?.lowercase(Locale.getDefault())) enum class OS { WINDOWS, LINUX, MAC; - /** - * The name of the current desktop environment. - * For Linux systems it can be GNOME, KDE, XFCE, LXDE, and so on, - * while for macOS it will be Aqua and Windows Shell for Windows. - */ - fun getDesktopEnvironment(): String? = - when (this) { - WINDOWS -> "Windows Shell" - MAC -> "Aqua" - LINUX -> System.getenv("XDG_CURRENT_DESKTOP") - } - companion object { - fun from(os: String): OS? = when { + fun from(os: String?): OS? = when { + os.isNullOrBlank() -> null os.contains("win", true) -> { WINDOWS } @@ -49,7 +38,8 @@ enum class Arch { ; companion object { - fun from(arch: String): Arch? = when { + fun from(arch: String?): Arch? = when { + arch.isNullOrBlank() -> null arch.contains("amd64", true) || arch.contains("x86_64", true) -> AMD64 arch.contains("arm64", true) || arch.contains("aarch64", true) -> ARM64 arch.contains("armv7", true) -> ARMV7 diff --git a/src/main/kotlin/com/coder/toolbox/util/SemVer.kt b/src/main/kotlin/com/coder/toolbox/util/SemVer.kt index 238ce81..a40a9a9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/SemVer.kt +++ b/src/main/kotlin/com/coder/toolbox/util/SemVer.kt @@ -1,6 +1,6 @@ package com.coder.toolbox.util -class SemVer(private val major: Long = 0, private val minor: Long = 0, private val patch: Long = 0) : Comparable { +class SemVer(val major: Long = 0, val minor: Long = 0, val patch: Long = 0) : Comparable { init { require(major >= 0) { "Coder major version must be a positive number" } require(minor >= 0) { "Coder minor version must be a positive number" } diff --git a/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt new file mode 100644 index 0000000..46ae602 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt @@ -0,0 +1,22 @@ +package com.coder.toolbox.util + +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration + +/** + * Suspends the coroutine until first true value is received. + */ +suspend fun StateFlow.waitForTrue() = this.first { it } + +/** + * Suspends the coroutine until first false value is received. + */ +suspend fun StateFlow.waitForFalseWithTimeout(duration: Duration): Boolean? { + if (!this.value) return false + + return withTimeoutOrNull(duration) { + this@waitForFalseWithTimeout.first { !it } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt index c1aaa81..7e2a8e3 100644 --- a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt @@ -1,11 +1,44 @@ package com.coder.toolbox.util +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.WebUrlValidationResult.Valid import java.net.IDN import java.net.URI import java.net.URL fun String.toURL(): URL = URI.create(this).toURL() +fun String.validateStrictWebUrl(): WebUrlValidationResult = try { + val uri = URI(this) + + when { + uri.isOpaque -> Invalid( + "The URL \"$this\" is invalid because it is not in the standard format. " + + "Please enter a full web address like \"https://example.com\"" + ) + + !uri.isAbsolute -> Invalid( + "The URL \"$this\" is missing a scheme (like https://). " + + "Please enter a full web address like \"https://example.com\"" + ) + uri.scheme?.lowercase() !in setOf("http", "https") -> + Invalid( + "The URL \"$this\" must start with http:// or https://, not \"${uri.scheme}\"" + ) + uri.authority.isNullOrBlank() -> + Invalid( + "The URL \"$this\" does not include a valid website name. " + + "Please enter a full web address like \"https://example.com\"" + ) + else -> Valid + } +} catch (_: Exception) { + Invalid( + "The input \"$this\" is not a valid web address. " + + "Please enter a full web address like \"https://example.com\"" + ) +} + fun URL.withPath(path: String): URL = URL( this.protocol, this.host, @@ -30,3 +63,8 @@ fun URI.toQueryParameters(): Map = (this.query ?: "") parts[0] to "" } } + +sealed class WebUrlValidationResult { + object Valid : WebUrlValidationResult() + data class Invalid(val reason: String) : WebUrlValidationResult() +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt similarity index 58% rename from src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt rename to src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index ad0a6de..bca3606 100644 --- a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -3,27 +3,42 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.views.state.AuthWizardState +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiField import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -class AuthWizardPage( +class CoderCliSetupWizardPage( private val context: CoderToolboxContext, - initialAutoLogin: Boolean = false, - onConnect: ( + private val settingsPage: CoderSettingsPage, + visibilityState: StateFlow, + initialAutoSetup: Boolean = false, + jumpToMainPageOnError: Boolean = false, + onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, -) : CoderPage(context, context.i18n.ptrl("Authenticate to Coder")) { - private val shouldAutoLogin = MutableStateFlow(initialAutoLogin) +) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { + private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) + private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { + context.ui.showUiPage(settingsPage) + }) - private val signInStep = SignInStep(context) + private val deploymentUrlStep = DeploymentUrlStep(context, visibilityState) private val tokenStep = TokenStep(context) - private val connectStep = ConnectStep(context, shouldAutoLogin, this::notify, this::displaySteps, onConnect) - + private val connectStep = ConnectStep( + context, + shouldAutoLogin = shouldAutoSetup, + jumpToMainPageOnError, + visibilityState, + this::displaySteps, + onConnect + ) + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) /** * Fields for this page, displayed in order. @@ -31,26 +46,29 @@ class AuthWizardPage( override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) + override fun beforeShow() { displaySteps() + errorReporter.flush() } private fun displaySteps() { - when (AuthWizardState.currentStep()) { + when (CoderCliSetupWizardState.currentStep()) { WizardStep.URL_REQUEST -> { fields.update { - listOf(signInStep.panel) + listOf(deploymentUrlStep.panel) } actionButtons.update { listOf( - Action(context.i18n.ptrl("Sign In"), closesPage = false, actionBlock = { - if (signInStep.onNext()) { + Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = { + if (deploymentUrlStep.onNext()) { displaySteps() } - }) + }), + settingsAction ) } - signInStep.onVisible() + deploymentUrlStep.onVisible() } WizardStep.TOKEN_REQUEST -> { @@ -64,6 +82,7 @@ class AuthWizardPage( displaySteps() } }), + settingsAction, Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { tokenStep.onBack() displaySteps() @@ -73,15 +92,16 @@ class AuthWizardPage( tokenStep.onVisible() } - WizardStep.LOGIN -> { + WizardStep.CONNECT -> { fields.update { listOf(connectStep.panel) } actionButtons.update { listOf( + settingsAction, Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { connectStep.onBack() - shouldAutoLogin.update { + shouldAutoSetup.update { false } displaySteps() @@ -92,4 +112,9 @@ class AuthWizardPage( } } } + + /** + * Show an error as a popup on this page. + */ + fun notify(message: String, ex: Throwable) = errorReporter.report(message, ex) } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index eb2f252..eec0765 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -1,11 +1,12 @@ package com.coder.toolbox.views -import com.coder.toolbox.CoderToolboxContext import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update /** * Base page that handles the icon, displaying error notifications, and @@ -18,16 +19,15 @@ import com.jetbrains.toolbox.api.ui.components.UiPage * to use the mouse. */ abstract class CoderPage( - private val context: CoderToolboxContext, - title: LocalizableString, + private val titleObservable: MutableStateFlow, showIcon: Boolean = true, -) : UiPage(title) { +) : UiPage(titleObservable) { - /** Toolbox uses this to show notifications on the page. */ - private var notifier: ((Throwable) -> Unit)? = null - - /** Stores errors until the notifier is attached. */ - private var errorBuffer: MutableList = mutableListOf() + fun setTitle(title: LocalizableString) { + titleObservable.update { + title + } + } /** * Return the icon, if showing one. @@ -42,28 +42,6 @@ abstract class CoderPage( } else { SvgIcon(byteArrayOf(), type = IconType.Masked) } - - /** - * Show an error as a popup on this page. - */ - fun notify(logPrefix: String, ex: Throwable) { - context.logger.error(ex, logPrefix) - // It is possible the error listener is not attached yet. - notifier?.let { it(ex) } ?: errorBuffer.add(ex) - } - - /** - * Immediately notify any pending errors and store for later errors. - */ - override fun setActionErrorNotifier(notifier: ((Throwable) -> Unit)?) { - this.notifier = notifier - notifier?.let { - errorBuffer.forEach { - notifier(it) - } - errorBuffer.clear() - } - } } /** diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index ff86c42..3444683 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -1,15 +1,24 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.HttpLoggingVerbosity.BASIC +import com.coder.toolbox.settings.HttpLoggingVerbosity.BODY +import com.coder.toolbox.settings.HttpLoggingVerbosity.HEADERS +import com.coder.toolbox.settings.HttpLoggingVerbosity.NONE import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.CheckboxField +import com.jetbrains.toolbox.api.ui.components.ComboBoxField +import com.jetbrains.toolbox.api.ui.components.ComboBoxField.LabelledValue import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -19,8 +28,8 @@ import kotlinx.coroutines.launch * TODO@JB: There is no scroll, and our settings do not fit. As a consequence, * I have not been able to test this page. */ -class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel) : - CoderPage(context, context.i18n.ptrl("Coder Settings"), false) { +class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConfig: Channel) : + CoderPage(MutableStateFlow(context.i18n.ptrl("Coder Settings")), false) { private val settings = context.settingsStore.readOnly() // TODO: Copy over the descriptions, holding until I can test this page. @@ -32,6 +41,28 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General) private val enableDownloadsField = CheckboxField(settings.enableDownloads, context.i18n.ptrl("Enable downloads")) + + private val disableSignatureVerificationField = CheckboxField( + settings.disableSignatureVerification, + context.i18n.ptrl("Disable Coder CLI signature verification") + ) + private val signatureFallbackStrategyField = + CheckboxField( + settings.fallbackOnCoderForSignatures.isAllowed(), + context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") + ) + + private val httpLoggingField = ComboBoxField( + ComboBoxField.Label(context.i18n.ptrl("HTTP logging level:")), + settings.httpClientLogLevel, + listOf( + LabelledValue(context.i18n.ptrl("None"), NONE, listOf("" to "No logs")), + LabelledValue(context.i18n.ptrl("Basic"), BASIC, listOf("" to "Method, URL and status")), + LabelledValue(context.i18n.ptrl("Header"), HEADERS, listOf("" to " Basic + sanitized headers")), + LabelledValue(context.i18n.ptrl("Body"), BODY, listOf("" to "Headers + body content")), + ) + ) + private val enableBinaryDirectoryFallbackField = CheckboxField( settings.enableBinaryDirectoryFallback, @@ -56,14 +87,19 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< TextField(context.i18n.ptrl("Extra SSH options"), settings.sshConfigOptions ?: "", TextType.General) private val sshLogDirField = TextField(context.i18n.ptrl("SSH proxy log directory"), settings.sshLogDirectory ?: "", TextType.General) + private val networkInfoDirField = + TextField(context.i18n.ptrl("SSH network metrics directory"), settings.networkInfoDir, TextType.General) - + private lateinit var visibilityUpdateJob: Job override val fields: StateFlow> = MutableStateFlow( listOf( binarySourceField, enableDownloadsField, binaryDirectoryField, enableBinaryDirectoryFallbackField, + disableSignatureVerificationField, + signatureFallbackStrategyField, + httpLoggingField, dataDirectoryField, headerCommandField, tlsCertPathField, @@ -73,6 +109,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< disableAutostartField, enableSshWildCardConfig, sshLogDirField, + networkInfoDirField, sshExtraArgs, ) ) @@ -80,22 +117,25 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< override val actionButtons: StateFlow> = MutableStateFlow( listOf( Action(context.i18n.ptrl("Save"), closesPage = true) { - context.settingsStore.updateBinarySource(binarySourceField.textState.value) - context.settingsStore.updateBinaryDirectory(binaryDirectoryField.textState.value) - context.settingsStore.updateDataDirectory(dataDirectoryField.textState.value) + context.settingsStore.updateBinarySource(binarySourceField.contentState.value) + context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) + context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) + context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) + context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) + context.settingsStore.updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) context.settingsStore.updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value) - context.settingsStore.updateHeaderCommand(headerCommandField.textState.value) - context.settingsStore.updateCertPath(tlsCertPathField.textState.value) - context.settingsStore.updateKeyPath(tlsKeyPathField.textState.value) - context.settingsStore.updateCAPath(tlsCAPathField.textState.value) - context.settingsStore.updateAltHostname(tlsAlternateHostnameField.textState.value) + context.settingsStore.updateHeaderCommand(headerCommandField.contentState.value) + context.settingsStore.updateCertPath(tlsCertPathField.contentState.value) + context.settingsStore.updateKeyPath(tlsKeyPathField.contentState.value) + context.settingsStore.updateCAPath(tlsCAPathField.contentState.value) + context.settingsStore.updateAltHostname(tlsAlternateHostnameField.contentState.value) context.settingsStore.updateDisableAutostart(disableAutostartField.checkedState.value) val oldIsSshWildcardConfigEnabled = settings.isSshWildcardConfigEnabled context.settingsStore.updateEnableSshWildcardConfig(enableSshWildCardConfig.checkedState.value) if (enableSshWildCardConfig.checkedState.value != oldIsSshWildcardConfigEnabled) { - context.cs.launch { + context.cs.launch(CoroutineName("SSH Wildcard Setting")) { try { triggerSshConfig.send(true) context.logger.info("Wildcard settings have been modified from $oldIsSshWildcardConfigEnabled to ${!oldIsSshWildcardConfigEnabled}, ssh config is going to be regenerated...") @@ -103,9 +143,87 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< } } } - context.settingsStore.updateSshLogDir(sshLogDirField.textState.value) - context.settingsStore.updateSshConfigOptions(sshExtraArgs.textState.value) + context.settingsStore.updateSshLogDir(sshLogDirField.contentState.value) + context.settingsStore.updateNetworkInfoDir(networkInfoDirField.contentState.value) + context.settingsStore.updateSshConfigOptions(sshExtraArgs.contentState.value) } ) ) + + override fun beforeShow() { + // update the value of all fields + binarySourceField.contentState.update { + settings.binarySource ?: "" + } + binaryDirectoryField.contentState.update { + settings.binaryDirectory ?: "" + } + dataDirectoryField.contentState.update { + settings.dataDirectory ?: "" + } + enableDownloadsField.checkedState.update { + settings.enableDownloads + } + signatureFallbackStrategyField.checkedState.update { + settings.fallbackOnCoderForSignatures.isAllowed() + } + + enableBinaryDirectoryFallbackField.checkedState.update { + settings.enableBinaryDirectoryFallback + } + + headerCommandField.contentState.update { + settings.headerCommand ?: "" + } + + tlsCertPathField.contentState.update { + settings.tls.certPath ?: "" + } + + tlsKeyPathField.contentState.update { + settings.tls.keyPath ?: "" + } + + tlsCAPathField.contentState.update { + settings.tls.caPath ?: "" + } + + tlsAlternateHostnameField.contentState.update { + settings.tls.altHostname ?: "" + } + + disableAutostartField.checkedState.update { + settings.disableAutostart + } + + enableSshWildCardConfig.checkedState.update { + settings.isSshWildcardConfigEnabled + } + + sshExtraArgs.contentState.update { + settings.sshConfigOptions ?: "" + } + + sshLogDirField.contentState.update { + settings.sshLogDirectory ?: "" + } + + networkInfoDirField.contentState.update { + settings.networkInfoDir + } + + visibilityUpdateJob = context.cs.launch(CoroutineName("Signature Verification Fallback Setting")) { + disableSignatureVerificationField.checkedState.collect { state -> + signatureFallbackStrategyField.visibility.update { + // the fallback checkbox should not be visible + // if signature verification is disabled + !state + } + } + } + } + + override fun afterHide() { + visibilityUpdateJob.cancel() + } } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 30f757b..7798328 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -5,18 +5,19 @@ import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.util.toURL -import com.coder.toolbox.views.state.AuthWizardState -import com.jetbrains.toolbox.api.localization.LocalizableString +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.yield -import java.util.concurrent.CancellationException private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -26,32 +27,36 @@ private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" class ConnectStep( private val context: CoderToolboxContext, private val shouldAutoLogin: StateFlow, - private val notify: (String, Throwable) -> Unit, + private val jumpToMainPageOnError: Boolean, + visibilityState: StateFlow, private val refreshWizard: () -> Unit, - private val onConnect: ( - client: CoderRestClient, - cli: CoderCLIManager, - ) -> Unit, + private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, ) : WizardStep { private var signInJob: Job? = null private val statusField = LabelField(context.i18n.pnotr("")) private val errorField = ValidationErrorField(context.i18n.pnotr("")) + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) override val panel: RowGroup = RowGroup( RowGroup.RowField(statusField), RowGroup.RowField(errorField) ) - override val nextButtonTitle: LocalizableString? = null - override fun onVisible() { + errorReporter.flush() errorField.textState.update { context.i18n.pnotr("") } - val url = context.deploymentUrl?.first?.toURL() - statusField.textState.update { context.i18n.pnotr("Connecting to ${url?.host}...") } + if (context.settingsStore.requireTokenAuth && CoderCliSetupContext.isNotReadyForAuth()) { + errorField.textState.update { + context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!") + } + return + } + + statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderCliSetupContext.url!!.host}...") } connect() } @@ -59,75 +64,103 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - val url = context.deploymentUrl?.first?.toURL() - val token = context.getToken(context.deploymentUrl?.first)?.first - if (url == null) { + if (!CoderCliSetupContext.hasUrl()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return } - if (token.isNullOrBlank()) { + if (context.settingsStore.requireTokenAuth && !CoderCliSetupContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } + // Capture the host name early for error reporting + val hostName = CoderCliSetupContext.url!!.host + signInJob?.cancel() - signInJob = context.cs.launch { + signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) { try { - statusField.textState.update { (context.i18n.ptrl("Authenticating to ${url.host}...")) } - // The http client Toolbox gives us is already set up with the - // proxy config, so we do net need to explicitly add it. + context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( context, - url, - token, - proxyValues = null, + CoderCliSetupContext.url!!, + if (context.settingsStore.requireTokenAuth) CoderCliSetupContext.token else null, PluginManager.pluginInfo.version, ) // allows interleaving with the back/cancel action yield() - client.authenticate() - statusField.textState.update { (context.i18n.ptrl("Checking Coder binary...")) } - val cli = ensureCLI(context, client.url, client.buildVersion) + client.initializeSession() + logAndReportProgress("Checking Coder CLI...") + val cli = ensureCLI( + context, client.url, + client.buildVersion + ) { progress -> + statusField.textState.update { (context.i18n.pnotr(progress)) } + } // We only need to log in if we are using token-based auth. - if (client.token != null) { - statusField.textState.update { (context.i18n.ptrl("Configuring CLI...")) } + if (context.settingsStore.requireTokenAuth) { + logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() - cli.login(client.token) + cli.login(client.token!!) } - statusField.textState.update { (context.i18n.ptrl("Successfully configured ${url.host}...")) } + logAndReportProgress("Successfully configured ${hostName}...") // allows interleaving with the back/cancel action yield() + context.logger.info("Connection setup done, initializing the workspace poller...") onConnect(client, cli) - AuthWizardState.resetSteps() + + CoderCliSetupContext.reset() + CoderCliSetupWizardState.goToFirstStep() + context.envPageManager.showPluginEnvironmentsPage() } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { - notify("Connection to ${url.host} was configured", ex) - onBack() + errorReporter.report("Connection to $hostName was configured", ex) + handleNavigation() refreshWizard() } } catch (ex: Exception) { - notify("Failed to configure ${url.host}", ex) - onBack() + errorReporter.report("Failed to configure $hostName", ex) + handleNavigation() refreshWizard() } } } + private fun logAndReportProgress(msg: String) { + context.logger.info(msg) + statusField.textState.update { context.i18n.pnotr(msg) } + } + + /** + * Handle navigation logic for both errors and back button + */ + private fun handleNavigation() { + if (shouldAutoLogin.value) { + CoderCliSetupContext.reset() + if (jumpToMainPageOnError) { + context.popupPluginMainPage() + } else { + CoderCliSetupWizardState.goToFirstStep() + } + } else { + if (context.settingsStore.requireTokenAuth) { + CoderCliSetupWizardState.goToPreviousStep() + } else { + CoderCliSetupWizardState.goToFirstStep() + } + } + } + override fun onNext(): Boolean { return false } override fun onBack() { try { + context.logger.info("Back button was pressed, cancelling in-progress connection setup...") signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON)) } finally { - if (shouldAutoLogin.value) { - AuthWizardState.resetSteps() - context.secrets.rememberMe = false - } else { - AuthWizardState.goToPreviousStep() - } + handleNavigation() } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt new file mode 100644 index 0000000..be3d4d0 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -0,0 +1,115 @@ +package com.coder.toolbox.views + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.validateStrictWebUrl +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState +import com.jetbrains.toolbox.api.ui.components.CheckboxField +import com.jetbrains.toolbox.api.ui.components.LabelField +import com.jetbrains.toolbox.api.ui.components.LabelStyleType +import com.jetbrains.toolbox.api.ui.components.RowGroup +import com.jetbrains.toolbox.api.ui.components.TextField +import com.jetbrains.toolbox.api.ui.components.TextType +import com.jetbrains.toolbox.api.ui.components.ValidationErrorField +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import java.net.MalformedURLException +import java.net.URL + +/** + * A page with a field for providing the Coder deployment URL. + * + * Populates with the provided URL, at which point the user can accept or + * enter their own. + */ +class DeploymentUrlStep( + private val context: CoderToolboxContext, + visibilityState: StateFlow, +) : + WizardStep { + private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) + + private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General) + private val emptyLine = LabelField(context.i18n.pnotr(""), LabelStyleType.Normal) + + private val signatureFallbackStrategyField = CheckboxField( + context.settingsStore.fallbackOnCoderForSignatures.isAllowed(), + context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") + ) + + private val errorField = ValidationErrorField(context.i18n.pnotr("")) + + override val panel: RowGroup + get() { + if (!context.settingsStore.disableSignatureVerification) { + return RowGroup( + RowGroup.RowField(urlField), + RowGroup.RowField(emptyLine), + RowGroup.RowField(signatureFallbackStrategyField), + RowGroup.RowField(errorField) + ) + + } + return RowGroup( + RowGroup.RowField(urlField), + RowGroup.RowField(errorField) + ) + } + + override fun onVisible() { + errorField.textState.update { + context.i18n.pnotr("") + } + urlField.textState.update { + context.secrets.lastDeploymentURL + } + + signatureFallbackStrategyField.checkedState.update { + context.settingsStore.fallbackOnCoderForSignatures.isAllowed() + } + errorReporter.flush() + } + + override fun onNext(): Boolean { + context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) + val url = urlField.textState.value + if (url.isBlank()) { + errorField.textState.update { context.i18n.ptrl("URL is required") } + return false + } + try { + CoderCliSetupContext.url = validateRawUrl(url) + } catch (e: MalformedURLException) { + errorReporter.report("URL is invalid", e) + return false + } + if (context.settingsStore.requireTokenAuth) { + CoderCliSetupWizardState.goToNextStep() + } else { + CoderCliSetupWizardState.goToLastStep() + } + return true + } + + /** + * Throws [MalformedURLException] if the given string violates RFC-2396 + */ + private fun validateRawUrl(url: String): URL { + try { + val result = url.validateStrictWebUrl() + if (result is Invalid) { + throw MalformedURLException(result.reason) + } + return url.toURL() + } catch (e: Exception) { + throw MalformedURLException(e.message) + } + } + + override fun onBack() { + // it's the first step. Can't go anywhere back from here + } +} diff --git a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt index 89ef3dd..3353fe4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt +++ b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.views import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent -import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.jetbrains.toolbox.api.remoteDev.environments.SshEnvironmentContentsView import com.jetbrains.toolbox.api.remoteDev.ssh.SshConnectionInfo import java.net.URL @@ -17,31 +16,56 @@ import java.net.URL * SSH must be configured before this will work. */ class EnvironmentView( - private val settings: ReadOnlyCoderSettings, private val url: URL, + private val cli: CoderCLIManager, private val workspace: Workspace, private val agent: WorkspaceAgent, ) : SshEnvironmentContentsView { - override suspend fun getConnectionInfo(): SshConnectionInfo = object : SshConnectionInfo { - /** - * The host name generated by the cli manager for this workspace. - */ - override val host: String = resolveHost() - - /** - * The port is ignored by the Coder proxy command. - */ - override val port: Int = 22 - - /** - * The username is ignored by the Coder proxy command. - */ - override val userName: String? = "coder" + override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(url, cli, workspace, agent) +} + +private class WorkspaceSshConnectionInfo( + url: URL, + cli: CoderCLIManager, + private val workspace: Workspace, + private val agent: WorkspaceAgent, +) : SshConnectionInfo { + /** + * The host name generated by the cli manager for this workspace. + */ + override val host: String = cli.getHostname(url, workspace, agent) + + /** + * The port is ignored by the Coder proxy command. + */ + override val port: Int = 22 + + /** + * The username is ignored by the Coder proxy command. + */ + override val userName: String? = null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as WorkspaceSshConnectionInfo + + if (port != other.port) return false + if (workspace.name != other.workspace.name) return false + if (agent.name != other.agent.name) return false + if (host != other.host) return false + + return true } - private fun resolveHost(): String = - if (settings.isSshWildcardConfigEnabled) - CoderCLIManager.getWildcardHostname(url, workspace, agent) - else CoderCLIManager.getHostname(url, workspace, agent) -} + override fun hashCode(): Int { + var result = port + result = 31 * result + workspace.name.hashCode() + result = 31 * result + agent.name.hashCode() + result = 31 * result + host.hashCode() + return result + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt b/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt new file mode 100644 index 0000000..88ace65 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt @@ -0,0 +1,73 @@ +package com.coder.toolbox.views + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.ex.APIResponseException +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +sealed class ErrorReporter { + + /** + * Logs and show errors as popups. + */ + abstract fun report(message: String, ex: Throwable) + + /** + * Processes any buffered errors when the application becomes visible. + */ + abstract fun flush() + + companion object { + fun create( + context: CoderToolboxContext, + visibilityState: StateFlow, + callerClass: Class<*> + ): ErrorReporter = ErrorReporterImpl(context, visibilityState, callerClass) + } +} + +private class ErrorReporterImpl( + private val context: CoderToolboxContext, + private val visibilityState: StateFlow, + private val callerClass: Class<*> +) : ErrorReporter() { + private val errorBuffer = mutableListOf() + + override fun report(message: String, ex: Throwable) { + context.logger.error(ex, "[${callerClass.simpleName}] $message") + if (!visibilityState.value.applicationVisible) { + context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later") + errorBuffer.add(ex) + return + } + showError(ex) + } + + private fun showError(ex: Throwable) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message ?: ex.toString() + context.cs.launch { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered while setting up Coder"), + context.i18n.pnotr(textError ?: ""), + context.i18n.ptrl("Dismiss") + ) + } + } + + + override fun flush() { + if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { + errorBuffer.forEach { + showError(it) + } + errorBuffer.clear() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt index 56b2910..cde23b2 100644 --- a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt @@ -1,6 +1,5 @@ package com.coder.toolbox.views -import com.coder.toolbox.CoderToolboxContext import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.UiField import kotlinx.coroutines.flow.MutableStateFlow @@ -14,7 +13,7 @@ import kotlinx.coroutines.flow.StateFlow * For now we just use this to display the deployment URL since we do not * support creating environments from the plugin. */ -class NewEnvironmentPage(context: CoderToolboxContext, deploymentURL: LocalizableString) : - CoderPage(context, deploymentURL) { +class NewEnvironmentPage(deploymentURL: LocalizableString) : + CoderPage(MutableStateFlow(deploymentURL)) { override val fields: StateFlow> = MutableStateFlow(emptyList()) } diff --git a/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt b/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt deleted file mode 100644 index ffab7ae..0000000 --- a/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.coder.toolbox.views - -import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.views.state.AuthWizardState -import com.jetbrains.toolbox.api.localization.LocalizableString -import com.jetbrains.toolbox.api.ui.components.LabelField -import com.jetbrains.toolbox.api.ui.components.RowGroup -import com.jetbrains.toolbox.api.ui.components.TextField -import com.jetbrains.toolbox.api.ui.components.TextType -import com.jetbrains.toolbox.api.ui.components.ValidationErrorField -import kotlinx.coroutines.flow.update - -/** - * A page with a field for providing the Coder deployment URL. - * - * Populates with the provided URL, at which point the user can accept or - * enter their own. - */ -class SignInStep(private val context: CoderToolboxContext) : WizardStep { - private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General) - private val descriptionField = LabelField(context.i18n.pnotr("")) - private val errorField = ValidationErrorField(context.i18n.pnotr("")) - - override val panel: RowGroup = RowGroup( - RowGroup.RowField(urlField), - RowGroup.RowField(descriptionField), - RowGroup.RowField(errorField) - ) - - override val nextButtonTitle: LocalizableString? = context.i18n.ptrl("Sign In") - - override fun onVisible() { - errorField.textState.update { - context.i18n.pnotr("") - } - urlField.textState.update { - context.deploymentUrl?.first ?: "" - } - - descriptionField.textState.update { - context.i18n.pnotr(context.deploymentUrl?.second?.description("URL") ?: "") - } - } - - override fun onNext(): Boolean { - var url = urlField.textState.value - if (url.isBlank()) { - errorField.textState.update { context.i18n.ptrl("URL is required") } - return false - } - url = if (!url.startsWith("http://") && !url.startsWith("https://")) { - "https://$url" - } else { - url - } - - context.secrets.lastDeploymentURL = url - AuthWizardState.goToNextStep() - return true - } - - override fun onBack() { - // it's the first step. Can't go anywhere back from here - } -} diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index afd9aa5..b449f40 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt @@ -1,11 +1,9 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.util.toURL import com.coder.toolbox.util.withPath -import com.coder.toolbox.views.state.AuthWizardState -import com.jetbrains.toolbox.api.localization.LocalizableString -import com.jetbrains.toolbox.api.ui.components.LabelField +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.LinkField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField @@ -20,35 +18,35 @@ import kotlinx.coroutines.flow.update * Populate with the provided token, at which point the user can accept or * enter their own. */ -class TokenStep(private val context: CoderToolboxContext) : WizardStep { +class TokenStep( + private val context: CoderToolboxContext, +) : WizardStep { private val tokenField = TextField(context.i18n.ptrl("Token"), "", TextType.Password) - private val descriptionField = LabelField(context.i18n.pnotr("")) private val linkField = LinkField(context.i18n.ptrl("Get a token"), "") private val errorField = ValidationErrorField(context.i18n.pnotr("")) override val panel: RowGroup = RowGroup( RowGroup.RowField(tokenField), - RowGroup.RowField(descriptionField), RowGroup.RowField(linkField), RowGroup.RowField(errorField) ) - override val nextButtonTitle: LocalizableString? = context.i18n.ptrl("Connect") override fun onVisible() { errorField.textState.update { context.i18n.pnotr("") } - tokenField.textState.update { - context.getToken(context.deploymentUrl?.first)?.first ?: "" - } - descriptionField.textState.update { - context.i18n.pnotr( - context.getToken(context.deploymentUrl?.first)?.second?.description("token") - ?: "No existing token for ${context.deploymentUrl} found." - ) + if (CoderCliSetupContext.hasUrl()) { + tokenField.textState.update { + context.secrets.tokenFor(CoderCliSetupContext.url!!) ?: "" + } + } else { + errorField.textState.update { + context.i18n.pnotr("URL not configure in the previous step. Please go back and provide a proper URL.") + return + } } (linkField.urlState as MutableStateFlow).update { - context.deploymentUrl?.first?.toURL()?.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" + CoderCliSetupContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" } } @@ -59,12 +57,12 @@ class TokenStep(private val context: CoderToolboxContext) : WizardStep { return false } - context.secrets.lastToken = token - AuthWizardState.goToNextStep() + CoderCliSetupContext.token = token + CoderCliSetupWizardState.goToNextStep() return true } override fun onBack() { - AuthWizardState.goToPreviousStep() + CoderCliSetupWizardState.goToPreviousStep() } } diff --git a/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt b/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt index 6ba3d52..bb19281 100644 --- a/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt @@ -1,11 +1,9 @@ package com.coder.toolbox.views -import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.RowGroup interface WizardStep { val panel: RowGroup - val nextButtonTitle: LocalizableString? /** * Callback when step is visible diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt new file mode 100644 index 0000000..8d503b9 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt @@ -0,0 +1,45 @@ +package com.coder.toolbox.views.state + +import java.net.URL + +/** + * Singleton that holds Coder CLI setup context (URL and token) across multiple + * Toolbox window lifecycle events. + * + * This ensures that user input (URL and token) is not lost when the Toolbox + * window is temporarily closed or recreated. + */ +object CoderCliSetupContext { + /** + * The currently entered URL. + */ + var url: URL? = null + + /** + * The token associated with the URL. + */ + var token: String? = null + + /** + * Returns true if a URL is currently set. + */ + fun hasUrl(): Boolean = url != null + + /** + * Returns true if a token is currently set. + */ + fun hasToken(): Boolean = !token.isNullOrBlank() + + /** + * Returns true if URL or token is missing and auth is not yet possible. + */ + fun isNotReadyForAuth(): Boolean = !(hasUrl() && token != null) + + /** + * Resets both URL and token to null. + */ + fun reset() { + url = null + token = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt similarity index 51% rename from src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt rename to src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt index 42bf2c0..92a0845 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt @@ -1,7 +1,14 @@ package com.coder.toolbox.views.state -object AuthWizardState { +/** + * A singleton that maintains the state of the coder setup wizard across Toolbox window lifecycle events. + * + * This is used to persist the wizard's progress (i.e., current step) between visibility changes + * of the Toolbox window. Without this object, closing and reopening the window would reset the wizard + * to its initial state by creating a new instance. + */ +object CoderCliSetupWizardState { private var currentStep = WizardStep.URL_REQUEST fun currentStep(): WizardStep = currentStep @@ -18,11 +25,15 @@ object AuthWizardState { currentStep = WizardStep.entries.toTypedArray()[(currentStep.ordinal - 1) % WizardStep.entries.size] } - fun resetSteps() { + fun goToLastStep() { + currentStep = WizardStep.CONNECT + } + + fun goToFirstStep() { currentStep = WizardStep.URL_REQUEST } } enum class WizardStep { - URL_REQUEST, TOKEN_REQUEST, LOGIN; + URL_REQUEST, TOKEN_REQUEST, CONNECT; } \ No newline at end of file diff --git a/src/main/resources/META-INF/trusted-keys/pgp-public.key b/src/main/resources/META-INF/trusted-keys/pgp-public.key new file mode 100644 index 0000000..fb5c4c5 --- /dev/null +++ b/src/main/resources/META-INF/trusted-keys/pgp-public.key @@ -0,0 +1,99 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGPGrCwBEAC7SSKQIFoQdt3jYv/1okRdoleepLDG4NfcG52S45Ex3/fUA6Z/ +ewHQrx//SN+h1FLpb0zQMyamWrSh2O3dnkWridwlskb5/y8C/6OUdk4L/ZgHeyPO +Ncbyl1hqO8oViakiWt4IxwSYo83eJHxOUiCGZlqV6EpEsaur43BRHnK8EciNeIxF +Bjle3yXH1K3EgGGHpgnSoKe1nSVxtWIwX45d06v+VqnBoI6AyK0Zp+Nn8bL0EnXC +xGYU3XOkC6EmITlhMju1AhxnbkQiy8IUxXiaj3NoPc1khapOcyBybhESjRZHlgu4 +ToLZGaypjtfQJgMeFlpua7sJK0ziFMW4wOTX+6Ix/S6XA80dVbl3VEhSMpFCcgI+ +OmEd2JuBs6maG+92fCRIzGAClzV8/ifM//JU9D7Qlq6QJpcbNClODlPNDNe7RUEO +b7Bu7dJJS3VhHO9eEen6m6vRE4DNriHT4Zvq1UkHfpJUW7njzkIYRni3eNrsr4Da +U/eeGbVipok4lzZEOQtuaZlX9ytOdGrWEGMGSosTOG6u6KAKJoz7cQGZiz4pZpjR +3N2SIYv59lgpHrIV7UodGx9nzu0EKBhkoulaP1UzH8F16psSaJXRjeyl/YP8Rd2z +SYgZVLjTzkTUXkJT8fQO8zLBEuwA0IiXX5Dl7grfEeShANVrM9LVu8KkUwARAQAB +tC5Db2RlciBSZWxlYXNlIFNpZ25pbmcgS2V5IDxzZWN1cml0eUBjb2Rlci5jb20+ +iQJUBBMBCgA+FiEEKMY4lDj2Q3PIwvSKi87Yfbu4ZEsFAmPGrCwCGwMFCQWjmoAF +CwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQi87Yfbu4ZEvrQQ//a3ySdMVhnLP+ +KneonV2zuNilTMC2J/MNG7Q0hU+8I9bxCc6DDqcnBBCQkIUwJq3wmelt3nTC8RxI +fv+ggnbdF9pz7Fc91nIJsGlWpH+bu1tSIvKF/rzZA8v6xUblFFfaC7Gsc5P4xk/+ +h0XBDAy6K+7+AafgLFpRD08Y0Kf2aMcqdM6c2Zo4IPo6FNrOa66FNkypZdQ4IByW +4kMezZSTp4Phqd9yqGC4m44U8YgzmW9LHgrvS0JyIaRPcQFM31AJ50K3iYRxL1ll +ETqJvbDR8UORNQs3Qs3CEZL588BoDMX2TYObTCG6g9Om5vJT0kgUkjDxQHwbAj6E +z9j8BoWkDT2JNzwdfTbPueuRjO+A+TXA9XZtrzbEYEzh0sD9Bdr7ozSF3JAs4GZS +nqcVlyp7q44ZdePR9L8w0ksth56tBWHfE9hi5jbRDRY2OnkV7y7JtWnBDQx9bCIo +7L7aBT8eirI1ZOnUxHJrnqY5matfWjSDBFW+YmWUkjnzBsa9F4m8jq9MSD3Q/8hN +ksJFrmLQs0/8hnM39tS7kLnAaWeGvbmjnxdeMqZsICxNpbyQrq2AhF4GhWfc+NsZ +yznVagJZ9bIlGsycSXJbsA5GbXDnm172TlodMUbLF9FU8i0vV4Y7q6jKO/VsblKU +F0bhXIRqVLrd9g88IyVyyZozmwbJKIy5Ag0EY8asLAEQAMgI9bMurq6Zic4s5W0u +W6LBDHyZhe+w2a3oT/i2YgTsh8XmIjrNasYYWO67b50JKepA3fk3ZA44w8WJqq+z +HLpslEb2fY5I1HvENUMKjYAUIsswSC21DSBau4yYiRGF0MNqv/MWy5Rjc993vIU4 +4TM3mvVhPrYfIkr0jwSbxq8+cm3sBjr0gcBQO57C3w8QkcZ6jefuI7y+1ZeM7X3L +OngmBFJDEutd9LPO/6Is4j/iQfTb8WDR6OmMX3Y04RHrP4sm7jf+3ZZKjcFCZQjr +QA4XHcQyJjnMN34Fn1U7KWopivU+mqViAnVpA643dq9SiBqsl83/R03DrpwKpP7r +6qasUHSUULuS7A4n8+CDwK5KghvrS0hOwMiYoIwZIVPITSUFHPYxrCJK7gU2OHfk +IZHX5m9L5iNwLz958GwzwHuONs5bjMxILbKknRhEBOcbhcpk0jswiPNUrEdipRZY +GR9G9fzD6q4P5heV3kQRqyUUTxdDj8w7jbrwl8sm5zk+TMnPRsu2kg0uwIN1aILm +oVkDN5CiZtg00n2Fu3do5F3YkF0Cz7indx5yySr5iUuoCY0EnpqSwourJ/ZdZA9Y +ZCHjhgjwyPCbxpTGfLj1g25jzQBYn5Wdgr2aHCQcqnU8DKPCnYL9COHJJylgj0vN +NSxyDjNXYYwSrYMqs/91f5xVABEBAAGJAjwEGAEKACYWIQQoxjiUOPZDc8jC9IqL +zth9u7hkSwUCY8asLAIbDAUJBaOagAAKCRCLzth9u7hkSyMvD/0Qal5kwiKDjgBr +i/dtMka+WNBTMb6vKoM759o33YAl22On5WgLr9Uz0cjkJPtzMHxhUo8KQmiPRtsK +dOmG9NI9NttfSeQVbeL8V/DC672fWPKM4TB8X7Kkj56/KI7ueGRokDhXG2pJlhQr +HwzZsAKoCMMnjcquAhHJClK9heIpVLBGFVlmVzJETzxo6fbEU/c7L79+hOrR4BWx +Tg6Dk7mbAGe7BuQLNtw6gcWUVWtHS4iYQtE/4khU1QppC1Z/ZbZ+AJT2TAFXzIaw +0l9tcOh7+TXqsvCLsXN0wrUh1nOdxA81sNWEMY07bG1qgvHyVc7ZYM89/ApK2HP+ +bBDIpAsRCGu2MHtrnJIlNE1J14G1mnauR5qIqI3C0R5MPLXOcDtp+gnjFe+PLU+6 +rQxJObyOkyEpOvtVtJKfFnpI5bqyl8WEPN0rDaS2A27cGXi5nynSAqoM1xT15W21 +uyY2GXY26DIwVfc59wGeclwcM29nS7prRU3KtskjonJ0iQoQebYOHLxy896cK+pK +nnhZx5AQjYiZPsPktSNZjSuOvTZ3g+IDwbCSvmBHcQpitzUOPShTUTs0QjSttzk2 +I6WxP9ivoR9yJGsxwNgCgrYdyt5+hyXXW/aUVihnQwizQRbymjJ2/z+I8NRFIeYb +xbtNFaH3WjLnhm9CB/H+Lc8fUj6HaZkCDQRjxt6QARAAsjZuCMjZBaAC1LFMeRcv +9+Ck7T5UNXTL9xQr1jUFZR95I6loWiWvFJ3Uet7gIbgNYY5Dc1gDr1Oqx9KQBjsN +TUahXov5lmjF5mYeyWTDZ5TS8H3o50zQzfZRC1eEbqjiBMLAHv74KD13P62nvzv6 +Dejwc7Nwc6aOH3cdZm74kz4EmdobJYRVdd5X9EYH/hdM928SsipKhm44oj3RDGi/ +x+ptjW9gr0bnrgCbkyCMNKhnmHSM60I8f4/viRItb+hWRpZYfLxMGTBVunicSXcX +Zh6Fq/DD/yTjzN9N83/NdDvwCyKo5U/kPgD2Ixh5PyJ38cpz6774Awnb/tstCI1g +glnlNbu8Qz84STr3NRZMOgT5h5b5qASOeruG4aVo9euaYJHlnlgcoUmpbEMnwr0L +tREUXSHGXWor7EYPjUQLskIaPl9NCZ3MEw5LhsZTgEdFBnb54dxMSEl7/MYDYhD/ +uTIWOJmtsWHmuMmvfxnw5GDEhJnAp4dxUm9BZlJhfnVR07DtTKyEk37+kl6+i0ZQ +yU4HJ2GWItpLfK54E/CH+S91y7wpepb2TMkaFR2fCK0vXTGAXWK+Y+aTD8ZcLB5y +0IYPsvA0by5AFpmXNfWZiZtYvgJ5FAQZNuB5RILg3HsuDq2U4wzp5BoohWtsOzsn +antIUf/bN0D2g+pCySkc5ssAEQEAAbQuQ29kZXIgUmVsZWFzZSBTaWduaW5nIEtl +eSA8c2VjdXJpdHlAY29kZXIuY29tPokCVAQTAQoAPhYhBCHJaxy5UHGIdPZNvWpa +ZxteQKO5BQJjxt6QAhsDBQkFo5qABQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJ +EGpaZxteQKO5oysP/1rSdvbKMzozvnVZoglnPjnSGStY9Pr2ziGL7eIMk2yt+Orr +j/AwxYIDgsZPQoJEr87eX2dCYtUMM1x+CpZsWu8dDVFLxyZp8nPmhUzcUCFfutw1 +UmAVKQkOra9segZtw4HVcSctpdgLw7NHq7vIQm4knIvjWmdC15r1B6/VJJI8CeaR +Zy+ToPr9fKnYs1RNdz+DRDN2521skX1DaInhB/ALeid90rJTRujaP9XeyNb9k32K +qd3h4C0KUGIf0fNKj4mmDlNosX3V/pJZATpFiF8aVPlybHQ2W5xpn1U8FJxE4hgR +rvsZmO685Qwm6p/uRI5Eymfm8JC5OQNt9Kvs/BMhotsW0u+je8UXwnznptMILpVP ++qxNuHUe1MYLdjK21LFF+Pk5O4W1TT6mKcbisOmZuQMG5DxpzUwm1Rs5AX1omuJt +iOrmQEvmrKKWC9qbcmWW1t2scnIJsNtrsvME0UjJFz+RL6UUX3xXlLK6YOUghCr8 +gZ7ZPgFqygS6tMu8TAGURzSCfijDh+eZGwqrlvngBIaO5WiNdSXC/J9aE1KThXmX +90A3Gwry+yI2kRS7o8vmghXewPTZbnG0CVHiQIH2yqFNXnhKvhaJt0g04TcnxBte +kiFqRT4K1Bb7pUIlUANmrKo9/zRCxIOopEgRH5cVQ8ZglkT0t5d3ePmAo6h0uQIN +BGPG3pABEADghhNByVoC+qCMo+SErjxz9QYA+tKoAngbgPyxxyB4RD52Z58MwVaP ++Yk0qxJYUBat3dJwiCTlUGG+yTyMOwLl7qSDr53AD5ml0hwJqnLBJ6OUyGE4ax4D +RUVBprKlDltwr98cZDgzvwEhIO2T3tNZ4vySveITj9pLonOrLkAfGXqFOqom+S37 +6eZvjKTnEUbT+S0TTynwds70W31sxVUrL62qsUnmoKEnsKXk/7X8CLXWvtNqu9kf +eiXs5Jz4N6RZUqvS0WOaaWG9v1PHukTtb8RyeookhsBqf9fWOlw5foel+NQwGQjz +0D0dDTKxn2Taweq+gWNCRH7/FJNdWa9upZ2fUAjg9hN9Ow8Y5nE3J0YKCBAQTgNa +XNtsiGQjdEKYZslxZKFM34By3LD6IrkcAEPKu9plZthmqhQumqwYRAgB9O56jg3N +GDDRyAMS7y63nNphTSatpOZtPVVMtcBw5jPjMIPFfU2dlfsvmnCvru2dvfAij+Ng +EkwOLNS8rFQHMJSQysmHuAPSYT97Yl022mPrAtb9+hwtCXt3VI6dvIARl2qPyF0D +DMw2fW5E7ivhUr2WEFiBmXunrJvMIYldBzDkkBjamelPjoevR0wfoIn0x1CbSsQi +zbEs3PXHs7nGxb9TZnHY4+J94mYHdSXrImAuH/x97OnlfUpOKPv5lwARAQABiQI8 +BBgBCgAmFiEEIclrHLlQcYh09k29alpnG15Ao7kFAmPG3pACGwwFCQWjmoAACgkQ +alpnG15Ao7m2/g//Y/YRM+Qhf71G0MJpAfym6ZqmwsT78qQ8T9w95ZeIRD7UUE8d +tm39kqJTGP6DuHCNYEMs2M88o0SoQsS/7j/8is7H/13F5o40DWjuQphia2BWkB1B +G4QRRIXMlrPX8PS92GDCtGfvxn90Li2FhQGZWlNFwvKUB7+/yLMsZzOwo7BS6PwC +hvI3eC7DBC8sXjJUxsrgFAkxQxSx/njP8f4HdUwhNnB1YA2/5IY5bk8QrXxzrAK1 +sbIAjpJdtPYOrZByyyj4ZpRcSm3ngV2n8yd1muJ5u+oRIQoGCdEIaweCj598jNFa +k378ZA11hCyNFHjpPIKnF3tfsQ8vjDatoq4Asy+HXFuo1GA/lvNgNb3Nv4FUozuv +JYJ0KaW73FZXlFBIBkMkRQE8TspHy2v/IGyNXBwKncmkszaiiozBd+T+1NUZgtk5 +9o5uKQwLHVnHIU7r/w/oN5LvLawLg2dP/f2u/KoQXMxjwLZncSH4+5tRz4oa/GMn +k4F84AxTIjGfLJeXigyP6xIPQbvJy+8iLRaCpj+v/EPwAedbRV+u0JFeqqikca70 +aGN86JBOmwpU87sfFxLI7HdI02DkvlxYYK3vYlA6zEyWaeLZ3VNr6tHcQmOnFe8Q +26gcS0AQcxQZrcWTCZ8DJYF+RnXjSVRmHV/3YDts4JyMKcD6QX8s/3aaldk= +=dLmT +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/src/main/resources/icon.svg b/src/main/resources/icon.svg index 15696c6..4d780a6 100644 --- a/src/main/resources/icon.svg +++ b/src/main/resources/icon.svg @@ -1,15 +1,3 @@ - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/src/main/resources/icons/create.svg b/src/main/resources/icons/create.svg deleted file mode 100644 index c6da8ba..0000000 --- a/src/main/resources/icons/create.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/main/resources/icons/create_dark.svg b/src/main/resources/icons/create_dark.svg deleted file mode 100644 index 511a8ef..0000000 --- a/src/main/resources/icons/create_dark.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/main/resources/icons/delete.svg b/src/main/resources/icons/delete.svg deleted file mode 100644 index a6a94e9..0000000 --- a/src/main/resources/icons/delete.svg +++ /dev/null @@ -1,7 +0,0 @@ - - DeleteTest - - - - - diff --git a/src/main/resources/icons/delete_dark.svg b/src/main/resources/icons/delete_dark.svg deleted file mode 100644 index 901c57e..0000000 --- a/src/main/resources/icons/delete_dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - DeleteTest_dark - - - - - diff --git a/src/main/resources/icons/homeFolder.svg b/src/main/resources/icons/homeFolder.svg deleted file mode 100644 index 2d482b2..0000000 --- a/src/main/resources/icons/homeFolder.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/main/resources/icons/homeFolder_dark.svg b/src/main/resources/icons/homeFolder_dark.svg deleted file mode 100644 index b7ba16b..0000000 --- a/src/main/resources/icons/homeFolder_dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/main/resources/icons/open_terminal.svg b/src/main/resources/icons/open_terminal.svg deleted file mode 100644 index 12d2164..0000000 --- a/src/main/resources/icons/open_terminal.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/main/resources/icons/open_terminal_dark.svg b/src/main/resources/icons/open_terminal_dark.svg deleted file mode 100644 index 3994064..0000000 --- a/src/main/resources/icons/open_terminal_dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/main/resources/icons/run.svg b/src/main/resources/icons/run.svg deleted file mode 100644 index d0f970e..0000000 --- a/src/main/resources/icons/run.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/icons/run_dark.svg b/src/main/resources/icons/run_dark.svg deleted file mode 100644 index 25c1892..0000000 --- a/src/main/resources/icons/run_dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/icons/stop.svg b/src/main/resources/icons/stop.svg deleted file mode 100644 index 8347961..0000000 --- a/src/main/resources/icons/stop.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/icons/stop_dark.svg b/src/main/resources/icons/stop_dark.svg deleted file mode 100644 index 6392389..0000000 --- a/src/main/resources/icons/stop_dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/main/resources/icons/unknown.svg b/src/main/resources/icons/unknown.svg deleted file mode 100644 index 1f8cd75..0000000 --- a/src/main/resources/icons/unknown.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/main/resources/icons/update.svg b/src/main/resources/icons/update.svg deleted file mode 100644 index 50ad46f..0000000 --- a/src/main/resources/icons/update.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/main/resources/icons/update_dark.svg b/src/main/resources/icons/update_dark.svg deleted file mode 100644 index ebc8059..0000000 --- a/src/main/resources/icons/update_dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index b38b2a6..8aabe3f 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -79,6 +79,9 @@ msgstr "" msgid "Enable downloads" msgstr "" +msgid "Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment" +msgstr "" + msgid "Enable binary directory fallback" msgstr "" @@ -106,7 +109,7 @@ msgstr "" msgid "Configuring CLI..." msgstr "" -msgid "Sign In" +msgid "Next" msgstr "" msgid "Token" @@ -128,4 +131,52 @@ msgid "Extra SSH options" msgstr "" msgid "SSH proxy log directory" +msgstr "" + +msgid "SSH network metrics directory" +msgstr "" + +msgid "Network Status" +msgstr "" + +msgid "Create workspace" +msgstr "" + +msgid "Error encountered while handling Coder URI" +msgstr "" + +msgid "Error encountered while setting up Coder" +msgstr "" + +msgid "Setting up Coder" +msgstr "" + +msgid "Loading workspaces..." +msgstr "" + +msgid "Security Warning" +msgstr "" + +msgid "Accept" +msgstr "" + +msgid "Abort" +msgstr "" + +msgid "Run anyway" +msgstr "" + +msgid "Disable Coder CLI signature verification" +msgstr "" + +msgid "None" +msgstr "" + +msgid "Basic" +msgstr "" + +msgid "Headers" +msgstr "" + +msgid "Body" msgstr "" \ No newline at end of file diff --git a/src/main/resources/logo/coder_logo.svg b/src/main/resources/logo/coder_logo.svg deleted file mode 100644 index c500929..0000000 --- a/src/main/resources/logo/coder_logo.svg +++ /dev/null @@ -1,80 +0,0 @@ - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/logo/coder_logo_16.svg b/src/main/resources/logo/coder_logo_16.svg deleted file mode 100644 index f4ab0e1..0000000 --- a/src/main/resources/logo/coder_logo_16.svg +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/logo/coder_logo_16_dark.svg b/src/main/resources/logo/coder_logo_16_dark.svg deleted file mode 100644 index 77715c2..0000000 --- a/src/main/resources/logo/coder_logo_16_dark.svg +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/logo/coder_logo_dark.svg b/src/main/resources/logo/coder_logo_dark.svg deleted file mode 100644 index e8c05d1..0000000 --- a/src/main/resources/logo/coder_logo_dark.svg +++ /dev/null @@ -1,80 +0,0 @@ - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/pluginIcon.svg b/src/main/resources/pluginIcon.svg new file mode 100644 index 0000000..853f895 --- /dev/null +++ b/src/main/resources/pluginIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index ca9040e..7f5c831 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -4,6 +4,8 @@ import com.coder.toolbox.CoderToolboxContext 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.sdk.DataGen.Companion.workspace +import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.BINARY_DIRECTORY import com.coder.toolbox.store.BINARY_NAME @@ -15,7 +17,9 @@ import com.coder.toolbox.store.DATA_DIRECTORY import com.coder.toolbox.store.DISABLE_AUTOSTART import com.coder.toolbox.store.ENABLE_BINARY_DIR_FALLBACK import com.coder.toolbox.store.ENABLE_DOWNLOADS +import com.coder.toolbox.store.FALLBACK_ON_CODER_FOR_SIGNATURES import com.coder.toolbox.store.HEADER_COMMAND +import com.coder.toolbox.store.NETWORK_INFO_DIR import com.coder.toolbox.store.SSH_CONFIG_OPTIONS import com.coder.toolbox.store.SSH_CONFIG_PATH import com.coder.toolbox.store.SSH_LOG_DIR @@ -28,24 +32,35 @@ import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sha1 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.ProxyAuth +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 com.squareup.moshi.JsonEncodingException import com.sun.net.httpserver.HttpServer +import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.assertDoesNotThrow import org.zeroturnaround.exec.InvalidExitValueException import org.zeroturnaround.exec.ProcessInitException import java.net.HttpURLConnection import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.URI import java.net.URL import java.nio.file.AccessDeniedException import java.nio.file.Path +import java.util.UUID +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -54,22 +69,43 @@ import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue +private const val VERSION_FOR_PROGRESS_REPORTING = "v2.13.1-devel+de07351b8" +private val noOpTextProgress: (String) -> Unit = { _ -> } + internal class CoderCLIManagerTest { + private val ui = mockk(relaxed = true) private val context = CoderToolboxContext( - mockk(), + ui, mockk(), mockk(), + mockk(), mockk(), + mockk(), mockk(), mockk(relaxed = true), - mockk(), + mockk(relaxed = true), CoderSettingsStore( pluginTestSettingsStore(), Environment(), mockk(relaxed = true) ), - mockk() - ) + mockk(), + object : ToolboxProxySettings { + override fun getProxy(): Proxy? = null + override fun getProxySelector(): ProxySelector? = null + override fun getProxyAuth(): ProxyAuth? = null + + override fun addProxyChangeListener(listener: Runnable) { + } + + override fun removeProxyChangeListener(listener: Runnable) { + } + }) + + @BeforeTest + fun setup() { + coEvery { ui.showYesNoPopup(any(), any(), any(), any()) } returns true + } /** * Return the contents of a script that contains the string. @@ -98,6 +134,9 @@ internal class CoderCLIManagerTest { if (exchange.requestURI.path == "/bin/override") { code = HttpURLConnection.HTTP_OK response = mkbinVersion("0.0.0") + } else if (exchange.requestURI.path.contains(".asc")) { + code = HttpURLConnection.HTTP_NOT_FOUND + response = "not found" } else if (!exchange.requestURI.path.startsWith("/bin/coder-")) { code = HttpURLConnection.HTTP_NOT_FOUND response = "not found" @@ -110,6 +149,7 @@ internal class CoderCLIManagerTest { } val body = response.toByteArray() + exchange.responseHeaders["Content-Type"] = "application/octet-stream" exchange.sendResponseHeaders(code, if (code == HttpURLConnection.HTTP_OK) body.size.toLong() else -1) exchange.responseBody.write(body) exchange.close() @@ -122,19 +162,14 @@ internal class CoderCLIManagerTest { fun testServerInternalError() { val (srv, url) = mockServer(HttpURLConnection.HTTP_INTERNAL_ERROR) val ccm = CoderCLIManager( - url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore(), - Environment(), - mockk(relaxed = true) - ).readOnly() + context, + url ) val ex = assertFailsWith( exceptionClass = ResponseException::class, - block = { ccm.download() }, + block = { runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) } } ) assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR, ex.code) @@ -150,16 +185,16 @@ internal class CoderCLIManagerTest { ), Environment(), context.logger - ).readOnly() + ) val url = URL("http://localhost") - val ccm1 = CoderCLIManager(url, context.logger, settings) + val ccm1 = CoderCLIManager(context.copy(settingsStore = settings), url) assertEquals(settings.binSource(url), ccm1.remoteBinaryURL) assertEquals(settings.dataDir(url), ccm1.coderConfigPath.parent) assertEquals(settings.binPath(url), ccm1.localBinaryPath) // Can force using data directory. - val ccm2 = CoderCLIManager(url, context.logger, settings, true) + val ccm2 = CoderCLIManager(context.copy(settingsStore = settings), url, true) assertEquals(settings.binSource(url), ccm2.remoteBinaryURL) assertEquals(settings.dataDir(url), ccm2.coderConfigPath.parent) assertEquals(settings.binPath(url, true), ccm2.localBinaryPath) @@ -173,15 +208,16 @@ internal class CoderCLIManagerTest { val (srv, url) = mockServer() val ccm = CoderCLIManager( - url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("cli-dir-fail-to-write").toString(), - ), - Environment(), - context.logger - ).readOnly(), + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + DATA_DIRECTORY to tmpdir.resolve("cli-dir-fail-to-write").toString(), + ), + Environment(), + context.logger + ) + ), + url ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -189,7 +225,7 @@ internal class CoderCLIManagerTest { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ccm.download() }, + block = { runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) } }, ) srv.stop(0) @@ -207,22 +243,24 @@ internal class CoderCLIManagerTest { } val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + DATA_DIRECTORY to tmpdir.resolve("real-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + ), + Environment(), + context.logger + ) + ), url.toURL(), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("real-cli").toString(), - ), - Environment(), - context.logger - ).readOnly(), ) - assertTrue(ccm.download()) + assertTrue(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertDoesNotThrow { ccm.version() } // It should skip the second attempt. - assertFalse(ccm.download()) + assertFalse(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) // Make sure login failures propagate. assertFailsWith( @@ -235,39 +273,43 @@ internal class CoderCLIManagerTest { fun testDownloadMockCLI() { val (srv, url) = mockServer() var ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + ), + Environment(), + context.logger, + ) + ), url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) - assertEquals(true, ccm.download()) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) // It should skip the second attempt. - assertEquals(false, ccm.download()) + assertEquals(false, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) // Should use the source override. ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_SOURCE to "/bin/override", + DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + ), + Environment(), + context.logger + ) + ), url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_SOURCE to "/bin/override", - DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), - ), - Environment(), - context.logger - ).readOnly(), ) - assertEquals(true, ccm.download()) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertContains(ccm.localBinaryPath.toFile().readText(), "0.0.0") srv.stop(0) @@ -276,15 +318,16 @@ internal class CoderCLIManagerTest { @Test fun testRunNonExistentBinary() { val ccm = CoderCLIManager( - URL("https://foo"), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("does-not-exist").toString(), - ), - Environment(), - context.logger - ).readOnly(), + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + DATA_DIRECTORY to tmpdir.resolve("does-not-exist").toString(), + ), + Environment(), + context.logger + ) + ), + URL("https://foo") ) assertFailsWith( @@ -297,15 +340,17 @@ internal class CoderCLIManagerTest { fun testOverwritesWrongVersion() { val (srv, url) = mockServer() val ccm = CoderCLIManager( - url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("overwrite-cli").toString(), - ), - Environment(), - context.logger - ).readOnly(), + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + DATA_DIRECTORY to tmpdir.resolve("overwrite-cli").toString(), + ), + Environment(), + context.logger + ) + ), + url ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -315,7 +360,7 @@ internal class CoderCLIManagerTest { assertEquals("cli", ccm.localBinaryPath.toFile().readText()) assertEquals(0, ccm.localBinaryPath.toFile().lastModified()) - assertTrue(ccm.download()) + assertTrue(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertNotEquals("cli", ccm.localBinaryPath.toFile().readText()) assertNotEquals(0, ccm.localBinaryPath.toFile().lastModified()) @@ -332,23 +377,24 @@ internal class CoderCLIManagerTest { val settings = CoderSettingsStore( pluginTestSettingsStore( DATA_DIRECTORY to tmpdir.resolve("clobber-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow" ), Environment(), context.logger - ).readOnly() + ) - val ccm1 = CoderCLIManager(url1, context.logger, settings) - val ccm2 = CoderCLIManager(url2, context.logger, settings) + val ccm1 = CoderCLIManager(context.copy(settingsStore = settings), url1) + val ccm2 = CoderCLIManager(context.copy(settingsStore = settings), url2) - assertTrue(ccm1.download()) - assertTrue(ccm2.download()) + assertTrue(runBlocking { ccm1.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) + assertTrue(runBlocking { ccm2.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) srv1.stop(0) srv2.stop(0) } data class SSHTest( - val workspaces: List, + val workspaces: List, val input: String?, val output: String, val remove: String, @@ -362,10 +408,19 @@ internal class CoderCLIManagerTest { val extraConfig: String = "", val env: Environment = Environment(), val sshLogDirectory: Path? = null, + val url: URL? = null, ) @Test fun testConfigureSSH() { + val workspace = workspace("foo", agents = mapOf("agent1" to UUID.randomUUID().toString())) + val workspace2 = workspace("bar", agents = mapOf("agent1" to UUID.randomUUID().toString())) + val betterWorkspace = workspace("foo", agents = mapOf("agent1" to UUID.randomUUID().toString())) + val workspaceWithMultipleAgents = workspace( + "foo", + agents = mapOf("agent1" to UUID.randomUUID().toString(), "agent2" to UUID.randomUUID().toString()) + ) + val extraConfig = listOf( "ServerAliveInterval 5", @@ -373,27 +428,27 @@ internal class CoderCLIManagerTest { ).joinToString(System.lineSeparator()) val tests = listOf( - SSHTest(listOf("foo", "bar"), null, "multiple-workspaces", "blank"), - SSHTest(listOf("foo", "bar"), null, "multiple-workspaces", "blank"), - SSHTest(listOf("foo-bar"), "blank", "append-blank", "blank"), - SSHTest(listOf("foo-bar"), "blank-newlines", "append-blank-newlines", "blank"), - SSHTest(listOf("foo-bar"), "existing-end", "replace-end", "no-blocks"), - SSHTest(listOf("foo-bar"), "existing-end-no-newline", "replace-end-no-newline", "no-blocks"), - SSHTest(listOf("foo-bar"), "existing-middle", "replace-middle", "no-blocks"), + SSHTest(listOf(workspace, workspace2), null, "multiple-workspaces", "blank"), + SSHTest(listOf(workspace, workspace2), null, "multiple-workspaces", "blank"), + SSHTest(listOf(workspace), "blank", "append-blank", "blank"), + SSHTest(listOf(workspace), "blank-newlines", "append-blank-newlines", "blank"), + SSHTest(listOf(workspace), "existing-end", "replace-end", "no-blocks"), + SSHTest(listOf(workspace), "existing-end-no-newline", "replace-end-no-newline", "no-blocks"), + SSHTest(listOf(workspace), "existing-middle", "replace-middle", "no-blocks"), SSHTest( - listOf("foo-bar"), + listOf(workspace), "existing-middle-and-unrelated", "replace-middle-ignore-unrelated", "no-related-blocks" ), - SSHTest(listOf("foo-bar"), "existing-only", "replace-only", "blank"), - SSHTest(listOf("foo-bar"), "existing-start", "replace-start", "no-blocks"), - SSHTest(listOf("foo-bar"), "no-blocks", "append-no-blocks", "no-blocks"), - SSHTest(listOf("foo-bar"), "no-related-blocks", "append-no-related-blocks", "no-related-blocks"), - SSHTest(listOf("foo-bar"), "no-newline", "append-no-newline", "no-blocks"), + SSHTest(listOf(workspace), "existing-only", "replace-only", "blank"), + SSHTest(listOf(workspace), "existing-start", "replace-start", "no-blocks"), + SSHTest(listOf(workspace), "no-blocks", "append-no-blocks", "no-blocks"), + SSHTest(listOf(workspace), "no-related-blocks", "append-no-related-blocks", "no-related-blocks"), + SSHTest(listOf(workspace), "no-newline", "append-no-newline", "no-blocks"), if (getOS() == OS.WINDOWS) { SSHTest( - listOf("header"), + listOf(workspace), null, "header-command-windows", "blank", @@ -401,7 +456,7 @@ internal class CoderCLIManagerTest { ) } else { SSHTest( - listOf("header"), + listOf(workspace), null, "header-command", "blank", @@ -409,7 +464,7 @@ internal class CoderCLIManagerTest { ) }, SSHTest( - listOf("foo"), + listOf(workspace), null, "disable-autostart", "blank", @@ -420,9 +475,9 @@ internal class CoderCLIManagerTest { reportWorkspaceUsage = true, ), ), - SSHTest(listOf("foo"), null, "no-disable-autostart", "blank", ""), + SSHTest(listOf(workspace), null, "no-disable-autostart", "blank", ""), SSHTest( - listOf("foo"), + listOf(workspace), null, "no-report-usage", "blank", @@ -434,26 +489,54 @@ internal class CoderCLIManagerTest { ), ), SSHTest( - listOf("extra"), + listOf(workspace), null, "extra-config", "blank", extraConfig = extraConfig, ), SSHTest( - listOf("extra"), + listOf(workspace), null, "extra-config", "blank", env = Environment(mapOf(CODER_SSH_CONFIG_OPTIONS to extraConfig)), ), SSHTest( - listOf("foo"), + listOf(workspace), null, "log-dir", "blank", sshLogDirectory = tmpdir.resolve("ssh-logs"), ), + SSHTest( + listOf(workspace), + input = null, + output = "url", + remove = "blank", + url = URI.create("https://test.coder.invalid?foo=bar&baz=qux").toURL(), + ), + SSHTest( + listOf(workspace, betterWorkspace), + input = null, + output = "multiple-users", + remove = "blank", + ), + SSHTest( + listOf(workspaceWithMultipleAgents), + input = null, + output = "multiple-agents", + remove = "blank", + ), + SSHTest( + listOf(workspace), + input = null, + output = "wildcard", + remove = "blank", + features = Features( + wildcardSsh = true, + ), + ), ) val newlineRe = "\r?\n".toRegex() @@ -467,13 +550,19 @@ internal class CoderCLIManagerTest { HEADER_COMMAND to it.headerCommand, SSH_CONFIG_PATH to tmpdir.resolve(it.input + "_to_" + it.output + ".conf").toString(), SSH_CONFIG_OPTIONS to it.extraConfig, - SSH_LOG_DIR to (it.sshLogDirectory?.toString() ?: "") + SSH_LOG_DIR to (it.sshLogDirectory?.toString() ?: ""), + NETWORK_INFO_DIR to tmpdir.parent.resolve("coder-toolbox") + .resolve("ssh-network-metrics") + .normalize().toString() ), env = it.env, context.logger, - ).readOnly() + ) - val ccm = CoderCLIManager(URL("https://test.coder.invalid"), context.logger, settings) + val ccm = CoderCLIManager( + context.copy(settingsStore = settings), + it.url ?: URI.create("https://test.coder.invalid").toURL() + ) val sshConfigPath = Path.of(settings.sshConfigPath) // Input is the configuration that we start with, if any. @@ -487,6 +576,7 @@ internal class CoderCLIManagerTest { // Output is the configuration we expect to have after configuring. val coderConfigPath = ccm.localBinaryPath.parent.resolve("config") + val networkMetricsPath = tmpdir.parent.resolve("coder-toolbox").resolve("ssh-network-metrics") val expectedConf = Path.of("src/test/resources/fixtures/outputs/").resolve(it.output + ".conf").toFile().readText() .replace(newlineRe, System.lineSeparator()) @@ -495,6 +585,10 @@ internal class CoderCLIManagerTest { "/tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64", escape(ccm.localBinaryPath.toString()) ) + .replace( + "/tmp/coder-toolbox/ssh-network-metrics", + escape(networkMetricsPath.toString()) + ) .let { conf -> if (it.sshLogDirectory != null) { conf.replace("/tmp/coder-toolbox/test.coder.invalid/logs", it.sshLogDirectory.toString()) @@ -504,7 +598,14 @@ internal class CoderCLIManagerTest { } // Add workspaces. - ccm.configSsh(it.workspaces.toSet(), it.features) + ccm.configSsh( + it.workspaces.flatMap { ws -> + ws.latestBuild.resources.filter { r -> r.agents != null }.flatMap { r -> r.agents!! }.map { a -> + ws to a + } + }.toSet(), + it.features, + ) assertEquals(expectedConf, sshConfigPath.toFile().readText()) @@ -542,7 +643,7 @@ internal class CoderCLIManagerTest { ), Environment(), context.logger - ).readOnly() + ) val sshConfigPath = Path.of(settings.sshConfigPath) sshConfigPath.parent.toFile().mkdirs() Path.of("src/test/resources/fixtures/inputs").resolve("$it.conf").toFile().copyTo( @@ -550,7 +651,7 @@ internal class CoderCLIManagerTest { true, ) - val ccm = CoderCLIManager(URL("https://test.coder.invalid"), context.logger, settings) + val ccm = CoderCLIManager(context.copy(settingsStore = settings), URL("https://test.coder.invalid")) assertFailsWith( exceptionClass = SSHConfigFormatException::class, @@ -566,22 +667,32 @@ internal class CoderCLIManagerTest { "new\nline", ) + + val workspace = workspace( + "foo", + agents = mapOf("agentid1" to UUID.randomUUID().toString(), "agentid2" to UUID.randomUUID().toString()) + ) + val withAgents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! }.map { + workspace to it + } + tests.forEach { val ccm = CoderCLIManager( - URL("https://test.coder.invalid"), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - HEADER_COMMAND to it, - ), - Environment(), - context.logger - ).readOnly(), + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + HEADER_COMMAND to it, + ), + Environment(), + context.logger + ) + ), + URI.create("https://test.coder.invalid").toURL(), ) assertFailsWith( exceptionClass = Exception::class, - block = { ccm.configSsh(setOf("foo", "bar")) }, + block = { ccm.configSsh(withAgents.toSet()) }, ) } } @@ -619,16 +730,17 @@ internal class CoderCLIManagerTest { ) val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + BINARY_DIRECTORY to tmpdir.resolve("bad-version").toString(), + ), + Environment(), + context.logger, + ) + ), URL("https://test.coder.parse-fail.invalid"), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - BINARY_DIRECTORY to tmpdir.resolve("bad-version").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -672,16 +784,17 @@ internal class CoderCLIManagerTest { ) val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + BINARY_DIRECTORY to tmpdir.resolve("matches-version").toString(), + ), + Environment(), + context.logger, + ) + ), URL("https://test.coder.matches-version.invalid"), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - BINARY_DIRECTORY to tmpdir.resolve("matches-version").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -776,6 +889,7 @@ internal class CoderCLIManagerTest { ENABLE_BINARY_DIR_FALLBACK to it.enableFallback.toString(), DATA_DIRECTORY to tmpdir.resolve("ensure-data-dir").toString(), BINARY_DIRECTORY to tmpdir.resolve("ensure-bin-dir").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow" ), Environment(), context.logger @@ -810,12 +924,12 @@ internal class CoderCLIManagerTest { Result.ERROR -> { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ensureCLI(localContext, url, it.buildVersion) }, + block = { runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } } ) } Result.NONE -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertFailsWith( exceptionClass = ProcessInitException::class, @@ -824,25 +938,25 @@ internal class CoderCLIManagerTest { } Result.DL_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.DL_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.USE_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer.parse(it.version ?: ""), ccm.version()) } Result.USE_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer.parse(it.fallbackVersion ?: ""), ccm.version()) } @@ -871,18 +985,20 @@ internal class CoderCLIManagerTest { tests.forEach { val (srv, url) = mockServer(version = it.first) val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + DATA_DIRECTORY to tmpdir.resolve("features").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow" + ), + Environment(), + context.logger, + ) + ), url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - DATA_DIRECTORY to tmpdir.resolve("features").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) - assertEquals(true, ccm.download()) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertEquals(it.second, ccm.features, "version: ${it.first}") srv.stop(0) diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 66b2465..49314c5 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -21,8 +21,12 @@ import com.coder.toolbox.store.TLS_CA_PATH import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sslContextFromPEMs 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.ProxyAuth +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 @@ -51,6 +55,7 @@ import java.nio.file.Path import java.util.UUID import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -99,13 +104,26 @@ class CoderRestClientTest { mockk(), mockk(), mockk(), + mockk(), mockk(), + mockk(), mockk(), mockk(relaxed = true), mockk(), CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), - mockk() - ) + mockk(), + object : ToolboxProxySettings { + override fun getProxy(): Proxy? = null + override fun getProxySelector(): ProxySelector? = null + override fun getProxyAuth(): ProxyAuth? = null + + override fun addProxyChangeListener(listener: Runnable) { + } + + override fun removeProxyChangeListener(listener: Runnable) { + } + }) + data class TestWorkspace(var workspace: Workspace, var resources: List? = emptyList()) @@ -207,7 +225,7 @@ class CoderRestClientTest { val client = CoderRestClient(context, URL(url), "token") assertEquals(user.username, runBlocking { client.me() }.username) - val tests = listOf("invalid", null) + val tests = listOf("invalid") tests.forEach { token -> val ex = assertFailsWith( @@ -220,6 +238,26 @@ class CoderRestClientTest { srv.stop(0) } + @Test + fun `exception is raised when token is required for authentication and token value is null or empty`() { + listOf("", null).forEach { token -> + val ex = + assertFailsWith( + exceptionClass = IllegalStateException::class, + block = { + runBlocking { + CoderRestClient( + context, + URI.create("https://coder.com").toURL(), + token + ).me() + } + }, + ) + assertEquals(ex.message, "Token is required for https://coder.com deployment") + } + } + @Test fun testGetsWorkspaces() { val tests = @@ -529,6 +567,7 @@ class CoderRestClientTest { } @Test + @Ignore("Until proxy authentication is supported") fun usesProxy() { val settings = CoderSettingsStore(pluginTestSettingsStore(), Environment(), context.logger) val workspaces = listOf(DataGen.workspace("ws1")) @@ -545,26 +584,34 @@ class CoderRestClientTest { val srv2 = mockProxy() val client = CoderRestClient( - context.copy(settingsStore = settings), + context.copy(settingsStore = settings, proxySettings = object : ToolboxProxySettings { + override fun getProxy(): Proxy? = null + + override fun getProxySelector(): ProxySelector? { + return object : ProxySelector() { + override fun select(uri: URI): List = + listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port))) + + override fun connectFailed( + uri: URI, + sa: SocketAddress, + ioe: IOException, + ) { + getDefault().connectFailed(uri, sa, ioe) + } + } + } + + override fun getProxyAuth(): ProxyAuth? = null + override fun addProxyChangeListener(listener: Runnable) { + } + + override fun removeProxyChangeListener(listener: Runnable) { + } + + }), URL(url1), "token", - ProxyValues( - "foo", - "bar", - true, - object : ProxySelector() { - override fun select(uri: URI): List = - listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", srv2.address.port))) - - override fun connectFailed( - uri: URI, - sa: SocketAddress, - ioe: IOException, - ) { - getDefault().connectFailed(uri, sa, ioe) - } - }, - ), ) assertEquals(workspaces.map { ws -> ws.name }, runBlocking { client.workspaces() }.map { ws -> ws.name }) diff --git a/src/test/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetricsTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetricsTest.kt new file mode 100644 index 0000000..08b98df --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetricsTest.kt @@ -0,0 +1,107 @@ +package com.coder.toolbox.sdk.v2.models + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NetworkMetricsTest { + + @Test + fun `toPretty should return message for Coder Connect`() { + val metrics = NetworkMetrics( + p2p = null, + latency = null, + preferredDerp = null, + derpLatency = null, + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = true + ) + + val expected = "You're connected using Coder Connect" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should return message for P2P connection`() { + val metrics = NetworkMetrics( + p2p = true, + latency = 35.526, + preferredDerp = null, + derpLatency = null, + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + val expected = "Direct (35.53ms). You're connected peer-to-peer" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should round latency with more than two decimals correctly for P2P`() { + val metrics = NetworkMetrics( + p2p = true, + latency = 42.6789, + preferredDerp = null, + derpLatency = null, + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + val expected = "Direct (42.68ms). You're connected peer-to-peer" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should pad latency with one decimal correctly for P2P`() { + val metrics = NetworkMetrics( + p2p = true, + latency = 12.5, + preferredDerp = null, + derpLatency = null, + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + val expected = "Direct (12.50ms). You're connected peer-to-peer" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should return message for DERP relay connection`() { + val metrics = NetworkMetrics( + p2p = false, + latency = 80.0, + preferredDerp = "derp1", + derpLatency = mapOf("derp1" to 30.0), + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + val expected = "You ↔ derp1 (30.00ms) ↔ Workspace (50.00ms). You are connected through a relay" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should round and pad latencies correctly for DERP`() { + val metrics = NetworkMetrics( + p2p = false, + latency = 78.1267, + preferredDerp = "derp2", + derpLatency = mapOf("derp2" to 23.5), + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + // Total latency: 78.1267 + // DERP latency: 23.5 → formatted as 23.50 + // Workspace latency: 78.1267 - 23.5 = 54.6267 → formatted as 54.63 + + val expected = "You ↔ derp2 (23.50ms) ↔ Workspace (54.63ms). You are connected through a relay" + assertEquals(expected, metrics.toPretty()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index d80b237..5033487 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.settings import com.coder.toolbox.store.BINARY_NAME import com.coder.toolbox.store.CODER_SSH_CONFIG_OPTIONS import com.coder.toolbox.store.CoderSettingsStore -import com.coder.toolbox.store.DEFAULT_URL import com.coder.toolbox.store.DISABLE_AUTOSTART import com.coder.toolbox.store.ENABLE_BINARY_DIR_FALLBACK import com.coder.toolbox.store.ENABLE_DOWNLOADS @@ -277,108 +276,6 @@ internal class CoderSettingsTest { assertEquals(false, settings.readOnly().requireTokenAuth) } - @Test - fun testDefaultURL() { - val tmp = Path.of(System.getProperty("java.io.tmpdir")) - val dir = tmp.resolve("coder-toolbox-test/test-default-url") - var env = Environment(mapOf("CODER_CONFIG_DIR" to dir.toString())) - dir.toFile().deleteRecursively() - - // No config. - var settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals(null, settings.defaultURL()) - - // Read from global config. - val globalConfigPath = Path.of(settings.readOnly().globalConfigDir) - globalConfigPath.toFile().mkdirs() - globalConfigPath.resolve("url").toFile().writeText("url-from-global-config") - settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals("url-from-global-config" to SettingSource.CONFIG, settings.defaultURL()) - - // Read from environment. - env = - Environment( - mapOf( - "CODER_URL" to "url-from-env", - "CODER_CONFIG_DIR" to dir.toString(), - ), - ) - settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals("url-from-env" to SettingSource.ENVIRONMENT, settings.defaultURL()) - - // Read from settings. - settings = - CoderSettingsStore( - pluginTestSettingsStore( - DEFAULT_URL to "url-from-settings", - ), - env, - logger - ) - assertEquals("url-from-settings" to SettingSource.SETTINGS, settings.defaultURL()) - } - - @Test - fun testToken() { - val tmp = Path.of(System.getProperty("java.io.tmpdir")) - val url = URL("http://test.deployment.coder.com") - val dir = tmp.resolve("coder-toolbox-test/test-default-token") - val env = - Environment( - mapOf( - "CODER_CONFIG_DIR" to dir.toString(), - "LOCALAPPDATA" to dir.toString(), - "XDG_DATA_HOME" to dir.toString(), - "HOME" to dir.toString(), - ), - ) - dir.toFile().deleteRecursively() - - // No config. - var settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals(null, settings.readOnly().token(url)) - - val globalConfigPath = Path.of(settings.readOnly().globalConfigDir) - globalConfigPath.toFile().mkdirs() - globalConfigPath.resolve("url").toFile().writeText(url.toString()) - globalConfigPath.resolve("session").toFile().writeText("token-from-global-config") - - // Ignore global config if it does not match. - assertEquals(null, settings.readOnly().token(URL("http://some.random.url"))) - - // Read from global config. - assertEquals("token-from-global-config" to SettingSource.CONFIG, settings.readOnly().token(url)) - - // Compares exactly. - assertEquals(null, settings.readOnly().token(url.withPath("/test"))) - - val deploymentConfigPath = settings.readOnly().dataDir(url).resolve("config") - deploymentConfigPath.toFile().mkdirs() - deploymentConfigPath.resolve("url").toFile().writeText("url-from-deployment-config") - deploymentConfigPath.resolve("session").toFile().writeText("token-from-deployment-config") - - // Read from deployment config. - assertEquals("token-from-deployment-config" to SettingSource.DEPLOYMENT_CONFIG, settings.readOnly().token(url)) - - // Only compares host . - assertEquals( - "token-from-deployment-config" to SettingSource.DEPLOYMENT_CONFIG, - settings.readOnly().token(url.withPath("/test")) - ) - - // Ignore if using mTLS. - settings = - CoderSettingsStore( - pluginTestSettingsStore( - TLS_KEY_PATH to "key", - TLS_CERT_PATH to "cert", - ), - env, - logger - ) - assertEquals(null, settings.readOnly().token(url)) - } - @Test fun testDefaults() { // Test defaults for the remaining settings. diff --git a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt new file mode 100644 index 0000000..636ef61 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt @@ -0,0 +1,85 @@ +package com.coder.toolbox.store + +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.util.pluginTestSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +class CoderSettingsStoreTest { + private var originalOsName: String? = null + private var originalOsArch: String? = null + + private lateinit var store: CoderSettingsStore + + @BeforeTest + fun setUp() { + originalOsName = System.getProperty("os.name") + originalOsArch = System.getProperty("os.arch") + + store = CoderSettingsStore( + pluginTestSettingsStore(), + Environment(), + mockk(relaxed = true) + ) + } + + @AfterTest + fun tearDown() { + System.setProperty("os.name", originalOsName) + System.setProperty("os.arch", originalOsArch) + } + + @Test + fun `Default CLI and signature for Windows AMD64`() = + assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") + + @Test + fun `Default CLI and signature for Windows ARM64`() = + assertBinaryAndSignature("Windows 10", "aarch64", "coder-windows-arm64.exe", "coder-windows-arm64.exe.asc") + + @Test + fun `Default CLI and signature for Linux AMD64`() = + assertBinaryAndSignature("Linux", "x86_64", "coder-linux-amd64", "coder-linux-amd64.asc") + + @Test + fun `Default CLI and signature for Linux ARM64`() = + assertBinaryAndSignature("Linux", "aarch64", "coder-linux-arm64", "coder-linux-arm64.asc") + + @Test + fun `Default CLI and signature for Linux ARMV7`() = + assertBinaryAndSignature("Linux", "armv7l", "coder-linux-armv7", "coder-linux-armv7.asc") + + @Test + fun `Default CLI and signature for Mac AMD64`() = + assertBinaryAndSignature("Mac OS X", "x86_64", "coder-darwin-amd64", "coder-darwin-amd64.asc") + + @Test + fun `Default CLI and signature for Mac ARM64`() = + assertBinaryAndSignature("Mac OS X", "aarch64", "coder-darwin-arm64", "coder-darwin-arm64.asc") + + @Test + fun `Default CLI and signature for unknown OS and Arch`() = + assertBinaryAndSignature(null, null, "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") + + @Test + fun `Default CLI and signature for unknown Arch fallback on Linux`() = + assertBinaryAndSignature("Linux", "mips64", "coder-linux-amd64", "coder-linux-amd64.asc") + + private fun assertBinaryAndSignature( + osName: String?, + arch: String?, + expectedBinary: String, + expectedSignature: String + ) { + if (osName == null) System.clearProperty("os.name") else System.setProperty("os.name", osName) + if (arch == null) System.clearProperty("os.arch") else System.setProperty("os.arch", arch) + + assertEquals(expectedBinary, store.defaultCliBinaryNameByOsAndArch) + assertEquals(expectedSignature, store.defaultSignatureNameByOsAndArch) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt new file mode 100644 index 0000000..56402e5 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -0,0 +1,178 @@ +package com.coder.toolbox.util + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.DataGen +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.store.CoderSecretsStore +import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.views.CoderSettingsPage +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.ProviderVisibilityState +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 io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.DisplayName +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal class CoderProtocolHandlerTest { + private val context = CoderToolboxContext( + mockk(relaxed = true), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(relaxed = true), + mockk(relaxed = true), + CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), + mockk(), + mockk() + ) + + private val protocolHandler = CoderProtocolHandler( + context, + DialogUi(context), + CoderSettingsPage(context, Channel(Channel.CONFLATED)), + MutableStateFlow(ProviderVisibilityState(applicationVisible = true, providerVisible = true)), + MutableStateFlow(false) + ) + + private val agents = + mapOf( + "agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", + "agent_name_bill" to "fb3daea4-da6b-424d-84c7-36b90574cfef", + "agent_name_riker" to "9a920eee-47fb-4571-9501-e4b3120c12f2", + ) + private val agentBob = + mapOf( + "agent_name_bob" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", + ) + + @Test + @DisplayName("given a ws with multiple agents, expect the correct agent to be resolved if it matches the agent_name query param") + fun getMatchingAgent() { + val ws = DataGen.workspace("ws", agents = agents) + + val tests = + listOf( + Pair( + mapOf("agent_name" to "agent_name_riker"), + "9a920eee-47fb-4571-9501-e4b3120c12f2" + ), + Pair( + mapOf("agent_name" to "agent_name_bill"), + "fb3daea4-da6b-424d-84c7-36b90574cfef" + ), + Pair( + mapOf("agent_name" to "agent_name_bob"), + "b0e4c54d-9ba9-4413-8512-11ca1e826a24" + ) + ) + runBlocking { + tests.forEach { + assertEquals(UUID.fromString(it.second), protocolHandler.getMatchingAgent(it.first, ws)?.id) + } + } + } + + @Test + @DisplayName("given a ws with only multiple agents expect the agent resolution to fail if none match the agent_name query param") + fun failsToGetMatchingAgent() { + val ws = DataGen.workspace("ws", agents = agents) + val tests = + listOf( + Triple(emptyMap(), MissingArgumentException::class, "Unable to determine"), + Triple(mapOf("agent_name" to ""), MissingArgumentException::class, "Unable to determine"), + Triple(mapOf("agent_name" to null), MissingArgumentException::class, "Unable to determine"), + Triple(mapOf("agent_name" to "not-an-agent-name"), IllegalArgumentException::class, "agent with ID"), + Triple( + mapOf("agent_name" to "agent_name_homer"), + IllegalArgumentException::class, + "agent with name" + ) + ) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + } + } + } + + @Test + @DisplayName("given a ws with only one agent, the agent is selected even when agent_name query param was not provided") + fun getsFirstAgentWhenOnlyOne() { + val ws = DataGen.workspace("ws", agents = agentBob) + val tests = + listOf( + emptyMap(), + mapOf("agent_name" to ""), + mapOf("agent_name" to null) + ) + runBlocking { + tests.forEach { + assertEquals( + UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), + protocolHandler.getMatchingAgent( + it, + ws, + )?.id, + ) + } + } + } + + @Test + @DisplayName("given a ws with only one agent, the agent is NOT selected when agent_name query param was provided but does not match") + fun failsToGetAgentWhenOnlyOne() { + val wsWithAgentBob = DataGen.workspace("ws", agents = agentBob) + val tests = + listOf( + Triple( + mapOf("agent_name" to "agent_name_garfield"), + IllegalArgumentException::class, + "agent with name" + ), + ) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, wsWithAgentBob)) + } + } + } + + @Test + @DisplayName("fails to resolve any agent when the workspace has no agents") + fun failsToGetAgentWhenWorkspaceHasNoAgents() { + val wsWithoutAgents = DataGen.workspace("ws") + val tests = + listOf( + Triple(emptyMap(), IllegalArgumentException::class, "has no agents"), + Triple(mapOf("agent_name" to ""), IllegalArgumentException::class, "has no agents"), + Triple(mapOf("agent_name" to null), IllegalArgumentException::class, "has no agents"), + Triple( + mapOf("agent_name" to "agent_name_riker"), + IllegalArgumentException::class, + "has no agents" + ), + ) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, wsWithoutAgents)) + } + } + } +} diff --git a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt deleted file mode 100644 index 61ecc65..0000000 --- a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -package com.coder.toolbox.util - -import com.coder.toolbox.sdk.DataGen -import com.sun.net.httpserver.HttpHandler -import com.sun.net.httpserver.HttpServer -import java.net.HttpURLConnection -import java.net.InetSocketAddress -import java.util.UUID -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -internal class LinkHandlerTest { - /** - * Create, start, and return a server that uses the provided handler. - */ - private fun mockServer(handler: HttpHandler): Pair { - val srv = HttpServer.create(InetSocketAddress(0), 0) - srv.createContext("/", handler) - srv.start() - return Pair(srv, "http://localhost:" + srv.address.port) - } - - /** - * Create, start, and return a server that mocks redirects. - */ - private fun mockRedirectServer( - location: String, - temp: Boolean, - ): Pair = mockServer { exchange -> - exchange.responseHeaders.set("Location", location) - exchange.sendResponseHeaders( - if (temp) HttpURLConnection.HTTP_MOVED_TEMP else HttpURLConnection.HTTP_MOVED_PERM, - -1, - ) - exchange.close() - } - - private val agents = - mapOf( - "agent_name_3" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - "agent_name_2" to "fb3daea4-da6b-424d-84c7-36b90574cfef", - "agent_name" to "9a920eee-47fb-4571-9501-e4b3120c12f2", - ) - private val oneAgent = - mapOf( - "agent_name_3" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - ) - - @Test - fun getMatchingAgent() { - val ws = DataGen.workspace("ws", agents = agents) - - val tests = - listOf( - Pair(mapOf("agent" to "agent_name"), "9a920eee-47fb-4571-9501-e4b3120c12f2"), - Pair(mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), "9a920eee-47fb-4571-9501-e4b3120c12f2"), - Pair(mapOf("agent" to "agent_name_2"), "fb3daea4-da6b-424d-84c7-36b90574cfef"), - Pair(mapOf("agent_id" to "fb3daea4-da6b-424d-84c7-36b90574cfef"), "fb3daea4-da6b-424d-84c7-36b90574cfef"), - Pair(mapOf("agent" to "agent_name_3"), "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - Pair(mapOf("agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - // Prefer agent_id. - Pair( - mapOf( - "agent" to "agent_name", - "agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - ), - "b0e4c54d-9ba9-4413-8512-11ca1e826a24", - ), - ) - - tests.forEach { - assertEquals(UUID.fromString(it.second), getMatchingAgent(it.first, ws).id) - } - } - - @Test - fun failsToGetMatchingAgent() { - val ws = DataGen.workspace("ws", agents = agents) - val tests = - listOf( - Triple(emptyMap(), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent" to ""), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_id" to ""), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent" to null), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_id" to null), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent" to "ws"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "ws.agent_name"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "agent_name_4"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent_id" to "not-a-uuid"), IllegalArgumentException::class, "agent with ID"), - Triple(mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), IllegalArgumentException::class, "agent with ID"), - // Will ignore agent if agent_id is set even if agent matches. - Triple( - mapOf( - "agent" to "agent_name", - "agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168", - ), - IllegalArgumentException::class, - "agent with ID", - ), - ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) - } - } - - @Test - fun getsFirstAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", agents = oneAgent) - val tests = - listOf( - emptyMap(), - mapOf("agent" to ""), - mapOf("agent_id" to ""), - mapOf("agent" to null), - mapOf("agent_id" to null), - ) - - tests.forEach { - assertEquals( - UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - getMatchingAgent( - it, - ws, - ).id, - ) - } - } - - @Test - fun failsToGetAgentWhenOnlyOne() { - val ws = DataGen.workspace("ws", agents = oneAgent) - val tests = - listOf( - Triple(mapOf("agent" to "ws"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "ws.agent_name_3"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent" to "agent_name_4"), IllegalArgumentException::class, "agent named"), - Triple(mapOf("agent_id" to "ceaa7bcf-1612-45d7-b484-2e0da9349168"), IllegalArgumentException::class, "agent with ID"), - ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) - } - } - - @Test - fun failsToGetAgentWithoutAgents() { - val ws = DataGen.workspace("ws") - val tests = - listOf( - Triple(emptyMap(), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent" to ""), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_id" to ""), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent" to null), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_id" to null), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent" to "agent_name"), IllegalArgumentException::class, "has no agents"), - Triple(mapOf("agent_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), IllegalArgumentException::class, "has no agents"), - ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) - } - } - - @Test - fun followsRedirects() { - val (srv1, url1) = - mockServer { exchange -> - exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1) - exchange.close() - } - val (srv2, url2) = mockRedirectServer(url1, false) - val (srv3, url3) = mockRedirectServer(url2, true) - - assertEquals(url1.toURL(), resolveRedirects(java.net.URL(url3))) - - srv1.stop(0) - srv2.stop(0) - srv3.stop(0) - } - - @Test - fun followsMaximumRedirects() { - val (srv, url) = mockRedirectServer(".", true) - - assertFailsWith( - exceptionClass = Exception::class, - block = { resolveRedirects(java.net.URL(url)) }, - ) - - srv.stop(0) - } -} diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index 1db26c7..af1b4ef 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -60,4 +60,96 @@ internal class URLExtensionsTest { ) } } + + @Test + fun `valid http URL should return Valid`() { + val result = "http://coder.com".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `valid https URL with path and query should return Valid`() { + val result = "https://coder.com/bin/coder-linux-amd64?query=1".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `relative URL should return Invalid with appropriate message`() { + val url = "/bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"/bin/coder-linux-amd64\" is missing a scheme (like https://). Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `opaque URI like mailto should return Invalid`() { + val url = "mailto:user@coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"mailto:user@coder.com\" is invalid because it is not in the standard format. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `unsupported scheme like ftp should return Invalid`() { + val url = "ftp://coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"ftp://coder.com\" must start with http:// or https://, not \"ftp\""), + result + ) + } + + @Test + fun `http URL with missing authority should return Invalid`() { + val url = "http:///bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"http:///bin/coder-linux-amd64\" does not include a valid website name. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `malformed URI should return Invalid with parsing error message`() { + val url = "http://[invalid-uri]" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The input \"http://[invalid-uri]\" is not a valid web address. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `URI without colon should return Invalid as URI is not absolute`() { + val url = "http//coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"http//coder.com\" is missing a scheme (like https://). Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `URI without double forward slashes should return Invalid because the URI is not hierarchical`() { + val url = "http:coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"http:coder.com\" is invalid because it is not in the standard format. Please enter a full web address like \"https://example.com\""), + result + ) + } + + @Test + fun `URI without a single forward slash should return Invalid because the URI does not have a hostname`() { + val url = "https:/coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("The URL \"https:/coder.com\" does not include a valid website name. Please enter a full web address like \"https://example.com\""), + result + ) + } } diff --git a/src/test/resources/extension.json b/src/test/resources/extension.json new file mode 100644 index 0000000..3f897e2 --- /dev/null +++ b/src/test/resources/extension.json @@ -0,0 +1,4 @@ +{ + "id": "com.coder.toolbox", + "version": "development" +} \ No newline at end of file diff --git a/src/test/resources/fixtures/outputs/append-blank-newlines.conf b/src/test/resources/fixtures/outputs/append-blank-newlines.conf index 7124556..51d1d75 100644 --- a/src/test/resources/fixtures/outputs/append-blank-newlines.conf +++ b/src/test/resources/fixtures/outputs/append-blank-newlines.conf @@ -3,18 +3,12 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/append-blank.conf b/src/test/resources/fixtures/outputs/append-blank.conf index d884838..f2f1c8b 100644 --- a/src/test/resources/fixtures/outputs/append-blank.conf +++ b/src/test/resources/fixtures/outputs/append-blank.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/append-no-blocks.conf b/src/test/resources/fixtures/outputs/append-no-blocks.conf index e4c161b..0c34e44 100644 --- a/src/test/resources/fixtures/outputs/append-no-blocks.conf +++ b/src/test/resources/fixtures/outputs/append-no-blocks.conf @@ -4,18 +4,12 @@ Host test2 Port 443 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/append-no-newline.conf b/src/test/resources/fixtures/outputs/append-no-newline.conf index b5b9d2c..c25a062 100644 --- a/src/test/resources/fixtures/outputs/append-no-newline.conf +++ b/src/test/resources/fixtures/outputs/append-no-newline.conf @@ -3,18 +3,12 @@ Host test Host test2 Port 443 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/append-no-related-blocks.conf b/src/test/resources/fixtures/outputs/append-no-related-blocks.conf index 87446f5..53f964e 100644 --- a/src/test/resources/fixtures/outputs/append-no-related-blocks.conf +++ b/src/test/resources/fixtures/outputs/append-no-related-blocks.conf @@ -10,18 +10,12 @@ some jetbrains config # --- END CODER JETBRAINS TOOLBOX test.coder.unrelated # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/disable-autostart.conf b/src/test/resources/fixtures/outputs/disable-autostart.conf index cf993f8..27c6986 100644 --- a/src/test/resources/fixtures/outputs/disable-autostart.conf +++ b/src/test/resources/fixtures/outputs/disable-autostart.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --disable-autostart --usage-app=jetbrains foo - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --disable-autostart --usage-app=disable foo +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --disable-autostart --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/extra-config.conf b/src/test/resources/fixtures/outputs/extra-config.conf index 3acb86d..6abe1f0 100644 --- a/src/test/resources/fixtures/outputs/extra-config.conf +++ b/src/test/resources/fixtures/outputs/extra-config.conf @@ -1,15 +1,6 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-extra--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains extra - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - ServerAliveInterval 5 - ServerAliveCountMax 3 -Host coder-jetbrains-toolbox-extra--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable extra +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null @@ -17,4 +8,5 @@ Host coder-jetbrains-toolbox-extra--test.coder.invalid--bg SetEnv CODER_SSH_SESSION_TYPE=JetBrains ServerAliveInterval 5 ServerAliveCountMax 3 + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/header-command-windows.conf b/src/test/resources/fixtures/outputs/header-command-windows.conf index 84d0529..4d3b49c 100644 --- a/src/test/resources/fixtures/outputs/header-command-windows.conf +++ b/src/test/resources/fixtures/outputs/header-command-windows.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-header--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command "\"C:\Program Files\My Header Command\HeaderCommand.exe\" --url=\"%%CODER_URL%%\" --test=\"foo bar\"" ssh --stdio --usage-app=jetbrains header - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-header--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command "\"C:\Program Files\My Header Command\HeaderCommand.exe\" --url=\"%%CODER_URL%%\" --test=\"foo bar\"" ssh --stdio --usage-app=disable header +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command "\"C:\Program Files\My Header Command\HeaderCommand.exe\" --url=\"%%CODER_URL%%\" --test=\"foo bar\"" ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/header-command.conf b/src/test/resources/fixtures/outputs/header-command.conf index c8ee5cd..4d27aaa 100644 --- a/src/test/resources/fixtures/outputs/header-command.conf +++ b/src/test/resources/fixtures/outputs/header-command.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-header--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command 'my-header-command --url="$CODER_URL" --test="foo bar" --literal='\''$CODER_URL'\''' ssh --stdio --usage-app=jetbrains header - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-header--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command 'my-header-command --url="$CODER_URL" --test="foo bar" --literal='\''$CODER_URL'\''' ssh --stdio --usage-app=disable header +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command 'my-header-command --url="$CODER_URL" --test="foo bar" --literal='\''$CODER_URL'\''' ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/log-dir.conf b/src/test/resources/fixtures/outputs/log-dir.conf index a0be236..0050661 100644 --- a/src/test/resources/fixtures/outputs/log-dir.conf +++ b/src/test/resources/fixtures/outputs/log-dir.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --log-dir /tmp/coder-toolbox/test.coder.invalid/logs --usage-app=jetbrains foo - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --log-dir /tmp/coder-toolbox/test.coder.invalid/logs --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/multiple-agents.conf b/src/test/resources/fixtures/outputs/multiple-agents.conf new file mode 100644 index 0000000..d26e398 --- /dev/null +++ b/src/test/resources/fixtures/outputs/multiple-agents.conf @@ -0,0 +1,18 @@ +# --- START CODER JETBRAINS TOOLBOX test.coder.invalid +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + +Host coder-jetbrains-toolbox--owner--foo.agent2--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent2 + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + +# --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/multiple-users.conf b/src/test/resources/fixtures/outputs/multiple-users.conf new file mode 100644 index 0000000..13801b9 --- /dev/null +++ b/src/test/resources/fixtures/outputs/multiple-users.conf @@ -0,0 +1,18 @@ +# --- START CODER JETBRAINS TOOLBOX test.coder.invalid +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + +# --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/multiple-workspaces.conf b/src/test/resources/fixtures/outputs/multiple-workspaces.conf index e54e00c..d912d26 100644 --- a/src/test/resources/fixtures/outputs/multiple-workspaces.conf +++ b/src/test/resources/fixtures/outputs/multiple-workspaces.conf @@ -1,30 +1,18 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable bar + +Host coder-jetbrains-toolbox--owner--bar.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/bar.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/no-disable-autostart.conf b/src/test/resources/fixtures/outputs/no-disable-autostart.conf index cd9e3ad..f2f1c8b 100644 --- a/src/test/resources/fixtures/outputs/no-disable-autostart.conf +++ b/src/test/resources/fixtures/outputs/no-disable-autostart.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/no-report-usage.conf b/src/test/resources/fixtures/outputs/no-report-usage.conf index 03a8d81..3f2311c 100644 --- a/src/test/resources/fixtures/outputs/no-report-usage.conf +++ b/src/test/resources/fixtures/outputs/no-report-usage.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio foo - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio foo +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/replace-end-no-newline.conf b/src/test/resources/fixtures/outputs/replace-end-no-newline.conf index 4d4e958..7e64e33 100644 --- a/src/test/resources/fixtures/outputs/replace-end-no-newline.conf +++ b/src/test/resources/fixtures/outputs/replace-end-no-newline.conf @@ -2,18 +2,12 @@ Host test Port 80 Host test2 Port 443 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/replace-end.conf b/src/test/resources/fixtures/outputs/replace-end.conf index b5b9d2c..c25a062 100644 --- a/src/test/resources/fixtures/outputs/replace-end.conf +++ b/src/test/resources/fixtures/outputs/replace-end.conf @@ -3,18 +3,12 @@ Host test Host test2 Port 443 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf b/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf index 36b03f3..f4f7f16 100644 --- a/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf +++ b/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf @@ -4,20 +4,14 @@ Host test some coder config # ------------END-CODER------------ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid Host test2 Port 443 diff --git a/src/test/resources/fixtures/outputs/replace-middle.conf b/src/test/resources/fixtures/outputs/replace-middle.conf index 437404c..8d6fadc 100644 --- a/src/test/resources/fixtures/outputs/replace-middle.conf +++ b/src/test/resources/fixtures/outputs/replace-middle.conf @@ -1,20 +1,14 @@ Host test Port 80 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid Host test2 Port 443 diff --git a/src/test/resources/fixtures/outputs/replace-only.conf b/src/test/resources/fixtures/outputs/replace-only.conf index d884838..f2f1c8b 100644 --- a/src/test/resources/fixtures/outputs/replace-only.conf +++ b/src/test/resources/fixtures/outputs/replace-only.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/replace-start.conf b/src/test/resources/fixtures/outputs/replace-start.conf index aeb47d4..dfc2151 100644 --- a/src/test/resources/fixtures/outputs/replace-start.conf +++ b/src/test/resources/fixtures/outputs/replace-start.conf @@ -1,18 +1,12 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains foo-bar - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-foo-bar--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable foo-bar +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid Host test Port 80 diff --git a/src/test/resources/fixtures/outputs/url.conf b/src/test/resources/fixtures/outputs/url.conf new file mode 100644 index 0000000..d028507 --- /dev/null +++ b/src/test/resources/fixtures/outputs/url.conf @@ -0,0 +1,10 @@ +# --- START CODER JETBRAINS TOOLBOX test.coder.invalid +Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid?foo=bar&baz=qux ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + +# --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/wildcard.conf b/src/test/resources/fixtures/outputs/wildcard.conf new file mode 100644 index 0000000..86d4d97 --- /dev/null +++ b/src/test/resources/fixtures/outputs/wildcard.conf @@ -0,0 +1,10 @@ +# --- START CODER JETBRAINS TOOLBOX test.coder.invalid +Host coder-jetbrains-toolbox-test.coder.invalid--* + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --ssh-host-prefix coder-jetbrains-toolbox-test.coder.invalid-- %h + ConnectTimeout 0 + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + SetEnv CODER_SSH_SESSION_TYPE=JetBrains + +# --- END CODER JETBRAINS TOOLBOX test.coder.invalid