diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a55c79..de884ff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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..d1d2019 --- /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@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + 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/CHANGELOG.md b/CHANGELOG.md index 0cf1ae9..e87ca97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,146 @@ ## Unreleased +### Changed + +- workspaces status is now refresh every time Coder Toolbox becomes visible + +### Fixed + +- support for downloading the CLI when proxy is configured + +## 0.6.2 - 2025-08-14 + +### Changed + +- content-type is now enforced when downloading the CLI to accept only binary responses + +## 0.6.1 - 2025-08-11 + +### Added + +- support for skipping CLI signature verification + +### Changed + +- URL validation is stricter in the connection screen and URI protocol handler +- support for verbose logging a sanitized version of the REST API request and responses + +### Fixed + +- remote IDE reconnects automatically after plugin upgrade + +## 0.6.0 - 2025-07-25 + +### Changed + +- improved workflow when network connection is flaky + +## 0.5.2 - 2025-07-22 + +### Fixed + +- fix class cast exception during signature verification +- the correct CLI signature for Windows is now downloaded + +## 0.5.1 - 2025-07-21 + +### Added + +- support for certificate based authentication + +## 0.5.0 - 2025-07-17 + +### Added + +- support for matching workspace agent in the URI via the agent name +- support for checking if CLI is signed + +### Removed + +- dropped support for `agent_id` as a URI parameter + +## 0.4.0 - 2025-07-08 + +### Added + +- support for basic authentication for HTTP/HTTPS proxy +- support for Toolbox 2.7 release + +### Changed + +- improved message while loading the workspace + +### Fixed + +- URI protocol handler is now able to switch to the Coder provider even if the last opened provider was something else + +## 0.3.2 - 2025-06-25 + +### Changed + +- the logos and icons now match the new branding + +## 0.3.1 - 2025-06-19 + +### Added + +- visual text progress during Coder CLI downloading + +### Changed + +- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically + establish it after an expired token was refreshed. + +### Fixed + +- `Stop` action is now available for running workspaces that have an out of date template. +- outdated and stopped workspaces are now updated and started when handling URI +- show errors when the Toolbox is visible again after being minimized. +- URI handling now installs the exact build number if it is available for the workspace. + +## 0.3.0 - 2025-06-10 + +### Added + +- support for Toolbox 2.6.3 with improved URI handling + +## 0.2.3 - 2025-05-26 + +### Changed + +- improved workspace status reporting (icon and colors) when it is failed, stopping, deleting, stopped or when we are + establishing the SSH connection. + +### Fixed + +- url on the main page is now refreshed when switching between multiple deployments (via logout/login or URI handling) +- tokens are now remembered after switching between multiple deployments + +## 0.2.2 - 2025-05-21 + +- render network status in the Settings tab, under `Additional environment information` section. +- quick action for creating new workspaces from the web dashboard. + +### Fixed + +- `Open web terminal` action is no longer displayed when the workspace is stopped. +- URL links can now be opened in Windows + +## 0.2.1 - 2025-05-05 + +### Changed + +- ssh configuration is simplified, background hostnames have been discarded. + +### Fixed + +- rendering glitches when a Workspace is stopped while SSH connection is alive +- misleading message saying that there are no workspaces rendered during manual authentication +- Coder Settings can now be accessed from the authentication wizard + +## 0.2.0 - 2025-04-24 + ### Added - support for using proxies. Proxy authentication is not yet supported. 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 c70df74..74e9cd5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Connects your JetBrains IDE to Coder workspaces 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.40284` release or +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). @@ -64,9 +64,9 @@ You can use specially crafted JetBrains Gateway URIs to automatically: ### Example URIs ```text -jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fp.rizon.top%3A443%2Fhttps%2Fdev.coder.com&token=zeoX4SbSpP-j2qGpajkdwxR9jBdcekXS2&workspace=bobiverse-bob&agent=dev&ide_product_code=GO&ide_build_number=241.23774.119&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs +jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fp.rizon.top%3A443%2Fhttps%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/com.coder.toolbox?url=https%3A%2F%2Fp.rizon.top%3A443%2Fhttps%2Fj5gj2r1so5nbi.pit-1.try.coder.app%2F&token=gqEirOoI1U-FfCQ6uj8iOLtybBIk99rr8&workspace=bobiverse-riker&agent=dev&ide_product_code=RR&ide_build_number=243.26053.17&folder=%2Fhome%2Fcoder%2Fworkspace%2Fhello-world-rs +jetbrains://gateway/coder?url=https%3A%2F%2Fp.rizon.top%3A443%2Fhttps%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 @@ -76,34 +76,105 @@ jetbrains://gateway/com.coder.toolbox ?url=http(s):// &token= &workspace= - &agent_id= + &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_id | ID of the agent associated with the workspace | No | +| 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 | -If only a single agent is available, specifying an agent ID is optional. However, if multiple agents exist, -you must provide either the ID to target a specific one. 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. +> [!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 (without authentication which is not yet supported) and verify that +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 @@ -115,7 +186,6 @@ interception. 2. Start the proxy: ```bash - mitmweb --ssl-insecure --set stream_large_bodies="10m" ``` @@ -127,6 +197,12 @@ mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other: 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 @@ -137,9 +213,209 @@ mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other: 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. +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 9c81da9..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. 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 a4ec268..b10e0c2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.2.0 +version=0.6.3 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 e9acb33..a3d0755 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.14.0" +plugin-structure = "3.310" +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 69ee6cd..f8b3a17 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -1,15 +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 @@ -19,15 +23,22 @@ 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.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. * @@ -43,39 +54,58 @@ class CoderRemoteEnvironment( 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")) { + val actions = mutableListOf() + if (wsRawStatus.canStop()) { + actions.add(Action(context.i18n.ptrl("Open web terminal")) { context.cs.launch { - BrowserUtil.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { + 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.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 { + context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + context.ui.showErrorInfoPopup(it) } - }) + } + }) if (wsRawStatus.canStart()) { if (workspace.outdated) { @@ -103,30 +133,85 @@ class CoderRemoteEnvironment( 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 { + 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() } - override fun afterDisconnect() { + private fun pollNetworkMetrics(): Job = context.cs.launch { + context.logger.info("Starting the network metrics poll job for $id") + while (isActive) { + context.logger.debug("Searching SSH command's PID for workspace $id...") + val pid = proxyCommandHandle.findByWorkspaceAndAgent(workspace, agent) + if (pid == null) { + context.logger.debug("No SSH command PID was found for workspace $id") + delay(POLL_INTERVAL) + continue + } + + val metricsFile = Path.of(context.settingsStore.networkInfoDir, "$pid.json").toFile() + if (metricsFile.doesNotExists()) { + context.logger.debug("No metrics file found at ${metricsFile.absolutePath} for $id") + delay(POLL_INTERVAL) + continue + } + context.logger.debug("Loading metrics from ${metricsFile.absolutePath} for $id") + try { + val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) ?: return@launch + context.logger.debug("$id metrics: $metrics") + additionalEnvironmentInformation[context.i18n.ptrl("Network Status")] = metrics.toPretty() + } catch (e: Exception) { + context.logger.error( + e, + "Error encountered while trying to load network metrics from ${metricsFile.absolutePath} for $id" + ) + } + delay(POLL_INTERVAL) + } + } + + private fun File.doesNotExists(): Boolean = !this.exists() + + override fun afterDisconnect(isManual: Boolean) { + context.logger.info("Stopping the network metrics poll job for $id") + pollJob?.cancel() this.connectionRequest.update { false } - 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") } @@ -153,31 +238,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( + 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) { + 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 { connectionRequest.update { true } } + return true } + return false } override fun getDeleteEnvironmentConfirmationParams(): DeleteEnvironmentConfirmationParams? { @@ -191,7 +283,7 @@ class CoderRemoteEnvironment( } } - override fun onDelete() { + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow { context.cs.launch { try { client.removeWorkspace(workspace) @@ -220,6 +312,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 4ccce7e..596255e 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -1,22 +1,27 @@ 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.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.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.ExperimentalCoroutinesApi @@ -30,6 +35,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI +import java.util.UUID import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -48,8 +54,8 @@ 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) @@ -59,12 +65,23 @@ class CoderRemoteProvider( // 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 coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized) - override val environments: MutableStateFlow>> = MutableStateFlow( + + override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") + override val environments: MutableStateFlow>> = MutableStateFlow( LoadableState.Loading ) + private val visibilityState = MutableStateFlow( + ProviderVisibilityState( + applicationVisible = false, + providerVisible = false + ) + ) + + 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 @@ -105,7 +122,6 @@ class CoderRemoteProvider( return@launch } - // Reconfigure if environments changed. if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") @@ -115,7 +131,7 @@ class CoderRemoteProvider( environments.update { LoadableState.Value(resolvedEnvironments.toList()) } - if (isInitialized.value == false) { + if (!isInitialized.value) { context.logger.info("Environments for ${client.url} are now initialized") isInitialized.update { true @@ -125,33 +141,55 @@ class CoderRemoteProvider( clear() addAll(resolvedEnvironments.sortedBy { it.id }) } + + 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() + } + + 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, going to re-initialize the http client...") - client.setupSession() + context.logger.info("wake-up from an OS sleep was detected") } else { - context.logger.error(ex, "workspace polling error encountered, trying to auto-login") - close() - goToEnvironmentsPage() - break + 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 + } } } - // TODO: Listening on a web socket might be better? - select { + select { onTimeout(POLL_INTERVAL) { - context.logger.trace("workspace poller waked up by the $POLL_INTERVAL timeout") + context.logger.debug("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") + 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() } @@ -162,9 +200,7 @@ class CoderRemoteProvider( * 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() } @@ -184,6 +220,14 @@ class CoderRemoteProvider( override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( + Action(context.i18n.ptrl("Create workspace")) { + context.cs.launch { + 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) }, @@ -202,7 +246,7 @@ class CoderRemoteProvider( environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } client = null - AuthWizardState.resetSteps() + CoderCliSetupWizardState.goToFirstStep() } override val svgIcon: SvgIcon = @@ -250,32 +294,67 @@ 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 { + 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(), + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + true + } + }, + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + false + } + } + ) { 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) { + context.logger.error(ex, "") + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered while handling Coder URI"), + context.i18n.pnotr(textError ?: ""), + context.i18n.ptrl("Dismiss") + ) + } finally { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + false + } + } } /** @@ -285,48 +364,58 @@ 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) { - val errorBuffer = mutableListOf() - // When coming back to the application, authenticate immediately. - val autologin = shouldDoAutoLogin() - 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) { - errorBuffer.add(ex) - } - } + // When coming back to the application, initializeSession immediately. + if (shouldDoAutoSetup()) { + try { + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) + } catch (ex: Exception) { + errorBuffer.add(ex) + } finally { + firstRun = false } } - firstRun = false // Login flow. - val authWizard = AuthWizardPage(context, false, ::onConnect) + val setupWizardPage = + CoderCliSetupWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect) // We might have navigated here due to a polling error. errorBuffer.forEach { - authWizard.notify("Error encountered", it) + setupWizardPage.notify("Error encountered", it) } + errorBuffer.clear() // and now reset the errors, otherwise we show it every time on the screen - return authWizard + 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) this.client = client pollJob?.cancel() + environments.showLoadingMessage() + coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) pollJob = poll(client, cli) - goToEnvironmentsPage() + context.envPageManager.showPluginEnvironmentsPage() + } + + 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 9e5eace..4291321 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -1,23 +1,29 @@ 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, @@ -35,32 +41,51 @@ 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") + ) + } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 5ab89a2..5cfcd11 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -8,10 +8,12 @@ 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 @@ -30,7 +32,9 @@ class CoderToolboxExtension : RemoteDevExtension { serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), + serviceLocator.getService(), serviceLocator.getService(), + serviceLocator.getService(), serviceLocator.getService(), 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 c7e4297..67947c3 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 } + } + + // we have the cli, and signature is downloaded, let's verify the signature + signatureResult = signatureResult as Downloaded + gpgVerifier.verifySignature(cliResult.dst, signatureResult.dst).let { result -> + when { + result.isValid() -> { + downloader.commit() + return true + } - HttpURLConnection.HTTP_NOT_MODIFIED -> { - logger.info("Using cached binary at $localBinaryPath") - return false + else -> { + logFailure(result, cliResult, signatureResult) + // prompt the user if he wants to accept the risk + val shouldRunAnyway = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("Could not verify the authenticity of the ${cliResult.source}, it may be tampered with. Would you like to run it anyway?"), + context.i18n.ptrl("Run anyway"), + context.i18n.ptrl("Abort"), + ) + + if (shouldRunAnyway) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unverified CLI from ${cliResult.source} was denied by the user") + } + } } } - } catch (e: ConnectException) { - // Add the URL so this is more easily debugged. - throw ConnectException("${e.message} to $remoteBinaryURL") } finally { - conn.disconnect() + downloader.cleanup() } - throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode) } - /** - * Return the entity tag for the binary on disk, if any. - */ - private fun getBinaryETag(): String? = try { - sha1(FileInputStream(localBinaryPath.toFile())) - } catch (e: FileNotFoundException) { - null - } catch (e: Exception) { - logger.warn(e, "Unable to calculate hash for $localBinaryPath") - null + private fun logFailure( + result: VerificationResult, + cliResult: Downloaded, + signatureResult: Downloaded + ) { + when { + result.isInvalid() -> { + val reason = (result as Invalid).reason + context.logger.error("Signature of ${cliResult.dst} is invalid." + reason?.let { " Reason: $it" } + .orEmpty()) + } + + result.signatureIsNotFound() -> { + context.logger.error("Can't verify signature of ${cliResult.dst} because ${signatureResult.dst} does not exist") + } + + else -> { + val failure = result as Failed + UnsignedBinaryExecutionDeniedException(result.error.message) + context.logger.error(failure.error, "Failed to verify signature for ${cliResult.dst}") + } + } } /** - * Use the provided token to authenticate the CLI. + * Use the provided token to initializeSession the CLI. */ fun login(token: String): String { - logger.info("Storing CLI credentials in $coderConfigPath") + context.logger.info("Storing CLI credentials in $coderConfigPath") return exec( "login", deploymentURL.toString(), @@ -226,7 +313,7 @@ class CoderCLIManager( wsWithAgents: Set>, feats: Features = features, ) { - logger.info("Configuring SSH config at ${settings.sshConfigPath}") + context.logger.info("Configuring SSH config at ${context.settingsStore.sshConfigPath}") writeSSHConfig(modifySSHConfig(readSSHConfig(), wsWithAgents, feats)) } @@ -234,8 +321,8 @@ class CoderCLIManager( * 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 } @@ -266,22 +353,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 +379,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,19 +387,8 @@ class CoderCLIManager( """.trimIndent() .plus("\n" + options.prependIndent(" ")) .plus(extraConfig) - .plus("\n\n") - .plus( - """ - Host ${getBackgroundHostnamePrefix(deploymentURL)}--* - ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${ - getBackgroundHostnamePrefix( - deploymentURL - ) - }-- %h - """.trimIndent() - .plus("\n" + options.prependIndent(" ")) - .plus(extraConfig), - ).replace("\n", System.lineSeparator()) + + .plus("\n") + .replace("\n", System.lineSeparator()) + System.lineSeparator() + endBlock } else { wsWithAgents.joinToString( @@ -328,25 +403,13 @@ class CoderCLIManager( .plus("\n" + options.prependIndent(" ")) .plus(extraConfig) .plus("\n") - .plus( - """ - Host ${getBackgroundHostname(deploymentURL, it.workspace(), it.agent())} - ProxyCommand ${backgroundProxyArgs.joinToString(" ")} ${ - getWsByOwner( - it.workspace(), - it.agent() - ) - } - """.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() } @@ -354,12 +417,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 @@ -383,7 +446,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 @@ -394,7 +457,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). @@ -409,14 +472,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() } } } @@ -434,9 +497,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") } } @@ -449,14 +512,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 @@ -469,17 +532,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 } @@ -487,13 +551,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 } @@ -512,24 +576,18 @@ class CoderCLIManager( } fun getHostname(url: URL, ws: Workspace, agent: WorkspaceAgent): String { - return if (settings.isSshWildcardConfigEnabled && features.wildcardSsh) { + 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()}" } } - fun getBackgroundHostname(url: URL, ws: Workspace, agent: WorkspaceAgent): String { - return "${getHostname(url, ws, agent)}--bg" - } - companion object { private val tokenRegex = "--token [^ ]+".toRegex() private fun getHostnamePrefix(url: URL): String = "coder-jetbrains-toolbox-${url.safeHost()}" - private fun getBackgroundHostnamePrefix(url: URL): String = "coder-jetbrains-toolbox-${url.safeHost()}-bg" - private fun getWsByOwner(ws: Workspace, agent: WorkspaceAgent): String = "${ws.ownerName}/${ws.name}.${agent.name}" 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..574184c --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -0,0 +1,240 @@ +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 + +/** + * 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?.startsWith("application/octet-stream") != true) { + 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 7a67b36..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,31 +61,33 @@ 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, + fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr(label), color = getStateColor(context), - reachable = ready() || unhealthy(), + isReachable = ready() || unhealthy(), // TODO@JB: How does this work? Would like a spinner for pending states. - icon = getStateIcon() + iconId = getStateIcon().id, + isPriorityShow = true ) } private fun getStateColor(context: CoderToolboxContext): StateColor { - return if (ready()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Active) - else if (unhealthy()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unhealthy) - 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() || unhealthy()) 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 } 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 6785675..803472c 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -3,9 +3,11 @@ 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 @@ -15,15 +17,10 @@ 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.OkHttpClient import retrofit2.Response @@ -32,7 +29,6 @@ import retrofit2.converter.moshi.MoshiConverterFactory import java.net.HttpURLConnection import java.net.URL import java.util.UUID -import javax.net.ssl.X509TrustManager /** * An HTTP client that can make requests to the Coder API. @@ -45,7 +41,6 @@ open class CoderRestClient( val token: String?, 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 @@ -57,7 +52,7 @@ open class CoderRestClient( setupSession() } - fun setupSession() { + private fun setupSession() { moshi = Moshi.Builder() .add(ArchConverter()) @@ -65,78 +60,40 @@ open class CoderRestClient( .add(OSConverter()) .add(UUIDConverter()) .build() - - val socketFactory = coderSocketFactory(settings.tls) - val trustManagers = coderTrustManagers(settings.tls.caPath) - var builder = OkHttpClient.Builder() - - if (context.proxySettings.getProxy() != null) { - context.logger.debug("proxy: ${context.proxySettings.getProxy()}") - builder.proxy(context.proxySettings.getProxy()) - } else if (context.proxySettings.getProxySelector() != null) { - context.logger.debug("proxy selector: ${context.proxySettings.getProxySelector()}") - builder.proxySelector(context.proxySettings.getProxySelector()!!) - } - - //TODO - add support for proxy auth. when Toolbox exposes them -// builder.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 @@ -149,7 +106,12 @@ open class CoderRestClient( suspend fun me(): User { val userResponse = retroRestClient.me() if (!userResponse.isSuccessful) { - throw APIResponseException("authenticate", url, userResponse.code(), userResponse.parseErrorBody(moshi)) + throw APIResponseException( + "initializeSession", + url, + userResponse.code(), + userResponse.parseErrorBody(moshi) + ) } return userResponse.body()!! @@ -192,12 +154,12 @@ open class CoderRestClient( } /** - * Maps the list of workspaces to the associated agents. + * Maps the available workspaces to the associated agents. */ - suspend fun groupByAgents(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 -> + return workspaces().flatMap { ws -> when (ws.latestBuild.status) { WorkspaceStatus.RUNNING -> ws.latestBuild.resources else -> resources(ws) @@ -261,7 +223,12 @@ 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( 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 9f78198..d109c75 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt @@ -8,8 +8,9 @@ import java.net.URL 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( 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/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 aca2464..f0e84b9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -9,20 +9,25 @@ 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.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay 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" +private val noOpTextProgress: (String) -> Unit = { _ -> } + +@Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, private val dialogUi: DialogUi, @@ -40,119 +45,276 @@ open class CoderProtocolHandler( suspend fun handle( uri: URI, shouldWaitForAutoLogin: Boolean, + markAsBusy: () -> Unit, + unmarkAsBusy: () -> Unit, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { - context.popupPluginMainPage() 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() + markAsBusy() + 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 + val restClient = buildRestClient(deploymentURL, token) ?: return + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return + + val cli = configureCli(deploymentURL, restClient) + + var agent: WorkspaceAgent + try { + 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. + agent = resolveAgent( + params, + restClient.workspace(workspace.id) + ) ?: return + if (!ensureAgentIsReady(workspace, agent)) return + } finally { + unmarkAsBusy() + } + 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) + } + } + + 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 + } + + 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 + } - // 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 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 } + } + + /** + * 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 + } - val workspaces = restClient.workspaces() - val workspace = workspaces.firstOrNull { it.name == workspaceName } + 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 + } - // TODO: Show a dropdown and ask for an agent if missing. - val agent: WorkspaceAgent + private suspend fun resolveAgent( + params: Map, + workspace: Workspace + ): WorkspaceAgent? { try { - agent = getMatchingAgent(params, workspace) + return getMatchingAgent(params, workspace) } catch (e: IllegalArgumentException) { - context.logger.error(e, "Can't resolve an agent for workspace $workspaceName from $deploymentURL") - context.showErrorPopup( - MissingArgumentException( - "Can't handle URI because we can't resolve an agent for workspace $workspaceName from $deploymentURL", - e - ) + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't resolve an agent for workspace ${workspace.name}", + e ) - return + 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 + } + + 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 ready") - context.showErrorPopup(MissingArgumentException("Can't handle URI because agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready")) - 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 + ): CoderCLIManager { val cli = ensureCLI( context, deploymentURL.toURL(), - restClient.buildInfo().version + restClient.buildInfo().version, + noOpTextProgress ) // We only need to log in if we are using token-based auth. @@ -162,31 +324,92 @@ open class CoderProtocolHandler( } context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.groupByAgents(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 { + 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 projectFolder = params.projectFolder() - 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, projectFolder) + 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 { + 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 { @@ -204,6 +427,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( @@ -211,129 +453,17 @@ 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") - } - val client = CoderRestClient( - context, - deploymentURL.toURL(), - token, - 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. - * - * @throws [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 (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 { - throw MissingArgumentException( - "Unable to determine which agent to connect to; \"$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() { this.ui.showWindow() this.envPageManager.showPluginEnvironmentsPage(true) } -/** - * Suspends the coroutine until first true value is received. - */ -suspend fun StateFlow.waitForTrue() = this.first { it } +private suspend fun CoderToolboxContext.showEnvironmentPage(envId: String) { + this.ui.showWindow() + this.envPageManager.showEnvironmentPage(envId, false) +} 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 1135227..a343e14 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -3,8 +3,7 @@ package com.coder.toolbox.util 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 FOLDER = "folder" @@ -15,7 +14,7 @@ fun Map.token() = this[TOKEN] fun Map.workspace() = this[WORKSPACE] -fun Map.agentID() = this[AGENT_ID] +fun Map.agentName() = this[AGENT_NAME] fun Map.ideProductCode() = this[IDE_PRODUCT_CODE] 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/AuthWizardPage.kt deleted file mode 100644 index ca92ed7..0000000 --- a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt +++ /dev/null @@ -1,95 +0,0 @@ -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.WizardStep -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.update - -class AuthWizardPage( - private val context: CoderToolboxContext, - initialAutoLogin: Boolean = false, - onConnect: ( - client: CoderRestClient, - cli: CoderCLIManager, - ) -> Unit, -) : CoderPage(context, context.i18n.ptrl("Authenticate to Coder")) { - private val shouldAutoLogin = MutableStateFlow(initialAutoLogin) - - private val signInStep = SignInStep(context, this::notify) - private val tokenStep = TokenStep(context) - private val connectStep = ConnectStep(context, shouldAutoLogin, this::notify, this::displaySteps, onConnect) - - - /** - * Fields for this page, displayed in order. - */ - override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) - override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) - - override fun beforeShow() { - displaySteps() - } - - private fun displaySteps() { - when (AuthWizardState.currentStep()) { - WizardStep.URL_REQUEST -> { - fields.update { - listOf(signInStep.panel) - } - actionButtons.update { - listOf( - Action(context.i18n.ptrl("Sign In"), closesPage = false, actionBlock = { - if (signInStep.onNext()) { - displaySteps() - } - }) - ) - } - signInStep.onVisible() - } - - WizardStep.TOKEN_REQUEST -> { - fields.update { - listOf(tokenStep.panel) - } - actionButtons.update { - listOf( - Action(context.i18n.ptrl("Connect"), closesPage = false, actionBlock = { - if (tokenStep.onNext()) { - displaySteps() - } - }), - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { - tokenStep.onBack() - displaySteps() - }) - ) - } - tokenStep.onVisible() - } - - WizardStep.LOGIN -> { - fields.update { - listOf(connectStep.panel) - } - actionButtons.update { - listOf( - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { - connectStep.onBack() - shouldAutoLogin.update { - false - } - displaySteps() - }) - ) - } - connectStep.onVisible() - } - } - } -} diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt new file mode 100644 index 0000000..5115204 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -0,0 +1,159 @@ +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.sdk.ex.APIResponseException +import com.coder.toolbox.util.toURL +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.ui.actions.RunnableActionDescription +import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.UUID + +class CoderCliSetupWizardPage( + private val context: CoderToolboxContext, + private val settingsPage: CoderSettingsPage, + private val visibilityState: MutableStateFlow, + initialAutoSetup: Boolean = false, + onConnect: suspend ( + client: CoderRestClient, + cli: CoderCLIManager, + ) -> Unit, +) : 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 deploymentUrlStep = DeploymentUrlStep(context, this::notify) + private val tokenStep = TokenStep(context) + private val connectStep = ConnectStep( + context, + shouldAutoSetup, + this::notify, + this::displaySteps, + onConnect + ) + + /** + * Fields for this page, displayed in order. + */ + override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) + override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) + + private val errorBuffer = mutableListOf() + + init { + if (shouldAutoSetup.value) { + CoderCliSetupContext.url = context.secrets.lastDeploymentURL.toURL() + CoderCliSetupContext.token = context.secrets.lastToken + } + } + + override fun beforeShow() { + displaySteps() + if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { + errorBuffer.forEach { + showError(it) + } + errorBuffer.clear() + } + } + + private fun displaySteps() { + when (CoderCliSetupWizardState.currentStep()) { + WizardStep.URL_REQUEST -> { + fields.update { + listOf(deploymentUrlStep.panel) + } + actionButtons.update { + listOf( + Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = { + if (deploymentUrlStep.onNext()) { + displaySteps() + } + }), + settingsAction + ) + } + deploymentUrlStep.onVisible() + } + + WizardStep.TOKEN_REQUEST -> { + fields.update { + listOf(tokenStep.panel) + } + actionButtons.update { + listOf( + Action(context.i18n.ptrl("Connect"), closesPage = false, actionBlock = { + if (tokenStep.onNext()) { + displaySteps() + } + }), + settingsAction, + Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + tokenStep.onBack() + displaySteps() + }) + ) + } + tokenStep.onVisible() + } + + WizardStep.CONNECT -> { + fields.update { + listOf(connectStep.panel) + } + actionButtons.update { + listOf( + settingsAction, + Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + connectStep.onBack() + shouldAutoSetup.update { + false + } + displaySteps() + }) + ) + } + connectStep.onVisible() + } + } + } + + /** + * Show an error as a popup on this page. + */ + fun notify(logPrefix: String, ex: Throwable) { + context.logger.error(ex, logPrefix) + 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 + + 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") + ) + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 77fb1c2..363d618 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -6,8 +6,8 @@ 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.launch -import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update /** * Base page that handles the icon, displaying error notifications, and @@ -20,10 +20,15 @@ import java.util.UUID * to use the mouse. */ abstract class CoderPage( - private val context: CoderToolboxContext, - title: LocalizableString, + private val titleObservable: MutableStateFlow, showIcon: Boolean = true, -) : UiPage(title) { +) : UiPage(titleObservable) { + + fun setTitle(title: LocalizableString) { + titleObservable.update { + title + } + } /** * Return the icon, if showing one. @@ -39,19 +44,10 @@ abstract class CoderPage( 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) - context.cs.launch { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.pnotr(logPrefix), - context.i18n.pnotr(ex.message ?: ""), - context.i18n.ptrl("Dismiss") - ) - } + override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) + + companion object { + fun emptyPage(ctx: CoderToolboxContext): UiPage = UiPage(ctx.i18n.pnotr("")) } } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index ff86c42..e937600 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -1,15 +1,23 @@ 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.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 +27,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 +40,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 +86,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 +108,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< disableAutostartField, enableSshWildCardConfig, sshLogDirField, + networkInfoDirField, sshExtraArgs, ) ) @@ -80,16 +116,19 @@ 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) @@ -103,9 +142,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 { + 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 3abbae8..7ea93e4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -5,9 +5,8 @@ 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.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField @@ -28,7 +27,7 @@ class ConnectStep( private val shouldAutoLogin: StateFlow, private val notify: (String, Throwable) -> Unit, private val refreshWizard: () -> Unit, - private val onConnect: ( + private val onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, @@ -43,15 +42,19 @@ class ConnectStep( RowGroup.RowField(errorField) ) - override val nextButtonTitle: LocalizableString? = null - override fun onVisible() { 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,52 +62,55 @@ 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 } signInJob?.cancel() signInJob = context.cs.launch { try { - statusField.textState.update { (context.i18n.ptrl("Authenticating to ${url.host}...")) } val client = CoderRestClient( context, - url, - token, + 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() + statusField.textState.update { (context.i18n.ptrl("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) { + statusField.textState.update { (context.i18n.ptrl("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}...")) } + statusField.textState.update { (context.i18n.ptrl("Successfully configured ${CoderCliSetupContext.url!!.host}...")) } // allows interleaving with the back/cancel action yield() + CoderCliSetupContext.reset() + CoderCliSetupWizardState.goToFirstStep() onConnect(client, cli) - AuthWizardState.resetSteps() } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { - notify("Connection to ${url.host} was configured", ex) + notify("Connection to ${CoderCliSetupContext.url!!.host} was configured", ex) onBack() refreshWizard() } } catch (ex: Exception) { - notify("Failed to configure ${url.host}", ex) + notify("Failed to configure ${CoderCliSetupContext.url!!.host}", ex) onBack() refreshWizard() } @@ -120,10 +126,14 @@ class ConnectStep( signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON)) } finally { if (shouldAutoLogin.value) { - AuthWizardState.resetSteps() - context.secrets.rememberMe = false + CoderCliSetupContext.reset() + CoderCliSetupWizardState.goToFirstStep() } else { - AuthWizardState.goToPreviousStep() + if (context.settingsStore.requireTokenAuth) { + CoderCliSetupWizardState.goToPreviousStep() + } else { + CoderCliSetupWizardState.goToFirstStep() + } } } } 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..34b027c --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -0,0 +1,110 @@ +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.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.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, + private val notify: (String, Throwable) -> Unit +) : + WizardStep { + 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() + } + } + + 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) { + notify("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 020ed8a..3353fe4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt +++ b/src/main/kotlin/com/coder/toolbox/views/EnvironmentView.kt @@ -21,20 +21,51 @@ class EnvironmentView( 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 = 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 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 + } + + 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/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 488045e..0000000 --- a/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.coder.toolbox.views - -import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.util.toURL -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 -import java.net.MalformedURLException - -/** - * 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, private val notify: (String, Throwable) -> Unit) : - 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("https://") && !url.startsWith("https://")) { - "https://$url" - } else { - url - } - try { - validateRawUrl(url) - } catch (e: MalformedURLException) { - notify("URL is invalid", e) - return false - } - context.secrets.lastDeploymentURL = url - AuthWizardState.goToNextStep() - return true - } - - /** - * Throws [MalformedURLException] if the given string violates RFC-2396 - */ - private fun validateRawUrl(url: String) { - try { - 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/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 d612000..7f5c831 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -17,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 @@ -30,27 +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 @@ -59,23 +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. @@ -104,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" @@ -116,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() @@ -128,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) @@ -156,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) @@ -179,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() @@ -195,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) @@ -213,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( @@ -241,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) @@ -282,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( @@ -303,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() @@ -321,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()) @@ -338,16 +377,17 @@ 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) @@ -510,14 +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(it.url ?: URI.create("https://test.coder.invalid").toURL(), 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. @@ -531,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()) @@ -539,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()) @@ -593,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( @@ -601,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, @@ -628,15 +678,16 @@ internal class CoderCLIManagerTest { tests.forEach { val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + HEADER_COMMAND to it, + ), + Environment(), + context.logger + ) + ), URI.create("https://test.coder.invalid").toURL(), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - HEADER_COMMAND to it, - ), - Environment(), - context.logger - ).readOnly(), ) assertFailsWith( @@ -679,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() @@ -732,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() @@ -836,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 @@ -870,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, @@ -884,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()) } @@ -931,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 0cee720..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,11 @@ 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 @@ -101,7 +104,9 @@ class CoderRestClientTest { mockk(), mockk(), mockk(), + mockk(), mockk(), + mockk(), mockk(), mockk(relaxed = true), mockk(), @@ -110,6 +115,8 @@ class CoderRestClientTest { object : ToolboxProxySettings { override fun getProxy(): Proxy? = null override fun getProxySelector(): ProxySelector? = null + override fun getProxyAuth(): ProxyAuth? = null + override fun addProxyChangeListener(listener: Runnable) { } @@ -218,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( @@ -231,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 = @@ -575,6 +602,7 @@ class CoderRestClientTest { } } + override fun getProxyAuth(): ProxyAuth? = null override fun addProxyChangeListener(listener: Runnable) { } 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..b26acde --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -0,0 +1,173 @@ +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.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 io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +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), + 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 bb87151..0000000 --- a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt +++ /dev/null @@ -1,219 +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_id" to "9a920eee-47fb-4571-9501-e4b3120c12f2"), - "9a920eee-47fb-4571-9501-e4b3120c12f2" - ), - Pair( - mapOf("agent_id" to "fb3daea4-da6b-424d-84c7-36b90574cfef"), - "fb3daea4-da6b-424d-84c7-36b90574cfef" - ), - Pair( - mapOf("agent_id" to "b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - "b0e4c54d-9ba9-4413-8512-11ca1e826a24" - ), - // Prefer agent_id. - Pair( - mapOf( - "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_id" to ""), MissingArgumentException::class, "Unable to determine"), - Triple(mapOf("agent_id" to null), MissingArgumentException::class, "Unable to determine"), - 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_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 c73e3a2..51d1d75 100644 --- a/src/test/resources/fixtures/outputs/append-blank-newlines.conf +++ b/src/test/resources/fixtures/outputs/append-blank-newlines.conf @@ -4,17 +4,11 @@ # --- 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 --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--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 owner/foo.agent1 + 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 7c9df80..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--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 --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--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 owner/foo.agent1 + 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 8db68d4..0c34e44 100644 --- a/src/test/resources/fixtures/outputs/append-no-blocks.conf +++ b/src/test/resources/fixtures/outputs/append-no-blocks.conf @@ -5,17 +5,11 @@ Host test2 # --- 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 --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--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 owner/foo.agent1 + 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 0a48b7e..c25a062 100644 --- a/src/test/resources/fixtures/outputs/append-no-newline.conf +++ b/src/test/resources/fixtures/outputs/append-no-newline.conf @@ -4,17 +4,11 @@ Host test2 Port 443 # --- 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 --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--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 owner/foo.agent1 + 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 73ebf8d..53f964e 100644 --- a/src/test/resources/fixtures/outputs/append-no-related-blocks.conf +++ b/src/test/resources/fixtures/outputs/append-no-related-blocks.conf @@ -11,17 +11,11 @@ some jetbrains config # --- 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 --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--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 owner/foo.agent1 + 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 916aa9f..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--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 --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--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 owner/foo.agent1 + 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 9a7900c..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--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 --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - ServerAliveInterval 5 - ServerAliveCountMax 3 -Host coder-jetbrains-toolbox--owner--foo.agent1--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 owner/foo.agent1 + 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--owner--foo.agent1--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 a27f0ab..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--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 --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--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 owner/foo.agent1 + 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 61b5d74..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--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 --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--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 owner/foo.agent1 + 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 84d7549..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--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 --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 -Host coder-jetbrains-toolbox--owner--foo.agent1--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 owner/foo.agent1 + 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 index a81a8be..d26e398 100644 --- a/src/test/resources/fixtures/outputs/multiple-agents.conf +++ b/src/test/resources/fixtures/outputs/multiple-agents.conf @@ -1,30 +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 --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--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 owner/foo.agent1 + 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 --usage-app=jetbrains owner/foo.agent2 - 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--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 owner/foo.agent2 + 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 index c8cd03d..13801b9 100644 --- a/src/test/resources/fixtures/outputs/multiple-users.conf +++ b/src/test/resources/fixtures/outputs/multiple-users.conf @@ -1,30 +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 --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--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 owner/foo.agent1 + 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 --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--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 owner/foo.agent1 + 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 8629ce3..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--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 --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--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 owner/foo.agent1 + 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--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 --usage-app=jetbrains owner/bar.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--bar.agent1--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 owner/bar.agent1 + 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 7c9df80..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--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 --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--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 owner/foo.agent1 + 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 7c971e5..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--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 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--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 owner/foo.agent1 + 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 77a7cbb..7e64e33 100644 --- a/src/test/resources/fixtures/outputs/replace-end-no-newline.conf +++ b/src/test/resources/fixtures/outputs/replace-end-no-newline.conf @@ -3,17 +3,11 @@ Host test Host test2 Port 443 # --- 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 --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--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 owner/foo.agent1 + 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 0a48b7e..c25a062 100644 --- a/src/test/resources/fixtures/outputs/replace-end.conf +++ b/src/test/resources/fixtures/outputs/replace-end.conf @@ -4,17 +4,11 @@ Host test2 Port 443 # --- 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 --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--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 owner/foo.agent1 + 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 ce23062..f4f7f16 100644 --- a/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf +++ b/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf @@ -5,19 +5,13 @@ some coder config # ------------END-CODER------------ # --- 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 --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--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 owner/foo.agent1 + 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 9125cd8..8d6fadc 100644 --- a/src/test/resources/fixtures/outputs/replace-middle.conf +++ b/src/test/resources/fixtures/outputs/replace-middle.conf @@ -2,19 +2,13 @@ Host test Port 80 # --- 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 --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--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 owner/foo.agent1 + 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 7c9df80..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--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 --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--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 owner/foo.agent1 + 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 060582d..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--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 --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--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 owner/foo.agent1 + 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 index dd3b17b..d028507 100644 --- a/src/test/resources/fixtures/outputs/url.conf +++ b/src/test/resources/fixtures/outputs/url.conf @@ -1,16 +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 --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--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?foo=bar&baz=qux ssh --stdio --usage-app=disable owner/foo.agent1 + 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 index b3fd6b8..86d4d97 100644 --- a/src/test/resources/fixtures/outputs/wildcard.conf +++ b/src/test/resources/fixtures/outputs/wildcard.conf @@ -1,17 +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 --ssh-host-prefix coder-jetbrains-toolbox-test.coder.invalid-- %h + 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 -Host coder-jetbrains-toolbox-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 --ssh-host-prefix coder-jetbrains-toolbox-test.coder.invalid-bg-- %h - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains # --- END CODER JETBRAINS TOOLBOX test.coder.invalid