diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 323b3d5b..e6e4b79b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: - windows-latest runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-java@v5 with: @@ -35,7 +35,7 @@ jobs: # Collect Tests Result of failed tests - if: ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: tests-result path: ${{ github.workspace }}/build/reports/tests @@ -50,7 +50,7 @@ jobs: steps: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Setup Java 21 environment for the next steps - name: Setup Java @@ -79,13 +79,13 @@ jobs: # Store already-built plugin as an artifact for downloading - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: zip-artifacts path: ./build/distributions/*.zip - name: Upload Release Notes - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-notes path: RELEASE_NOTES.md @@ -101,7 +101,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Remove old release drafts by using GitHub CLI - name: Remove Old Release Drafts @@ -113,7 +113,7 @@ jobs: | xargs -I '{}' gh api -X DELETE repos/${{ github.repository }}/releases/{} - name: Download Build Artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: zip-artifacts path: artifacts/ @@ -121,7 +121,7 @@ jobs: run: ls -R artifacts/ - name: Download Release Notes - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: release-notes path: notes/ diff --git a/.github/workflows/jetbrains-compliance.yml b/.github/workflows/jetbrains-compliance.yml index 74339e88..d41b69f8 100644 --- a/.github/workflows/jetbrains-compliance.yml +++ b/.github/workflows/jetbrains-compliance.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up JDK 21 uses: actions/setup-java@v5 @@ -40,7 +40,7 @@ jobs: ./gradlew detekt - name: Upload detekt reports - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: always() with: name: detekt-reports diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6918c4e6..2d8ca658 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ github.event.release.tag_name }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e4eaf183..de79e057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ ## Unreleased +## 0.8.1 - 2025-12-11 + +### Changed + +- streamlined URI handling with a faster workflow, clearer progress, and an overall smoother experience + +### Fixed + +- URI handling on Linux can now launch IDEs on newly started workspaces + +## 0.8.0 - 2025-12-03 + +### Added + +- application name can now be displayed as the main title page instead of the URL +- automatic mTLS certificate regeneration and retry mechanism + +### Changed + +- workspaces are now started with the help of the CLI + +## 0.7.2 - 2025-11-03 + +### Changed + +- URI handling no longer waits for confirmation to use latest build if the provided build number is too old + +### Fixed + +- IDE is now launched when URI is handled by an already running Toolbox instance. + +## 0.7.1 - 2025-10-13 + +### Fixed + +- potential race condition that could cause crashes when settings are modified concurrently +- CLI download on some Windows versions + +## 0.7.0 - 2025-09-27 + ### Changed - simplified storage for last used url and token diff --git a/README.md b/README.md index 74e9cd53..2d50806d 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,16 @@ storage paths. The options can be configured from the plugin's main Workspaces p - `Header command` command that outputs additional HTTP headers. Each line of output must be in the format key=value. The environment variable CODER_URL will be available to the command process. +- `lastDeploymentURL` the last Coder deployment URL that Coder Toolbox successfully authenticated to. + +- `workspaceViewUrl` specifies the dashboard page full URL where users can view details about a workspace. + Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment workspace page. + This setting supports `$workspaceOwner` and `$workspaceName` as placeholders. + +- `workspaceCreateUrl` specifies the dashboard page full URL where users can create new workspaces. + Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment templates page. + This setting supports `$workspaceOwner` as placeholder with the replacing value being the username that logged in. + ### TLS settings The following options control the secure communication behavior of the plugin with Coder deployment and its available diff --git a/gradle.properties b/gradle.properties index dc031f50..d9ce8bf0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.7.0 +version=0.8.1 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e12b62e..253d2c1a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,19 +4,19 @@ 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.49" +dependency-license-report = "3.0.1" +marketplace-client = "2.0.50" gradle-wrapper = "0.15.0" exec = "1.12" moshi = "1.15.2" ksp = "2.1.20-2.0.1" retrofit = "3.0.0" -changelog = "2.4.0" +changelog = "2.5.0" gettext = "0.7.0" -plugin-structure = "3.316" -mockk = "1.14.5" +plugin-structure = "3.320" +mockk = "1.14.7" detekt = "1.23.8" -bouncycastle = "1.82" +bouncycastle = "1.83" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 5bb42967..5cf160d0 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -26,6 +26,7 @@ import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.TextType import com.squareup.moshi.Moshi import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -53,18 +54,18 @@ class CoderRemoteEnvironment( private var workspace: Workspace, private var agent: WorkspaceAgent, ) : RemoteProviderEnvironment("${workspace.name}.${agent.name}"), BeforeConnectionHook, AfterDisconnectHook { - private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) + private var environmentStatus = 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)) + MutableStateFlow(environmentStatus.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()) + override val actionsList: MutableStateFlow> = MutableStateFlow(emptyList()) private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) private val proxyCommandHandle = SshCommandProcessHandle(context) @@ -75,14 +76,17 @@ class CoderRemoteEnvironment( context.logger.info("resuming SSH connection to $id — last session was still active.") startSshConnection() } + refreshAvailableActions() } fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) - private fun getAvailableActions(): List { + private fun refreshAvailableActions() { val actions = mutableListOf() - if (wsRawStatus.canStop()) { + context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus") + if (environmentStatus.canStop()) { actions.add(Action(context, "Open web terminal") { + context.logger.debug("Launching web terminal for $id...") context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { context.ui.showErrorInfoPopup(it) } @@ -91,8 +95,14 @@ class CoderRemoteEnvironment( } actions.add( Action(context, "Open in dashboard") { + val urlTemplate = context.settingsStore.workspaceViewUrl + ?: client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + val url = urlTemplate + .replace("\$workspaceOwner", workspace.ownerName) + .replace("\$workspaceName", workspace.name) + context.logger.debug("Opening the dashboard for $id...") context.desktop.browse( - client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + url ) { context.ui.showErrorInfoPopup(it) } @@ -100,31 +110,39 @@ class CoderRemoteEnvironment( ) actions.add(Action(context, "View template") { + context.logger.debug("Opening the template for $id...") context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { context.ui.showErrorInfoPopup(it) } - } - ) + }) - if (wsRawStatus.canStart()) { + if (environmentStatus.canStart()) { if (workspace.outdated) { actions.add(Action(context, "Update and start") { + context.logger.debug("Updating and starting $id...") val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) - } - ) + }) } else { actions.add(Action(context, "Start") { - val build = client.startWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - - } - ) + context.logger.debug("Starting $id... ") + context.cs + .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { + cli.startWorkspace(workspace.ownerName, workspace.name) + } + // cli takes 15 seconds to move the workspace in queueing/starting state + // while the user won't see anything happening in TBX after start is clicked + // During those 15 seconds we work around by forcing a `Queuing` state + updateStatus(WorkspaceAndAgentStatus.QUEUED) + // force refresh of the actions list (Start should no longer be available) + refreshAvailableActions() + }) } } - if (wsRawStatus.canStop()) { + if (environmentStatus.canStop()) { if (workspace.outdated) { actions.add(Action(context, "Update and restart") { + context.logger.debug("Updating and re-starting $id...") val build = client.updateWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } @@ -132,7 +150,7 @@ class CoderRemoteEnvironment( } actions.add(Action(context, "Stop") { tryStopSshConnection() - + context.logger.debug("Stoping $id...") val build = client.stopWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) } @@ -142,12 +160,14 @@ class CoderRemoteEnvironment( actions.add(Action(context, "Delete workspace", highlightInRed = true) { context.cs.launch(CoroutineName("Delete Workspace Action")) { var dialogText = - if (wsRawStatus.canStop()) "This will close the workspace and remove all its information, including files, unsaved changes, history, and usage data." + if (environmentStatus.canStop()) "This will close the workspace and remove all its information, including files, unsaved changes, history, and usage data." else "This will remove all information from the workspace, including files, unsaved changes, history, and usage data." dialogText += "\n\nType \"${workspace.name}\" below to confirm:" val confirmation = context.ui.showTextInputPopup( - if (wsRawStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl("Delete workspace?"), + if (environmentStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl( + "Delete workspace?" + ), context.i18n.pnotr(dialogText), context.i18n.ptrl("Workspace name"), TextType.General, @@ -157,10 +177,14 @@ class CoderRemoteEnvironment( if (confirmation != workspace.name) { return@launch } + context.logger.debug("Deleting $id...") deleteWorkspace() } }) - return actions + + actionsList.update { + actions + } } private suspend fun tryStopSshConnection() { @@ -236,19 +260,26 @@ class CoderRemoteEnvironment( * Update the workspace/agent status to the listeners, if it has changed. */ fun update(workspace: Workspace, agent: WorkspaceAgent) { + if (this.workspace.latestBuild == workspace.latestBuild) { + return + } this.workspace = workspace this.agent = agent - wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) + // workspace&agent status can be different from "environment status" + // which is forced to queued state when a workspace is scheduled to start + updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) + // we have to regenerate the action list in order to force a redraw // because the actions don't have a state flow on the enabled property - actionsList.update { - getAvailableActions() - } - context.cs.launch(CoroutineName("Workspace Status Updater")) { - state.update { - wsRawStatus.toRemoteEnvironmentState(context) - } + refreshAvailableActions() + } + + private fun updateStatus(status: WorkspaceAndAgentStatus) { + environmentStatus = status + state.update { + environmentStatus.toRemoteEnvironmentState(context) } + context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}") } /** @@ -278,11 +309,9 @@ class CoderRemoteEnvironment( * Returns true if the SSH connection was scheduled to start, false otherwise. */ fun startSshConnection(): Boolean { - if (wsRawStatus.ready() && !isConnected.value) { - context.cs.launch(CoroutineName("SSH Connection Trigger")) { - connectionRequest.update { - true - } + if (environmentStatus.ready() && !isConnected.value) { + connectionRequest.update { + true } return true } @@ -304,7 +333,7 @@ class CoderRemoteEnvironment( withTimeout(5.minutes) { var workspaceStillExists = true while (context.cs.isActive && workspaceStillExists) { - if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) { + if (environmentStatus == WorkspaceAndAgentStatus.DELETING || environmentStatus == WorkspaceAndAgentStatus.DELETED) { workspaceStillExists = false context.envPageManager.showPluginEnvironmentsPage() } else { diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index ed4854cf..98cc1ab3 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -2,11 +2,20 @@ package com.coder.toolbox import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.plugin.PluginManager 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.TOKEN +import com.coder.toolbox.util.URL +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.toQueryParameters +import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.token +import com.coder.toolbox.util.url +import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.util.waitForTrue import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action @@ -37,6 +46,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -44,6 +55,7 @@ import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as DropDownM import com.jetbrains.toolbox.api.ui.components.AccountDropdownField as dropDownFactory private val POLL_INTERVAL = 5.seconds +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @OptIn(ExperimentalCoroutinesApi::class) class CoderRemoteProvider( @@ -57,23 +69,34 @@ class CoderRemoteProvider( private val triggerSshConfig = Channel(Channel.CONFLATED) private val triggerProviderVisible = Channel(Channel.CONFLATED) - private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) private val dialogUi = DialogUi(context) // The REST client, if we are signed in private var client: CoderRestClient? = null + private var cli: CoderCLIManager? = null // On the first load, automatically log in if we can. private var firstRun = true + private val isInitialized: MutableStateFlow = MutableStateFlow(false) + private val isHandlingUri: AtomicBoolean = AtomicBoolean(false) private val coderHeaderPage = NewEnvironmentPage(context.i18n.pnotr(context.deploymentUrl.toString())) + private val settingsPage: CoderSettingsPage = CoderSettingsPage(context, triggerSshConfig) { + client?.let { restClient -> + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + } + } + } private val visibilityState = MutableStateFlow( ProviderVisibilityState( applicationVisible = false, providerVisible = false ) ) - private val linkHandler = CoderProtocolHandler(context, dialogUi, settingsPage, visibilityState, isInitialized) + private val linkHandler = CoderProtocolHandler(context) override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") override val environments: MutableStateFlow>> = MutableStateFlow( @@ -224,7 +247,11 @@ class CoderRemoteProvider( override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( Action(context, "Create workspace") { - context.desktop.browse(client?.url?.withPath("/templates").toString()) { + val url = context.settingsStore.workspaceCreateUrl ?: client?.url?.withPath("/templates").toString() + context.desktop.browse( + url + .replace("\$workspaceOwner", client?.me?.username ?: "") + ) { context.ui.showErrorInfoPopup(it) } }, @@ -241,6 +268,17 @@ class CoderRemoteProvider( * Also called as part of our own logout. */ override fun close() { + softClose() + client = null + cli = null + lastEnvironments.clear() + environments.value = LoadableState.Value(emptyList()) + isInitialized.update { false } + CoderCliSetupWizardState.goToFirstStep() + context.logger.info("Coder plugin is now closed") + } + + private fun softClose() { pollJob?.let { it.cancel() context.logger.info("Cancelled workspace poll job ${pollJob.toString()}") @@ -249,12 +287,6 @@ class CoderRemoteProvider( it.close() context.logger.info("REST API client closed and resources released") } - client = null - lastEnvironments.clear() - environments.value = LoadableState.Value(emptyList()) - isInitialized.update { false } - CoderCliSetupWizardState.goToFirstStep() - context.logger.info("Coder plugin is now closed") } override val svgIcon: SvgIcon = @@ -318,24 +350,49 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { - linkHandler.handle( - uri, - shouldDoAutoSetup() - ) { restClient, cli -> - context.logger.info("Stopping workspace polling and de-initializing resources") - close() - isInitialized.update { - false + 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 + } + isHandlingUri.set(true) + // this switches to the main plugin screen, even + // if last opened provider was not Coder + context.envPageManager.showPluginEnvironmentsPage() + coderHeaderPage.isBusy.update { true } + context.logger.info("Handling $uri...") + val newUrl = resolveDeploymentUrl(params)?.toURL() ?: return + val newToken = if (context.settingsStore.requiresMTlsAuth) null else resolveToken(params) ?: return + if (sameUrl(newUrl, client?.url)) { + if (context.settingsStore.requiresTokenAuth) { + newToken?.let { + refreshSession(newUrl, it) + } + } + } else { + CoderCliSetupContext.apply { + url = newUrl + token = newToken + } + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + CoderCliSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = true, + jumpToMainPageOnError = true, + connectSynchronously = true, + onConnect = ::onConnect + ).apply { + beforeShow() } - context.logger.info("Starting initialization with the new settings") - this@CoderRemoteProvider.client = restClient - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) - - environments.showLoadingMessage() - pollJob = poll(restClient, cli) - context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI $uri") - isInitialized.waitForTrue() } + // force the poll loop to run + triggerProviderVisible.send(true) + // wait for environments to be populated + isInitialized.waitForTrue() + + linkHandler.handle(params, newUrl, this.client!!, this.cli!!) + coderHeaderPage.isBusy.update { false } } catch (ex: Exception) { val textError = if (ex is APIResponseException) { if (!ex.reason.isNullOrBlank()) { @@ -347,7 +404,63 @@ class CoderRemoteProvider( textError ?: "" ) context.envPageManager.showPluginEnvironmentsPage() + } finally { + coderHeaderPage.isBusy.update { false } + isHandlingUri.set(false) + firstRun = false + } + } + + private suspend fun resolveDeploymentUrl(params: Map): String? { + val deploymentURL = params.url() ?: askUrl() + if (deploymentURL.isNullOrBlank()) { + 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 + } + + 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 fun sameUrl(first: URL, second: URL?): Boolean = first.toURI().normalize() == second?.toURI()?.normalize() + + private suspend fun refreshSession(url: URL, token: String): Pair { + context.logger.info("Stopping workspace polling and re-initializing the http client and cli with a new token") + softClose() + val newRestClient = CoderRestClient( + context, + url, + token, + PluginManager.pluginInfo.version, + ).apply { initializeSession() } + val newCli = CoderCLIManager(context, url).apply { + login(token) + } + this.client = newRestClient + this.cli = newCli + pollJob = poll(newRestClient, newCli) + context.logger.info("Workspace poll job with name ${pollJob.toString()} was created while handling URI") + return newRestClient to newCli + } + + private suspend fun askUrl(): String? { + context.popupPluginMainPage() + return dialogUi.ask( + context.i18n.ptrl("Deployment URL"), + context.i18n.ptrl("Enter the full URL of your Coder deployment") + ) } /** @@ -357,6 +470,9 @@ class CoderRemoteProvider( * list. */ override fun getOverrideUiPage(): UiPage? { + if (isHandlingUri.get()) { + return null + } // Show the setup page if we have not configured the client yet. if (client == null) { // When coming back to the application, initializeSession immediately. @@ -398,26 +514,28 @@ class CoderRemoteProvider( * 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 && (canAutoLogin() || !settings.requireTokenAuth) + private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requiresTokenAuth) fun canAutoLogin(): Boolean = !context.secrets.tokenFor(context.deploymentUrl).isNullOrBlank() private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. + close() context.settingsStore.updateLastUsedUrl(client.url) - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { context.secrets.storeTokenFor(client.url, client.token ?: "") context.logger.info("Deployment URL and token were stored and will be available for automatic connection") } else { context.logger.info("Deployment URL was stored and will be available for automatic connection") } this.client = client - pollJob?.let { - it.cancel() - context.logger.info("Cancelled workspace poll job ${pollJob.toString()} in order to start a new one") - } + this.cli = cli environments.showLoadingMessage() - coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + if (context.settingsStore.useAppNameAsTitle) { + coderHeaderPage.setTitle(context.i18n.pnotr(client.appName)) + } else { + coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString())) + } context.logger.info("Displaying ${client.url} in the UI") pollJob = poll(client, cli) context.logger.info("Workspace poll job with name ${pollJob.toString()} was created") diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 9b058e57..0fe6c25e 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -19,6 +19,7 @@ import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW import com.coder.toolbox.util.InvalidVersionException +import com.coder.toolbox.util.ReloadableTlsContext import com.coder.toolbox.util.SemVer import com.coder.toolbox.util.escape import com.coder.toolbox.util.escapeSubcommand @@ -125,6 +126,7 @@ data class Features( val disableAutostart: Boolean = false, val reportWorkspaceUsage: Boolean = false, val wildcardSsh: Boolean = false, + val buildReason: Boolean = false, ) /** @@ -152,7 +154,8 @@ class CoderCLIManager( } val okHttpClient = CoderHttpClientBuilder.build( context, - interceptors + interceptors, + ReloadableTlsContext(context.settingsStore.readOnly().tls) ) val retrofit = Retrofit.Builder() @@ -304,6 +307,25 @@ class CoderCLIManager( ) } + /** + * Start a workspace. Throws if the command execution fails. + */ + fun startWorkspace(workspaceOwner: String, workspaceName: String, feats: Features = features): String { + val args = mutableListOf( + "--global-config", + coderConfigPath.toString(), + "start", + "--yes", + "$workspaceOwner/$workspaceName" + ) + + if (feats.buildReason) { + args.addAll(listOf("--reason", "jetbrains_connection")) + } + + return exec(*args.toTypedArray()) + } + /** * Configure SSH to use this binary. * @@ -354,24 +376,22 @@ class CoderCLIManager( // always use the correct URL. "--url", escape(deploymentURL.toString()), - if (!context.settingsStore.headerCommand.isNullOrBlank()) "--header-command" else null, - if (!context.settingsStore.headerCommand.isNullOrBlank()) escapeSubcommand(context.settingsStore.headerCommand!!) else null, + context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { "--header-command" }, + context.settingsStore.headerCommand?.takeIf { it.isNotBlank() }?.let { escapeSubcommand(it) }, "ssh", "--stdio", if (context.settingsStore.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, "--network-info-dir ${escape(context.settingsStore.networkInfoDir)}" ) val proxyArgs = baseArgs + listOfNotNull( - if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, - if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) escape(context.settingsStore.sshLogDirectory!!) else null, + context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let { "--log-dir" }, + context.settingsStore.sshLogDirectory?.takeIf { it.isNotBlank() }?.let { escape(it) }, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) - val extraConfig = - if (!context.settingsStore.sshConfigOptions.isNullOrBlank()) { - "\n" + context.settingsStore.sshConfigOptions!!.prependIndent(" ") - } else { - "" - } + val extraConfig = context.settingsStore.sshConfigOptions + ?.takeIf { it.isNotBlank() } + ?.let { "\n" + it.prependIndent(" ") } + ?: "" val options = """ ConnectTimeout 0 StrictHostKeyChecking no @@ -571,7 +591,8 @@ class CoderCLIManager( Features( disableAutostart = version >= SemVer(2, 5, 0), reportWorkspaceUsage = version >= SemVer(2, 13, 0), - version >= SemVer(2, 19, 0), + wildcardSsh = version >= SemVer(2, 19, 0), + buildReason = version >= SemVer(2, 25, 0), ) } } diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt index 468bfd8a..2c2e87c6 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -35,6 +35,7 @@ private val SUPPORTED_BIN_MIME_TYPES = listOf( "application/x-winexe", "application/x-msdos-program", "application/x-msdos-executable", + "application/x-ms-dos-executable", "application/vnd.microsoft.portable-executable" ) /** diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt index f80d60cb..a526db05 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderHttpClientBuilder.kt @@ -2,31 +2,26 @@ 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.coder.toolbox.util.ReloadableTlsContext 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 + interceptors: List, + tlsContext: ReloadableTlsContext ): 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()!!) + val builder = OkHttpClient.Builder() + + context.proxySettings.getProxy()?.let { proxy -> + context.logger.info("proxy: $proxy") + builder.proxy(proxy) + } ?: context.proxySettings.getProxySelector()?.let { proxySelector -> + context.logger.info("proxy selector: $proxySelector") + builder.proxySelector(proxySelector) } // Note: This handles only HTTP/HTTPS proxy authentication. @@ -43,8 +38,8 @@ object CoderHttpClientBuilder { .build() } - builder.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) - .hostnameVerifier(CoderHostnameVerifier(settings.tls.altHostname)) + builder.sslSocketFactory(tlsContext.sslSocketFactory, tlsContext.trustManager) + .hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname)) .retryOnConnectionFailure(true) interceptors.forEach { interceptor -> diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 803472cb..b44352d3 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -7,20 +7,21 @@ 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.CertificateRefreshInterceptor import com.coder.toolbox.sdk.interceptors.Interceptors import com.coder.toolbox.sdk.v2.CoderV2RestFacade import com.coder.toolbox.sdk.v2.models.ApiErrorResponse +import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template import com.coder.toolbox.sdk.v2.models.User import com.coder.toolbox.sdk.v2.models.Workspace -import com.coder.toolbox.sdk.v2.models.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.ReloadableTlsContext import com.squareup.moshi.Moshi import okhttp3.OkHttpClient import retrofit2.Response @@ -41,12 +42,14 @@ open class CoderRestClient( val token: String?, private val pluginVersion: String = "development", ) { + private lateinit var tlsContext: ReloadableTlsContext private lateinit var moshi: Moshi private lateinit var httpClient: OkHttpClient private lateinit var retroRestClient: CoderV2RestFacade lateinit var me: User lateinit var buildVersion: String + lateinit var appName: String init { setupSession() @@ -60,12 +63,17 @@ open class CoderRestClient( .add(OSConverter()) .add(UUIDConverter()) .build() + + tlsContext = ReloadableTlsContext(context.settingsStore.readOnly().tls) + val interceptors = buildList { - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { if (token.isNullOrBlank()) { throw IllegalStateException("Token is required for $url deployment") } add(Interceptors.tokenAuth(token)) + } else if (context.settingsStore.requiresMTlsAuth && context.settingsStore.tls.certRefreshCommand?.isNotBlank() == true) { + add(CertificateRefreshInterceptor(context, tlsContext)) } add((Interceptors.userAgent(pluginVersion))) add(Interceptors.externalHeaders(context, url)) @@ -74,7 +82,8 @@ open class CoderRestClient( httpClient = CoderHttpClientBuilder.build( context, - interceptors + interceptors, + tlsContext ) retroRestClient = @@ -96,6 +105,7 @@ open class CoderRestClient( suspend fun initializeSession(): User { me = me() buildVersion = buildInfo().version + appName = appearance().applicationName return me } @@ -103,7 +113,7 @@ open class CoderRestClient( * Retrieve the current user. * @throws [APIResponseException]. */ - suspend fun me(): User { + internal suspend fun me(): User { val userResponse = retroRestClient.me() if (!userResponse.isSuccessful) { throw APIResponseException( @@ -114,7 +124,28 @@ open class CoderRestClient( ) } - return userResponse.body()!! + return requireNotNull(userResponse.body()) { + "Successful response returned null body or user" + } + } + + /** + * Retrieves the visual dashboard configuration. + */ + internal suspend fun appearance(): Appearance { + val appearanceResponse = retroRestClient.appearance() + if (!appearanceResponse.isSuccessful) { + throw APIResponseException( + "initializeSession", + url, + appearanceResponse.code(), + appearanceResponse.parseErrorBody(moshi) + ) + } + + return requireNotNull(appearanceResponse.body()) { + "Successful response returned null body for visual dashboard configuration" + } } /** @@ -132,7 +163,9 @@ open class CoderRestClient( ) } - return workspacesResponse.body()!!.workspaces + return requireNotNull(workspacesResponse.body()?.workspaces) { + "Successful response returned null body or workspaces" + } } /** @@ -140,33 +173,19 @@ open class CoderRestClient( * @throws [APIResponseException]. */ suspend fun workspace(workspaceID: UUID): Workspace { - val workspacesResponse = retroRestClient.workspace(workspaceID) - if (!workspacesResponse.isSuccessful) { + val workspaceResponse = retroRestClient.workspace(workspaceID) + if (!workspaceResponse.isSuccessful) { throw APIResponseException( "retrieve workspace", url, - workspacesResponse.code(), - workspacesResponse.parseErrorBody(moshi) + workspaceResponse.code(), + workspaceResponse.parseErrorBody(moshi) ) } - return workspacesResponse.body()!! - } - - /** - * Maps the available workspaces to the associated agents. - */ - 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 -> - when (ws.latestBuild.status) { - WorkspaceStatus.RUNNING -> ws.latestBuild.resources - else -> resources(ws) - }.filter { it.agents != null }.flatMap { it.agents!! }.map { - ws to it - } - }.toSet() + return requireNotNull(workspaceResponse.body()) { + "Successful response returned null body or workspace" + } } /** @@ -187,7 +206,10 @@ open class CoderRestClient( resourcesResponse.parseErrorBody(moshi) ) } - return resourcesResponse.body()!! + + return requireNotNull(resourcesResponse.body()) { + "Successful response returned null body or workspace resources" + } } suspend fun buildInfo(): BuildInfo { @@ -200,7 +222,10 @@ open class CoderRestClient( buildInfoResponse.parseErrorBody(moshi) ) } - return buildInfoResponse.body()!! + + return requireNotNull(buildInfoResponse.body()) { + "Successful response returned null body or build info" + } } /** @@ -216,12 +241,16 @@ open class CoderRestClient( templateResponse.parseErrorBody(moshi) ) } - return templateResponse.body()!! + + return requireNotNull(templateResponse.body()) { + "Successful response returned null body or template" + } } /** * @throws [APIResponseException]. */ + @Deprecated(message = "This operation needs to be delegated to the CLI") suspend fun startWorkspace(workspace: Workspace): WorkspaceBuild { val buildRequest = CreateWorkspaceBuildRequest( null, @@ -238,7 +267,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } /** @@ -254,7 +286,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } /** @@ -296,7 +331,10 @@ open class CoderRestClient( buildResponse.parseErrorBody(moshi) ) } - return buildResponse.body()!! + + return requireNotNull(buildResponse.body()) { + "Successful response returned null body or workspace build" + } } fun close() { diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt new file mode 100644 index 00000000..55dae436 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/CertificateRefreshInterceptor.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.util.ReloadableTlsContext +import okhttp3.Interceptor +import okhttp3.Response +import org.zeroturnaround.exec.ProcessExecutor +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +class CertificateRefreshInterceptor( + private val context: CoderToolboxContext, + private val tlsContext: ReloadableTlsContext +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + try { + return chain.proceed(request) + } catch (e: Exception) { + if ((e is SSLHandshakeException || e is SSLPeerUnverifiedException) && (e.message?.contains("certificate_expired") == true)) { + val command = context.settingsStore.tls.certRefreshCommand + if (command.isNullOrBlank()) { + throw IllegalStateException( + "Certificate expiration interceptor was set but the refresh command was removed in the meantime", + e + ) + } + + context.logger.info("SSL handshake exception encountered: certificates expired. Running certificate refresh command: $command") + try { + val result = ProcessExecutor() + .command(command.split(" ").toList()) + .exitValueNormal() + .readOutput(true) + .execute() + context.logger.info("`$command`: ${result.outputUTF8()}") + + if (result.exitValue == 0) { + context.logger.info("Certificate refresh command executed successfully. Reloading SSL certificates.") + tlsContext.reload() + // Retry the request + return chain.proceed(request) + } else { + context.logger.error("Certificate refresh command failed with exit code ${result.exitValue}") + } + } catch (ex: Exception) { + context.logger.error(ex, "Failed to execute certificate refresh command") + } + } + throw e + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt index adcaa6ef..5e7fc133 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.sdk.v2 +import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.Template @@ -23,6 +24,12 @@ interface CoderV2RestFacade { @GET("api/v2/users/me") suspend fun me(): Response + /** + * Returns the configuration of the visual dashboard. + */ + @GET("api/v2/appearance") + suspend fun appearance(): Response + /** * Retrieves all workspaces the authenticated user has access to. */ diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt new file mode 100644 index 00000000..0c8d830b --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/Appearance.kt @@ -0,0 +1,9 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Appearance( + @property:Json(name = "application_name") val applicationName: String +) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt index 2c5767e2..6b399871 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt @@ -10,20 +10,43 @@ import java.util.UUID */ @JsonClass(generateAdapter = true) data class WorkspaceBuild( - @Json(name = "template_version_id") val templateVersionID: UUID, - @Json(name = "resources") val resources: List, - @Json(name = "status") val status: WorkspaceStatus, + @property:Json(name = "id") val id: UUID, + @property:Json(name = "build_number") val buildNumber: Int, + @property:Json(name = "template_version_id") val templateVersionID: UUID, + @property:Json(name = "resources") val resources: List, + @property:Json(name = "status") val status: WorkspaceStatus, ) enum class WorkspaceStatus { - @Json(name = "pending") PENDING, - @Json(name = "starting") STARTING, - @Json(name = "running") RUNNING, - @Json(name = "stopping") STOPPING, - @Json(name = "stopped") STOPPED, - @Json(name = "failed") FAILED, - @Json(name = "canceling") CANCELING, - @Json(name = "canceled") CANCELED, - @Json(name = "deleting") DELETING, - @Json(name = "deleted") DELETED, -} + @Json(name = "pending") + PENDING, + + @Json(name = "starting") + STARTING, + + @Json(name = "running") + RUNNING, + + @Json(name = "stopping") + STOPPING, + + @Json(name = "stopped") + STOPPED, + + @Json(name = "failed") + FAILED, + + @Json(name = "canceling") + CANCELING, + + @Json(name = "canceled") + CANCELED, + + @Json(name = "deleting") + DELETING, + + @Json(name = "deleted") + DELETED; + + fun isNotStarted(): Boolean = this != STARTING && this != RUNNING +} \ 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 9ac6438e..689f2793 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -19,6 +19,12 @@ interface ReadOnlyCoderSettings { */ val defaultURL: String + /** + * Whether to display the application name instead of the URL + * in the main screen. Defaults to URL + */ + val useAppNameAsTitle: Boolean + /** * Used to download the Coder CLI which is necessary to proxy SSH * connections. The If-None-Match header will be set to the SHA1 of the CLI @@ -108,7 +114,12 @@ interface ReadOnlyCoderSettings { /** * Whether login should be done with a token */ - val requireTokenAuth: Boolean + val requiresTokenAuth: Boolean + + /** + * Whether the authentication is done with certificates. + */ + val requiresMTlsAuth: Boolean /** * Whether to add --disable-autostart to the proxy command. This works @@ -137,6 +148,17 @@ interface ReadOnlyCoderSettings { */ val sshConfigOptions: String? + /** + * A custom full URL to the dashboard page used for viewing details about a workspace. + * Supports `$workspaceOwner` and `$workspaceName` as placeholders. + */ + val workspaceViewUrl: String? + + /** + * A custom full URL to the dashboard page used for creating workspaces. + * Supports `$workspaceOwner` as placeholder. + */ + val workspaceCreateUrl: String? /** * The path where network information for SSH hosts are stored @@ -199,6 +221,12 @@ interface ReadOnlyTLSSettings { * Coder service does not match the hostname in the TLS certificate. */ val altHostname: String? + + /** + * Command to run when certificates expire and SSLHandshakeException + * is raised with `Received fatal alert: certificate_expired` as message + */ + val certRefreshCommand: String? } enum class SignatureFallbackStrategy { diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 66706ca9..ab8e54be 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -32,12 +32,14 @@ class CoderSettingsStore( override val certPath: String?, override val keyPath: String?, override val caPath: String?, - override val altHostname: String? + override val altHostname: String?, + override val certRefreshCommand: String? ) : ReadOnlyTLSSettings // Properties implementation override val lastDeploymentURL: String? get() = store[LAST_USED_URL] override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" + override val useAppNameAsTitle: Boolean get() = store[APP_NAME_AS_TITLE]?.toBooleanStrictOrNull() ?: false override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] override val disableSignatureVerification: Boolean @@ -61,9 +63,11 @@ class CoderSettingsStore( certPath = store[TLS_CERT_PATH], keyPath = store[TLS_KEY_PATH], caPath = store[TLS_CA_PATH], - altHostname = store[TLS_ALTERNATE_HOSTNAME] + altHostname = store[TLS_ALTERNATE_HOSTNAME], + certRefreshCommand = store[TLS_CERT_REFRESH_COMMAND] ) - override val requireTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank() + override val requiresTokenAuth: Boolean get() = tls.certPath.isNullOrBlank() || tls.keyPath.isNullOrBlank() + override val requiresMTlsAuth: Boolean get() = tls.certPath?.isNotBlank() == true && tls.keyPath?.isNotBlank() == true override val disableAutostart: Boolean get() = store[DISABLE_AUTOSTART]?.toBooleanStrictOrNull() ?: (getOS() == OS.MAC) override val isSshWildcardConfigEnabled: Boolean @@ -80,6 +84,11 @@ class CoderSettingsStore( .normalize() .toString() + override val workspaceViewUrl: String? + get() = store[WORKSPACE_VIEW_URL] + override val workspaceCreateUrl: String? + get() = store[WORKSPACE_CREATE_URL] + /** * Where the specified deployment should put its data. */ @@ -160,6 +169,10 @@ class CoderSettingsStore( store[LAST_USED_URL] = url.toString() } + fun updateUseAppNameAsTitle(appNameAsTitle: Boolean) { + store[APP_NAME_AS_TITLE] = appNameAsTitle.toString() + } + fun updateBinarySource(source: String) { store[BINARY_SOURCE] = source } diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 555c6b5c..c199aecd 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -6,6 +6,8 @@ internal const val LAST_USED_URL = "lastDeploymentURL" internal const val DEFAULT_URL = "defaultURL" +internal const val APP_NAME_AS_TITLE = "useAppNameAsTitle" + internal const val BINARY_SOURCE = "binarySource" internal const val BINARY_DIRECTORY = "binaryDirectory" @@ -34,6 +36,8 @@ internal const val TLS_CA_PATH = "tlsCAPath" internal const val TLS_ALTERNATE_HOSTNAME = "tlsAlternateHostname" +internal const val TLS_CERT_REFRESH_COMMAND = "tlsCertRefreshCommand" + internal const val DISABLE_AUTOSTART = "disableAutostart" internal const val ENABLE_SSH_WILDCARD_CONFIG = "enableSshWildcardConfig" @@ -46,5 +50,8 @@ internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions" internal const val NETWORK_INFO_DIR = "networkInfoDir" +internal const val WORKSPACE_VIEW_URL = "workspaceViewUrl" +internal const val WORKSPACE_CREATE_URL = "workspaceCreateUrl" + 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 a4c0b483..ae6d13a1 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -2,30 +2,19 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager -import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.models.WorkspaceAndAgentStatus -import com.coder.toolbox.plugin.PluginManager 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.coder.toolbox.util.WebUrlValidationResult.Invalid -import com.coder.toolbox.views.CoderCliSetupWizardPage -import com.coder.toolbox.views.CoderSettingsPage -import com.coder.toolbox.views.state.CoderCliSetupContext -import com.coder.toolbox.views.state.CoderCliSetupWizardState -import com.coder.toolbox.views.state.WizardStep -import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout -import java.net.URI +import java.net.URL import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes @@ -37,10 +26,6 @@ private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, - private val dialogUi: DialogUi, - private val settingsPage: CoderSettingsPage, - private val visibilityState: MutableStateFlow, - private val isInitialized: StateFlow, ) { private val settings = context.settingsStore.readOnly() @@ -52,40 +37,15 @@ open class CoderProtocolHandler( * connectable state. */ suspend fun handle( - uri: URI, - shouldWaitForAutoLogin: Boolean, - reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit + params: Map, + url: URL, + restClient: CoderRestClient, + cli: CoderCLIManager ) { - val params = uri.toQueryParameters() - if (params.isEmpty()) { - // probably a plugin installation scenario - context.logAndShowInfo("URI will not be handled", "No query parameters were provided") - return - } - // this switches to the main plugin screen, even - // if last opened provider was not Coder - context.envPageManager.showPluginEnvironmentsPage() - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() - } - - context.logger.info("Handling $uri...") - val deploymentURL = resolveDeploymentUrl(params) ?: return - val token = if (!context.settingsStore.requireTokenAuth) null else resolveToken(params) ?: return val workspaceName = resolveWorkspaceName(params) ?: return - - suspend fun onConnect( - restClient: CoderRestClient, - cli: CoderCLIManager - ) { - val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) - if (workspace == null) { - context.envPageManager.showPluginEnvironmentsPage() - return - } - reInitialize(restClient, cli) - context.envPageManager.showPluginEnvironmentsPage() - if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + val workspace = restClient.workspaces().matchName(workspaceName, url) + if (workspace != null) { + if (!prepareWorkspace(workspace, restClient, cli, url)) 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) @@ -106,43 +66,8 @@ open class CoderProtocolHandler( if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { launchIde(environmentId, productCode, buildNumber, projectFolder) } - } - - CoderCliSetupContext.apply { - url = deploymentURL.toURL() - CoderCliSetupContext.token = token - } - CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) - context.ui.showUiPage( - CoderCliSetupWizardPage( - context, settingsPage, visibilityState, true, - jumpToMainPageOnError = true, - onConnect = ::onConnect - ) - ) - } - - private suspend fun resolveDeploymentUrl(params: Map): String? { - val deploymentURL = params.url() ?: askUrl() - if (deploymentURL.isNullOrBlank()) { - context.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 - } - 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? { @@ -154,30 +79,7 @@ open class CoderProtocolHandler( return workspace } - private suspend fun buildRestClient(deploymentURL: String, token: String?): CoderRestClient? { - try { - return authenticate(deploymentURL, token) - } catch (ex: Exception) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, humanizeConnectionError(deploymentURL.toURL(), true, ex)) - return null - } - } - - /** - * Returns an authenticated Coder CLI. - */ - private suspend fun authenticate(deploymentURL: String, token: String?): CoderRestClient { - val client = CoderRestClient( - context, - deploymentURL.toURL(), - token, - PluginManager.pluginInfo.version - ) - client.initializeSession() - return client - } - - private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { + private suspend fun List.matchName(workspaceName: String, deploymentURL: URL): Workspace? { val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { context.logAndShowError( @@ -192,15 +94,15 @@ open class CoderProtocolHandler( private suspend fun prepareWorkspace( workspace: Workspace, restClient: CoderRestClient, - workspaceName: String, - deploymentURL: String + cli: CoderCLIManager, + url: URL ): Boolean { when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> if (!restClient.waitForReady(workspace)) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be ready on time" + "${workspace.name} from $url could not be ready on time" ) return false } @@ -210,7 +112,7 @@ open class CoderProtocolHandler( if (settings.disableAutostart) { context.logAndShowWarning( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL is not running and autostart is disabled" + "${workspace.name} from $url is not running and autostart is disabled" ) return false } @@ -219,12 +121,12 @@ open class CoderProtocolHandler( if (workspace.outdated) { restClient.updateWorkspace(workspace) } else { - restClient.startWorkspace(workspace) + cli.startWorkspace(workspace.ownerName, workspace.name) } } catch (e: Exception) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started", + "${workspace.name} from $url could not be started", e ) return false @@ -233,7 +135,7 @@ open class CoderProtocolHandler( if (!restClient.waitForReady(workspace)) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$workspaceName from $deploymentURL could not be started on time", + "${workspace.name} from $url could not be started on time", ) return false } @@ -242,7 +144,7 @@ open class CoderProtocolHandler( WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "Unable to connect to $workspaceName from $deploymentURL" + "Unable to connect to ${workspace.name} from $url" ) return false } @@ -277,7 +179,10 @@ open class CoderProtocolHandler( parameters: Map, workspace: Workspace, ): WorkspaceAgent? { - val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } + val agents = workspace.latestBuild.resources + .mapNotNull { it.agents } + .flatten() + if (agents.isEmpty()) { context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "The workspace \"${workspace.name}\" has no agents") return null @@ -326,29 +231,6 @@ open class CoderProtocolHandler( return true } - private suspend fun configureCli( - deploymentURL: String, - restClient: CoderRestClient, - progressReporter: (String) -> Unit - ): CoderCLIManager { - val cli = ensureCLI( - context, - deploymentURL.toURL(), - restClient.buildInfo().version, - progressReporter - ) - - // We only need to log in if we are using token-based auth. - if (restClient.token != null) { - context.logger.info("Authenticating Coder CLI...") - cli.login(restClient.token) - } - - context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.workspacesByAgents()) - return cli - } - private fun launchIde( environmentId: String, productCode: String, @@ -414,10 +296,7 @@ open class CoderProtocolHandler( 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" - ) + context.logger.info("$productCode-$buildNumber is not available, we've selected the latest $selectedIde") return selectedIde } return "$productCode-$buildNumber" @@ -467,19 +346,9 @@ open class CoderProtocolHandler( return false } } - - private suspend fun askUrl(): String? { - context.popupPluginMainPage() - return dialogUi.ask( - context.i18n.ptrl("Deployment URL"), - context.i18n.ptrl("Enter the full URL of your Coder deployment") - ) - } } 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) +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/util/TLS.kt b/src/main/kotlin/com/coder/toolbox/util/TLS.kt index 97a5df96..101370d2 100644 --- a/src/main/kotlin/com/coder/toolbox/util/TLS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/TLS.kt @@ -280,3 +280,77 @@ class MergedSystemTrustManger(private val otherTrustManager: X509TrustManager) : override fun getAcceptedIssuers(): Array = otherTrustManager.acceptedIssuers + systemTrustManager.acceptedIssuers } + +class ReloadableX509TrustManager( + private val caPath: String?, +) : X509TrustManager { + @Volatile + private var delegate: X509TrustManager = loadTrustManager() + + private fun loadTrustManager(): X509TrustManager { + val trustManagers = coderTrustManagers(caPath) + return trustManagers.first { it is X509TrustManager } as X509TrustManager + } + + fun reload() { + delegate = loadTrustManager() + } + + override fun checkClientTrusted(chain: Array?, authType: String?) { + delegate.checkClientTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array?, authType: String?) { + delegate.checkServerTrusted(chain, authType) + } + + override fun getAcceptedIssuers(): Array { + return delegate.acceptedIssuers + } +} + +class ReloadableSSLSocketFactory( + private val settings: ReadOnlyTLSSettings, +) : SSLSocketFactory() { + @Volatile + private var delegate: SSLSocketFactory = loadSocketFactory() + + private fun loadSocketFactory(): SSLSocketFactory { + return coderSocketFactory(settings) + } + + fun reload() { + delegate = loadSocketFactory() + } + + override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites + + override fun createSocket(): Socket = delegate.createSocket() + + override fun createSocket(s: Socket?, host: String?, port: Int, autoClose: Boolean): Socket = + delegate.createSocket(s, host, port, autoClose) + + override fun createSocket(host: String?, port: Int): Socket = delegate.createSocket(host, port) + + override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket = + delegate.createSocket(host, port, localHost, localPort) + + override fun createSocket(host: InetAddress?, port: Int): Socket = delegate.createSocket(host, port) + + override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int): Socket = + delegate.createSocket(address, port, localAddress, localPort) +} + +class ReloadableTlsContext( + settings: ReadOnlyTLSSettings +) { + val sslSocketFactory = ReloadableSSLSocketFactory(settings) + val trustManager = ReloadableX509TrustManager(settings.caPath) + + fun reload() { + sslSocketFactory.reload() + trustManager.reload() + } +} diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt index eca1179f..2c740241 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -18,6 +18,7 @@ class CoderCliSetupWizardPage( visibilityState: StateFlow, initialAutoSetup: Boolean = false, jumpToMainPageOnError: Boolean = false, + connectSynchronously: Boolean = false, onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, @@ -33,9 +34,10 @@ class CoderCliSetupWizardPage( private val connectStep = ConnectStep( context, shouldAutoLogin = shouldAutoSetup, - jumpToMainPageOnError, + jumpToMainPageOnError = jumpToMainPageOnError, + connectSynchronously = connectSynchronously, visibilityState, - this::displaySteps, + refreshWizard = this::displaySteps, onConnect ) private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index a7ad70f0..29a1e15d 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -34,6 +34,8 @@ abstract class CoderPage( } } + override val isBusy: MutableStateFlow = MutableStateFlow(false) + /** * Return the icon, if showing one. * diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 5d5f1159..b74b2d8c 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -28,7 +28,11 @@ 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(private val context: CoderToolboxContext, triggerSshConfig: Channel) : +class CoderSettingsPage( + private val context: CoderToolboxContext, + triggerSshConfig: Channel, + private val onSettingsClosed: () -> Unit +) : CoderPage(MutableStateFlow(context.i18n.ptrl("Coder Settings")), false) { private val settings = context.settingsStore.readOnly() @@ -41,6 +45,8 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General) private val enableDownloadsField = CheckboxField(settings.enableDownloads, context.i18n.ptrl("Enable downloads")) + private val useAppNameField = + CheckboxField(settings.useAppNameAsTitle, context.i18n.ptrl("Use app name as main page title instead of URL")) private val disableSignatureVerificationField = CheckboxField( settings.disableSignatureVerification, @@ -95,6 +101,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf listOf( binarySourceField, enableDownloadsField, + useAppNameField, binaryDirectoryField, enableBinaryDirectoryFallbackField, disableSignatureVerificationField, @@ -121,6 +128,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) + context.settingsStore.updateUseAppNameAsTitle(useAppNameField.checkedState.value) context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) context.settingsStore.updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) @@ -164,6 +172,9 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf enableDownloadsField.checkedState.update { settings.enableDownloads } + useAppNameField.checkedState.update { + settings.useAppNameAsTitle + } signatureFallbackStrategyField.checkedState.update { settings.fallbackOnCoderForSignatures.isAllowed() } @@ -225,5 +236,6 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf override fun afterHide() { visibilityUpdateJob.cancel() + onSettingsClosed() } } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 7798328f..3c1c8ef9 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -13,10 +13,12 @@ import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" @@ -28,6 +30,7 @@ class ConnectStep( private val context: CoderToolboxContext, private val shouldAutoLogin: StateFlow, private val jumpToMainPageOnError: Boolean, + private val connectSynchronously: Boolean, visibilityState: StateFlow, private val refreshWizard: () -> Unit, private val onConnect: suspend (client: CoderRestClient, cli: CoderCLIManager) -> Unit, @@ -49,14 +52,14 @@ class ConnectStep( context.i18n.pnotr("") } - if (context.settingsStore.requireTokenAuth && CoderCliSetupContext.isNotReadyForAuth()) { + if (context.settingsStore.requiresTokenAuth && 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}...") } + statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderCliSetupContext.url?.host ?: "unknown host"}...") } connect() } @@ -64,26 +67,31 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - if (!CoderCliSetupContext.hasUrl()) { + val url = CoderCliSetupContext.url + if (url == null) { errorField.textState.update { context.i18n.ptrl("URL is required") } return } - if (context.settingsStore.requireTokenAuth && !CoderCliSetupContext.hasToken()) { + if (context.settingsStore.requiresTokenAuth && !CoderCliSetupContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } + // Capture the host name early for error reporting - val hostName = CoderCliSetupContext.url!!.host + val hostName = url.host + // Cancel previous job regardless of the new mode signInJob?.cancel() - signInJob = context.cs.launch(CoroutineName("Http and CLI Setup")) { + + // 1. Extract the logic into a reusable suspend lambda + val connectionLogic: suspend CoroutineScope.() -> Unit = { try { context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( context, - CoderCliSetupContext.url!!, - if (context.settingsStore.requireTokenAuth) CoderCliSetupContext.token else null, + url, + if (context.settingsStore.requiresTokenAuth) CoderCliSetupContext.token else null, PluginManager.pluginInfo.version, ) // allows interleaving with the back/cancel action @@ -97,7 +105,7 @@ class ConnectStep( statusField.textState.update { (context.i18n.pnotr(progress)) } } // We only need to log in if we are using token-based auth. - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { logAndReportProgress("Configuring Coder CLI...") // allows interleaving with the back/cancel action yield() @@ -124,6 +132,17 @@ class ConnectStep( refreshWizard() } } + + // 2. Choose the execution strategy based on the flag + if (connectSynchronously) { + // Blocks the current thread until connectionLogic completes + runBlocking(CoroutineName("Synchronous Http and CLI Setup")) { + connectionLogic() + } + } else { + // Runs asynchronously using the context's scope + signInJob = context.cs.launch(CoroutineName("Async Http and CLI Setup"), block = connectionLogic) + } } private fun logAndReportProgress(msg: String) { @@ -143,7 +162,7 @@ class ConnectStep( CoderCliSetupWizardState.goToFirstStep() } } else { - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { 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 index 27e53f97..b4a60668 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -86,7 +86,7 @@ class DeploymentUrlStep( errorReporter.report("URL is invalid", e) return false } - if (context.settingsStore.requireTokenAuth) { + if (context.settingsStore.requiresTokenAuth) { CoderCliSetupWizardState.goToNextStep() } else { CoderCliSetupWizardState.goToLastStep() diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 29351e30..16b6ed5a 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -189,3 +189,6 @@ msgstr "" msgid "Workspace name" msgstr "" + +msgid "Use app name as main page title instead of URL" +msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 7f5c831f..74caf65c 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -976,8 +976,24 @@ internal class CoderCLIManagerTest { val tests = listOf( Pair("2.5.0", Features(true)), - Pair("2.13.0", Features(true, true)), - Pair("4.9.0", Features(true, true, true)), + Pair("2.13.0", Features(disableAutostart = true, reportWorkspaceUsage = true)), + Pair( + "2.25.0", + Features( + disableAutostart = true, + reportWorkspaceUsage = true, + wildcardSsh = true, + buildReason = true + ) + ), + Pair( + "4.9.0", Features( + disableAutostart = true, + reportWorkspaceUsage = true, + wildcardSsh = true, + buildReason = true + ) + ), Pair("2.4.9", Features(false)), Pair("1.0.1", Features(false)), ) diff --git a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt index 6d23c57b..bd8762d8 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/DataGen.kt @@ -12,6 +12,7 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS import java.util.UUID +import kotlin.random.Random class DataGen { companion object { @@ -20,19 +21,19 @@ class DataGen { agentId: String, ): WorkspaceResource = WorkspaceResource( agents = - listOf( - WorkspaceAgent( - id = UUID.fromString(agentId), - status = WorkspaceAgentStatus.CONNECTED, - name = agentName, - architecture = Arch.from("amd64"), - operatingSystem = OS.from("linux"), - directory = null, - expandedDirectory = null, - lifecycleState = WorkspaceAgentLifecycleState.READY, - loginBeforeReady = false, + listOf( + WorkspaceAgent( + id = UUID.fromString(agentId), + status = WorkspaceAgentStatus.CONNECTED, + name = agentName, + architecture = Arch.from("amd64"), + operatingSystem = OS.from("linux"), + directory = null, + expandedDirectory = null, + lifecycleState = WorkspaceAgentLifecycleState.READY, + loginBeforeReady = false, + ), ), - ), ) fun workspace( @@ -48,9 +49,9 @@ class DataGen { templateDisplayName = "template-display-name", templateIcon = "template-icon", latestBuild = - build( - resources = agents.map { resource(it.key, it.value) }, - ), + build( + resources = agents.map { resource(it.key, it.value) }, + ), outdated = false, name = name, ownerName = "owner", @@ -61,6 +62,8 @@ class DataGen { templateVersionID: UUID = UUID.randomUUID(), resources: List = emptyList(), ): WorkspaceBuild = WorkspaceBuild( + id = UUID.randomUUID(), + buildNumber = Random.nextInt(), templateVersionID = templateVersionID, resources = resources, status = WorkspaceStatus.RUNNING, diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index 50334876..9d38c4fe 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -261,19 +261,19 @@ internal class CoderSettingsTest { @Test fun testRequireTokenAuth() { var settings = CoderSettingsStore(pluginTestSettingsStore(), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore(pluginTestSettingsStore(TLS_CERT_PATH to "cert path"), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore(pluginTestSettingsStore(TLS_KEY_PATH to "key path"), Environment(), logger) - assertEquals(true, settings.readOnly().requireTokenAuth) + assertEquals(true, settings.readOnly().requiresTokenAuth) settings = CoderSettingsStore( pluginTestSettingsStore(TLS_CERT_PATH to "cert path", TLS_KEY_PATH to "key path"), Environment(), logger ) - assertEquals(false, settings.readOnly().requireTokenAuth) + assertEquals(false, settings.readOnly().requiresTokenAuth) } @Test diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 56402e52..326fce04 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -5,11 +5,9 @@ import com.coder.toolbox.sdk.DataGen import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore -import com.coder.toolbox.views.CoderSettingsPage import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory -import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings @@ -18,16 +16,28 @@ import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import io.mockk.mockk import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.DisplayName import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull internal class CoderProtocolHandlerTest { + + private companion object { + val AGENT_RIKER = AgentTestData(name = "Riker", id = "9a920eee-47fb-4571-9501-e4b3120c12f2") + val AGENT_BILL = AgentTestData(name = "Bill", id = "fb3daea4-da6b-424d-84c7-36b90574cfef") + val AGENT_BOB = AgentTestData(name = "Bob", id = "b0e4c54d-9ba9-4413-8512-11ca1e826a24") + + val ALL_AGENTS = mapOf( + AGENT_BOB.name to AGENT_BOB.id, + AGENT_BILL.name to AGENT_BILL.id, + AGENT_RIKER.name to AGENT_RIKER.id + ) + + val SINGLE_AGENT = mapOf(AGENT_BOB.name to AGENT_BOB.id) + } + private val context = CoderToolboxContext( mockk(relaxed = true), mockk(), @@ -44,135 +54,174 @@ internal class CoderProtocolHandlerTest { ) private val protocolHandler = CoderProtocolHandler( - context, - DialogUi(context), - CoderSettingsPage(context, Channel(Channel.CONFLATED)), - MutableStateFlow(ProviderVisibilityState(applicationVisible = true, providerVisible = true)), - MutableStateFlow(false) + context ) - 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" - ) + fun `given a workspace with multiple agents when getMatchingAgent is called with a valid agent name then it correctly resolves resolves an agent`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentMatchTestCase( + "resolves agent with name Riker", + mapOf("agent_name" to AGENT_RIKER.name), + AGENT_RIKER.uuid + ), + AgentMatchTestCase( + "resolves agent with name Bill", + mapOf("agent_name" to AGENT_BILL.name), + AGENT_BILL.uuid + ), + AgentMatchTestCase( + "resolves agent with name Bob", + mapOf("agent_name" to AGENT_BOB.name), + AGENT_BOB.uuid ) + ) + runBlocking { - tests.forEach { - assertEquals(UUID.fromString(it.second), protocolHandler.getMatchingAgent(it.first, ws)?.id) + testCases.forEach { testCase -> + assertEquals( + testCase.expectedAgentId, + protocolHandler.getMatchingAgent(testCase.params, ws)?.id, + "Failed: ${testCase.description}" + ) } } } @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" - ) + fun `given a workspace with multiple agents when getMatchingAgent is called with invalid agent names then no agent is resolved`() { + val ws = DataGen.workspace("ws", agents = ALL_AGENTS) + + val testCases = listOf( + AgentNullResultTestCase( + "empty parameters (i.e. no agent name) does not return any agent", + emptyMap() + ), + AgentNullResultTestCase( + "empty agent_name does not return any agent", + mapOf("agent_name" to "") + ), + AgentNullResultTestCase( + "null agent_name does not return any agent", + mapOf("agent_name" to null) + ), + AgentNullResultTestCase( + "non-existent agent does not return any agent", + mapOf("agent_name" to "agent_name_homer") + ), + AgentNullResultTestCase( + "UUID instead of name does not return any agent", + mapOf("agent_name" to "not-an-agent-name") ) + ) + runBlocking { - tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + testCases.forEach { testCase -> + assertNull( + protocolHandler.getMatchingAgent(testCase.params, ws)?.id, + "Failed: ${testCase.description}" + ) } } } @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( + fun `given a workspace with a single agent when getMatchingAgent is called with an empty agent name then the default agent is resolved`() { + val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) + + val testCases = listOf( + AgentMatchTestCase( + "empty parameters (i.e. no agent name) auto-selects the one and only agent available", emptyMap(), + AGENT_BOB.uuid + ), + AgentMatchTestCase( + "empty agent_name auto-selects the one and only agent available", mapOf("agent_name" to ""), - mapOf("agent_name" to null) + AGENT_BOB.uuid + ), + AgentMatchTestCase( + "null agent_name auto-selects the one and only agent available", + mapOf("agent_name" to null), + AGENT_BOB.uuid ) + ) + runBlocking { - tests.forEach { + testCases.forEach { testCase -> assertEquals( - UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - protocolHandler.getMatchingAgent( - it, - ws, - )?.id, + testCase.expectedAgentId, + protocolHandler.getMatchingAgent(testCase.params, ws)?.id, + "Failed: ${testCase.description}" ) } } } @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" - ), - ) + fun `given a workspace with a single agent when getMatchingAgent is called with an invalid agent name then no agent is resolved`() { + val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) + + val testCase = AgentNullResultTestCase( + "non-matching agent_name with single agent", + mapOf("agent_name" to "agent_name_garfield") + ) + runBlocking { - tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, wsWithAgentBob)) - } + assertNull( + protocolHandler.getMatchingAgent(testCase.params, ws), + "Failed: ${testCase.description}" + ) } } @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" - ), + fun `given a workspace with no agent when getMatchingAgent is called then no agent is resolved`() { + val ws = DataGen.workspace("ws") + + val testCases = listOf( + AgentNullResultTestCase( + "empty parameters (i.e. no agent name) does not return any agent", + emptyMap() + ), + AgentNullResultTestCase( + "empty agent_name does not return any agent", + mapOf("agent_name" to "") + ), + AgentNullResultTestCase( + "null agent_name does not return any agent", + mapOf("agent_name" to null) + ), + AgentNullResultTestCase( + "valid agent_name does not return any agent", + mapOf("agent_name" to AGENT_RIKER.name) ) + ) + runBlocking { - tests.forEach { - assertNull(protocolHandler.getMatchingAgent(it.first, wsWithoutAgents)) + testCases.forEach { testCase -> + assertNull( + protocolHandler.getMatchingAgent(testCase.params, ws), + "Failed: ${testCase.description}" + ) } } } -} + + internal data class AgentTestData(val name: String, val id: String) { + val uuid: UUID get() = UUID.fromString(id) + } + + internal data class AgentMatchTestCase( + val description: String, + val params: Map, + val expectedAgentId: UUID + ) + + internal data class AgentNullResultTestCase( + val description: String, + val params: Map + ) +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index af1b4efd..eebd4247 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -9,21 +9,21 @@ internal class URLExtensionsTest { @Test fun testToURL() { assertEquals( - URL("https", "localhost", 8080, "/path"), - "https://localhost:8080/path".toURL(), + expected = URI.create("https://localhost:8080/path").toURL(), + actual = "https://localhost:8080/path".toURL(), ) } @Test fun testWithPath() { assertEquals( - URL("https", "localhost", 8080, "/foo/bar"), - URL("https", "localhost", 8080, "/").withPath("/foo/bar"), + expected = "https://localhost:8080/foo/bar".toURL(), + actual = "https://localhost:8080/".toURL().withPath("/foo/bar"), ) assertEquals( - URL("https", "localhost", 8080, "/foo/bar"), - URL("https", "localhost", 8080, "/old/path").withPath("/foo/bar"), + expected = "https://localhost:8080/foo/bar".toURL(), + actual = "https://localhost:8080/old/path".toURL().withPath("/foo/bar"), ) }