From 6cb6789418c13d339efea13d111557daa970ae9d Mon Sep 17 00:00:00 2001 From: Artem Zatsarynnyi Date: Thu, 30 Apr 2026 19:06:23 +0200 Subject: [PATCH 1/8] Support the 'from Toolbox' connection flow; auto-port-fwd Signed-off-by: Artem Zatsarynnyi --- CLAUDE.md | 49 +++++++++++++++++++ .../toolbox/openshift/DevWorkspace.kt | 41 ++++++++++++++++ .../toolbox/openshift/DevWorkspaces.kt | 49 +++++++++++++++++++ .../openshift/OpenShiftClientFactory.kt | 2 + .../devtools/toolbox/openshift/Projects.kt | 22 +++++++++ 5 files changed, 163 insertions(+) create mode 100644 CLAUDE.md create mode 100644 plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt create mode 100644 plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt create mode 100644 plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/OpenShiftClientFactory.kt create mode 100644 plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/Projects.kt diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dfda569 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +JetBrains Toolbox plugin that enables local IntelliJ IDEs to connect to Red Hat OpenShift Dev Spaces (Eclipse Che) workspaces via SSH. + +## Build & Install Commands + +```bash +# Build the plugin JAR +./gradlew build + +# Build and install to JetBrains Toolbox +./gradlew installPlugin + +# Clean build artifacts +./gradlew clean +``` + +After installing, restart the Toolbox App to load the plugin. + +## Architecture + +``` +DataSource → EnvironmentConfig → Repository → RemoteEnvironment → Provider → Toolbox UI +``` + +**Key components in `plugin/src/main/kotlin/com/redhat/devtools/toolbox/`:** + +- **DevSpacesRemoteDevExtension** (`DevSpacesPlugin.kt`): Entry point implementing `RemoteDevExtension`. Creates the provider and repository, wires up the data source. + +- **DevSpacesRemoteProvider** (`DevSpacesRemoteProvider.kt`): `RemoteProvider` implementation. Handles URI callbacks (`jetbrains://gateway/com.redhat.devtools.toolbox?...`) from Dev Spaces Dashboard, triggering environment refresh and connection requests. + +- **EnvironmentRepository** (`EnvironmentRepository.kt`): Manages environment lifecycle - fetches configs from data source, caches `RemoteEnvironment` instances, exposes reactive state via `MutableStateFlow`. + +- **EnvironmentDataSource** (`datasource/`): Interface for fetching environment configs. `DevWorkspacesDataSource` is the real implementation. + +- **DevSpacesRemoteEnvironment** (`environment/`): Wraps `EnvironmentConfig` with Toolbox API reactive properties (state, description, connection requests). + +- **SshEnvironmentContentsViewFactory** (`environment/`): Creates SSH connection views. Current limitation: hardcoded local port 2022, supports only one active connection. + +## Technical Notes + +- The `group` value in `plugin/build.gradle.kts` (`com.redhat.devtools.toolbox`) is the provider ID used in URI handling. +- Plugin requires Java 21 (configured via `jvmToolchain(21)`). +- Uses JetBrains Toolbox Plugin API version defined in `gradle/libs.versions.toml`. +- Periodic environment polling is currently disabled to preserve environments received via external URLs (see TODO in `EnvironmentRepository.kt:61-67`). \ No newline at end of file diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt new file mode 100644 index 0000000..5703653 --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.toolbox.openshift + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource + +data class DevWorkspace( + val namespace: String, + val name: String, + val uid: String, + val started: Boolean, + val phase: String +) { + val running: Boolean + get() = phase == "Running" + + companion object { + fun from(resource: GenericKubernetesResource): DevWorkspace { + val metadata = resource.metadata + val spec = resource.additionalProperties["spec"] as? Map<*, *> ?: emptyMap() + val status = resource.additionalProperties["status"] as? Map<*, *> ?: emptyMap() + + return DevWorkspace( + namespace = metadata.namespace ?: "", + name = metadata.name ?: "", + uid = metadata.uid ?: "", + started = spec["started"] as? Boolean ?: false, + phase = status["phase"] as? String ?: "" + ) + } + } +} \ No newline at end of file diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt new file mode 100644 index 0000000..4a6133b --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.toolbox.openshift + +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.fabric8.kubernetes.api.model.GenericKubernetesResource +import io.fabric8.kubernetes.client.KubernetesClient +import io.fabric8.kubernetes.client.KubernetesClientException +import io.fabric8.kubernetes.client.dsl.base.CustomResourceDefinitionContext + +class DevWorkspaces(private val client: KubernetesClient, private val logger: Logger? = null) { + + private val devWorkspaceContext = CustomResourceDefinitionContext.Builder() + .withGroup("workspace.devfile.io") + .withVersion("v1alpha2") + .withPlural("devworkspaces") + .withScope("Namespaced") + .build() + + fun list(namespace: String): List { + return try { + val resources = client.genericKubernetesResources(devWorkspaceContext) + .inNamespace(namespace) + .list() + .items + + resources.map { resource -> DevWorkspace.from(resource) } + } catch (e: KubernetesClientException) { + logger?.info("API error listing DevWorkspaces in namespace $namespace: ${e.message}") + + when (e.code) { + 403, 404 -> emptyList() + else -> { + logger?.error("Kubernetes API error ${e.code}: ${e.message}") + throw e + } + } + } + } +} \ No newline at end of file diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/OpenShiftClientFactory.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/OpenShiftClientFactory.kt new file mode 100644 index 0000000..1fb8381 --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/OpenShiftClientFactory.kt @@ -0,0 +1,2 @@ +package com.redhat.devtools.toolbox.openshift + diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/Projects.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/Projects.kt new file mode 100644 index 0000000..93d004c --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/Projects.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.toolbox.openshift + +import io.fabric8.openshift.api.model.Project +import io.fabric8.openshift.client.OpenShiftClient + +class Projects(private val client: OpenShiftClient) { + + fun list(): List { + return client.projects().list().items + } +} \ No newline at end of file From 964aabcc5a4d8809a917fd41271264aca9b4bfd6 Mon Sep 17 00:00:00 2001 From: Artem Zatsarynnyi Date: Thu, 30 Apr 2026 19:06:49 +0200 Subject: [PATCH 2/8] Support the 'from Toolbox' connection flow; auto-port-fwd Signed-off-by: Artem Zatsarynnyi --- .../buildlogic/InstallToolboxPlugin.kt | 7 ++ plugin/build.gradle.kts | 10 ++- .../devtools/toolbox/DevSpacesPlugin.kt | 13 ++-- .../toolbox/DevSpacesRemoteProvider.kt | 2 +- .../devtools/toolbox/EnvironmentRepository.kt | 6 +- .../datasource/DevWorkspacesDataSource.kt | 40 +++++++++-- .../environment/DevSpacesRemoteEnvironment.kt | 70 +++++++++++++++++-- .../EnvironmentContentsViewFactory.kt | 2 +- .../SshEnvironmentContentsViewFactory.kt | 21 ++++-- .../openshift/OpenShiftClientFactory.kt | 19 ++++- 10 files changed, 163 insertions(+), 27 deletions(-) diff --git a/build-logic/src/main/kotlin/toolbox/buildlogic/InstallToolboxPlugin.kt b/build-logic/src/main/kotlin/toolbox/buildlogic/InstallToolboxPlugin.kt index 84ac0dc..59851e8 100644 --- a/build-logic/src/main/kotlin/toolbox/buildlogic/InstallToolboxPlugin.kt +++ b/build-logic/src/main/kotlin/toolbox/buildlogic/InstallToolboxPlugin.kt @@ -14,8 +14,10 @@ package com.redhat.devtools.toolbox.buildlogic import org.gradle.api.DefaultTask import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property +import org.gradle.api.tasks.Classpath import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.TaskAction @@ -29,6 +31,7 @@ class InstallToolboxPlugin : Plugin { val installTask = target.tasks.register("installPlugin", InstallTask::class.java) { extensionId.set(target.group.toString()) extensionJsonFile.set(target.layout.buildDirectory.file("generated/extension.json")) + pluginRuntimeDependencies.from(target.configurations.named("runtimeClasspath")) } installTask.configure { dependsOn(target.tasks.named("assemble")) } } @@ -41,6 +44,9 @@ class InstallToolboxPlugin : Plugin { @get:InputFile abstract val extensionJsonFile: RegularFileProperty + @get:Classpath + abstract val pluginRuntimeDependencies: ConfigurableFileCollection + @TaskAction fun install() { println("Installing Toolbox plugin...") @@ -65,6 +71,7 @@ class InstallToolboxPlugin : Plugin { // Copy jar task output and the generated JSON from(project.tasks.getByName("jar")) from(extensionJsonFile) + from(pluginRuntimeDependencies) // Copy selected resources from("src/main/resources") { diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index e3307af..f1dafe1 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -1,6 +1,6 @@ plugins { alias(libs.plugins.kotlin.jvm) - `kotlin-dsl` +// `kotlin-dsl` id("com.redhat.devtools.toolbox.packaging") id("com.redhat.devtools.toolbox.install") `java-library` @@ -11,6 +11,11 @@ plugins { group = "com.redhat.devtools.toolbox" version = "0.0.1" +configurations.named("runtimeClasspath") { + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") +} + kotlin { jvmToolchain(21) } @@ -18,6 +23,9 @@ kotlin { dependencies { compileOnly(libs.bundles.toolbox.plugin.api) compileOnly(libs.coroutines.core) + implementation("io.fabric8:openshift-client:7.2.0") { + exclude(group = "org.slf4j") + } } // Known issue with kotlin 2.1.0 when using MutableStateFlow, please remove diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesPlugin.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesPlugin.kt index f73c7e3..6ef0067 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesPlugin.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesPlugin.kt @@ -19,6 +19,7 @@ import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import kotlinx.coroutines.CoroutineScope import com.redhat.devtools.toolbox.datasource.EnvironmentDataSource import com.redhat.devtools.toolbox.datasource.DevWorkspacesDataSource +import com.redhat.devtools.toolbox.openshift.OpenShiftClientFactory /** * Extends Toolbox remote development subsystem with @@ -38,15 +39,18 @@ class DevSpacesRemoteDevExtension : RemoteDevExtension { val coroutineScope = serviceLocator.getService(CoroutineScope::class.java) val localizableStringFactory = serviceLocator.getService(LocalizableStringFactory::class.java) + val clientFactory = OpenShiftClientFactory(logger) + // Single data source, swap implementation as needed - val dataSource = createDataSource(logger) + val dataSource = createDataSource(logger, clientFactory) // Initialized and manages the remote environments val repository = EnvironmentRepository( dataSource = dataSource, coroutineScope = coroutineScope, logger = logger, - localizableStringFactory = localizableStringFactory + localizableStringFactory = localizableStringFactory, + clientFactory = clientFactory ) // Periodically refresh environments from the data source @@ -56,8 +60,7 @@ class DevSpacesRemoteDevExtension : RemoteDevExtension { return DevSpacesRemoteProvider(repository, logger) } - private fun createDataSource(logger: Logger): EnvironmentDataSource { - // data source creation logic - return DevWorkspacesDataSource(logger = logger) + private fun createDataSource(logger: Logger, clientFactory: OpenShiftClientFactory): EnvironmentDataSource { + return DevWorkspacesDataSource(clientFactory, logger) } } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt index 2e663e2..e70612d 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt @@ -26,7 +26,7 @@ import java.net.URI */ class DevSpacesRemoteProvider( val repository: EnvironmentRepository, val logger: Logger -) : RemoteProvider("Red Hat OpenShift Dev Spaces") { +) : RemoteProvider("Dev Spaces") { override val svgIcon: SvgIcon = SvgIcon( this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf(), diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt index 9173c46..7281105 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt @@ -18,6 +18,7 @@ import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState import com.redhat.devtools.toolbox.datasource.DataSourceException import com.redhat.devtools.toolbox.datasource.EnvironmentDataSource import com.redhat.devtools.toolbox.environment.* +import com.redhat.devtools.toolbox.openshift.OpenShiftClientFactory import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlin.coroutines.cancellation.CancellationException @@ -39,7 +40,8 @@ class EnvironmentRepository( private val coroutineScope: CoroutineScope, private val contentsViewFactory: EnvironmentContentsViewFactory = SshEnvironmentContentsViewFactory(), private val refreshInterval: Duration = 10.minutes, - private val localizableStringFactory: LocalizableStringFactory + private val localizableStringFactory: LocalizableStringFactory, + private val clientFactory: OpenShiftClientFactory ) { // Internal mutable state private val _environments = MutableStateFlow>>( @@ -106,7 +108,7 @@ class EnvironmentRepository( private fun getOrCreateEnvironment(config: EnvironmentConfig): DevSpacesRemoteEnvironment { return environmentCache.getOrPut(config.id) { logger.debug("Creating new environment: ${config.id}") - config.toRemoteEnvironment(contentsViewFactory, localizableStringFactory, logger) + config.toRemoteEnvironment(contentsViewFactory, localizableStringFactory, logger, clientFactory) }.also { existingEnv -> // Update config if it is changed if (existingEnv.getConfig() != config) { diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt index fb71d5e..bca2f3e 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt @@ -12,23 +12,53 @@ package com.redhat.devtools.toolbox.datasource import com.jetbrains.toolbox.api.core.diagnostics.Logger -import kotlinx.coroutines.flow.MutableStateFlow import com.redhat.devtools.toolbox.environment.EnvironmentConfig +import com.redhat.devtools.toolbox.openshift.OpenShiftClientFactory +import com.redhat.devtools.toolbox.openshift.DevWorkspaces +import com.redhat.devtools.toolbox.openshift.Projects +import kotlinx.coroutines.flow.MutableStateFlow /** * Data source returns the environment configurations from * the DevWorkspaces fetched from a Dev Spaces instance. */ class DevWorkspacesDataSource( - logger: Logger + private val clientFactory: OpenShiftClientFactory, + private val logger: Logger ) : EnvironmentDataSource { /** * Fetches the CDEs from the currently logged-in Dev Spaces instance. */ override suspend fun fetchEnvironments(): List { -// TODO("Not yet implemented") - return emptyList() + return try { + clientFactory.create().use { client -> + val projects = Projects(client).list() + + projects + .mapNotNull { it.metadata?.name } + .flatMap { namespace -> + DevWorkspaces(client, logger).list(namespace) + } + .map { workspace -> + // TODO: figure out how to fetch the connection data + EnvironmentConfig( + id = workspace.uid, + name = MutableStateFlow(workspace.name), + description = "[API] DevWorkspace", + username = "1001270000", + port = 2022, + sshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + "-----END OPENSSH PRIVATE KEY-----\n", + availableIdeProductCodes = listOf("IU"), + projectPaths = listOf("/projects") + ) + } + } + } catch (e: Exception) { + logger.error("Failed to fetch environments: ${e.message}") + emptyList() + } } override fun handleExternalRequest( @@ -44,4 +74,4 @@ class DevWorkspacesDataSource( projectPaths = projects ) } -} +} \ No newline at end of file diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt index 99b4147..51ec6f7 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt @@ -13,12 +13,17 @@ package com.redhat.devtools.toolbox.environment import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.AfterDisconnectHook +import com.jetbrains.toolbox.api.remoteDev.BeforeConnectionHook import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState +import com.redhat.devtools.toolbox.openshift.OpenShiftClientFactory +import io.fabric8.kubernetes.client.LocalPortForward +import io.fabric8.openshift.client.OpenShiftClient import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -30,7 +35,8 @@ class DevSpacesRemoteEnvironment( private val initialConfig: EnvironmentConfig, private val contentsViewFactory: EnvironmentContentsViewFactory = SshEnvironmentContentsViewFactory(), private val localizableStringFactory: LocalizableStringFactory, - private val logger: Logger + private val logger: Logger, + private val clientFactory: OpenShiftClientFactory ) : RemoteProviderEnvironment(initialConfig.id) { // Mutable internal state @@ -43,6 +49,10 @@ class DevSpacesRemoteEnvironment( private val _connectionRequest = MutableStateFlow(false) + // Port forwarding resources (kept alive during connection) + private var activeClient: OpenShiftClient? = null + private var activePortForward: LocalPortForward? = null + // Public reactive properties (observed by Toolbox UI) override var nameFlow: MutableStateFlow = _currentConfig.name @@ -54,7 +64,7 @@ class DevSpacesRemoteEnvironment( override suspend fun getContentsView(): EnvironmentContentsView { logger.debug("PLUGIN: getContentsView called for id='${initialConfig.id}', name='${_currentConfig.name}'") - val view = contentsViewFactory.create(_currentConfig) + val view = contentsViewFactory.create(_currentConfig) { activePortForward?.localPort ?: 2022 } logger.debug("PLUGIN: Created view with ${_currentConfig.availableIdeProductCodes.size} IDEs, ${_currentConfig.projectPaths.size} projects") return view } @@ -89,6 +99,57 @@ class DevSpacesRemoteEnvironment( } fun getConfig(): EnvironmentConfig = _currentConfig + + override fun getBeforeConnectionHooks(): List { + return listOf( + BeforeConnectionHook { + logger.info("Running before connection hook for environment: ${initialConfig.id}") + + // Close any previous port forward +// closePortForward() + + // Create new client and port forward (kept alive until closePortForward is called) + val client = clientFactory.create() + activeClient = client + + // TODO: figure out how to fetch the pod information + activePortForward = client.pods() + .inNamespace("azatsarynnyy-che") + .withName("workspacee64b5c5b8a6d4196-577c7ff96b-plbhx") + .portForward(_currentConfig.port, 0) + + logger.info("Port forward established: localhost:${activePortForward?.localPort} -> pod:${_currentConfig.port}") + } + ) + } + + override fun getAfterDisconnectHooks(): List { + return listOf( + object : AfterDisconnectHook { + override fun afterDisconnect(isManual: Boolean) { + logger.info("Running after disconnect hook for environment: ${initialConfig.id}") + closePortForward() + } + } + ) + } + + fun closePortForward() { + try { + activePortForward?.close() + activePortForward = null + logger.info("Port forward closed") + } catch (e: Exception) { + logger.debug("Error closing port forward: ${e.message}") + } + + try { + activeClient?.close() + activeClient = null + } catch (e: Exception) { + logger.debug("Error closing client: ${e.message}") + } + } } /** @@ -97,7 +158,8 @@ class DevSpacesRemoteEnvironment( fun EnvironmentConfig.toRemoteEnvironment( factory: EnvironmentContentsViewFactory = SshEnvironmentContentsViewFactory(), localizableStringFactory: LocalizableStringFactory, - logger: Logger + logger: Logger, + clientFactory: OpenShiftClientFactory ): DevSpacesRemoteEnvironment { - return DevSpacesRemoteEnvironment(this, factory, localizableStringFactory, logger) + return DevSpacesRemoteEnvironment(this, factory, localizableStringFactory, logger, clientFactory) } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt index f0fd8c5..a831d51 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt @@ -30,5 +30,5 @@ fun interface EnvironmentContentsViewFactory { * @param config The environment's configuration data * @return An EnvironmentContentsView implementation */ - suspend fun create(config: EnvironmentConfig): EnvironmentContentsView + suspend fun create(config: EnvironmentConfig, portProvider: () -> Int): EnvironmentContentsView } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt index bd1da84..0d361a5 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { - override suspend fun create(config: EnvironmentConfig): EnvironmentContentsView { + override suspend fun create(config: EnvironmentConfig, portProvider: () -> Int): EnvironmentContentsView { val ides = config.availableIdeProductCodes.map { productCode -> SimpleIdeStub(productCode) } @@ -32,7 +32,7 @@ class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { CachedProject(path) } - return SimpleEnvironmentContentsView(ides, projects, config.username!!, config.sshKey!!) + return SimpleEnvironmentContentsView(ides, projects, config.username!!, config.sshKey!!, portProvider) } } @@ -43,7 +43,8 @@ class SimpleEnvironmentContentsView( ides: List, projects: List, val userName: String, - val sshKey: String + val sshKey: String, + private val portProvider: () -> Int ) : ManualEnvironmentContentsView, SshEnvironmentContentsView { // Expose as immutable flows - data is set once at construction @@ -53,7 +54,7 @@ class SimpleEnvironmentContentsView( override val projectListState: Flow>> = MutableStateFlow(LoadableState.Value(projects)) - override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(userName, sshKey) + override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(userName, sshKey, portProvider) } data class SimpleIdeStub( @@ -63,12 +64,18 @@ data class SimpleIdeStub( override fun isRunning(): Boolean? = running } -private class WorkspaceSshConnectionInfo(val uName: String, val sshKey: String) : SshConnectionInfo { +private class WorkspaceSshConnectionInfo( + val uName: String, + val sshKey: String, + private val portProvider: () -> Int +) : SshConnectionInfo { override val host: String = "devspaces" - // TODO: constant local port means we only support one active connection - override val port: Int = 2022 + // TODO: the port value is read on first call only. + // But it's not re-read on the subsequent calls. + // Need to re-read it on each connection attempts as the port is chosen dynamically. + override val port: Int get() = portProvider() override val userName: String = uName diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/OpenShiftClientFactory.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/OpenShiftClientFactory.kt index 1fb8381..33882d5 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/OpenShiftClientFactory.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/OpenShiftClientFactory.kt @@ -1,2 +1,19 @@ -package com.redhat.devtools.toolbox.openshift +package com.redhat.devtools.toolbox.openshift +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.fabric8.kubernetes.client.KubernetesClientBuilder +import io.fabric8.openshift.client.OpenShiftClient + +class OpenShiftClientFactory( + private val logger: Logger +) { + + fun create(): OpenShiftClient { + return try { + KubernetesClientBuilder().build().adapt(OpenShiftClient::class.java) + } catch (e: Exception) { + logger.debug("Failed to build OpenShift client: ${e.message}") + throw e + } + } +} \ No newline at end of file From 4117153fb92ccf9ea40a99249a213653460d7fbb Mon Sep 17 00:00:00 2001 From: Artem Zatsarynnyi Date: Thu, 7 May 2026 18:45:49 +0200 Subject: [PATCH 3/8] multi-connection mode Signed-off-by: Artem Zatsarynnyi --- plugin/build.gradle.kts | 2 +- .../devtools/toolbox/DevSpacesPlugin.kt | 2 +- .../toolbox/DevSpacesRemoteProvider.kt | 19 ++++-- .../devtools/toolbox/EnvironmentRepository.kt | 15 ++--- .../datasource/DevWorkspacesDataSource.kt | 7 +- .../environment/DevSpacesRemoteEnvironment.kt | 64 ++++++++++++++++--- .../EnvironmentContentsViewFactory.kt | 6 +- .../toolbox/environment/PortAllocator.kt | 37 +++++++++++ .../SshEnvironmentContentsViewFactory.kt | 49 +++++++------- .../toolbox/environment/SshKeyStore.kt | 36 +++++++++++ 10 files changed, 180 insertions(+), 57 deletions(-) create mode 100644 plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/PortAllocator.kt create mode 100644 plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshKeyStore.kt diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index f1dafe1..141af95 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -23,7 +23,7 @@ kotlin { dependencies { compileOnly(libs.bundles.toolbox.plugin.api) compileOnly(libs.coroutines.core) - implementation("io.fabric8:openshift-client:7.2.0") { + implementation("io.fabric8:openshift-client:7.6.1") { exclude(group = "org.slf4j") } } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesPlugin.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesPlugin.kt index 6ef0067..0425578 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesPlugin.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesPlugin.kt @@ -57,7 +57,7 @@ class DevSpacesRemoteDevExtension : RemoteDevExtension { repository.startPolling() logger.info("DevSpacesRemoteProvider initialized with ${dataSource::class.simpleName}") - return DevSpacesRemoteProvider(repository, logger) + return DevSpacesRemoteProvider(repository, localizableStringFactory, logger) } private fun createDataSource(logger: Logger, clientFactory: OpenShiftClientFactory): EnvironmentDataSource { diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt index e70612d..ccdc5d9 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt @@ -12,10 +12,15 @@ package com.redhat.devtools.toolbox import com.jetbrains.toolbox.api.core.diagnostics.Logger -import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.util.LoadableState +import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProvider +import com.jetbrains.toolbox.api.ui.components.UiPage +import com.jetbrains.toolbox.platform.image.ImageResource +import com.jetbrains.toolbox.platform.image.image +import com.jetbrains.toolbox.platform.resource.jvm.jvmResourceReader import kotlinx.coroutines.flow.MutableStateFlow import com.redhat.devtools.toolbox.environment.DevSpacesRemoteEnvironment import com.redhat.devtools.toolbox.environment.EnvironmentConfig @@ -25,15 +30,13 @@ import java.net.URI * [RemoteProvider] implementation that delegates the environment management to [EnvironmentRepository]. */ class DevSpacesRemoteProvider( - val repository: EnvironmentRepository, val logger: Logger + val repository: EnvironmentRepository, val localizableStringFactory: LocalizableStringFactory, val logger: Logger ) : RemoteProvider("Dev Spaces") { - override val svgIcon: SvgIcon = SvgIcon( - this::class.java.getResourceAsStream("/icon.svg")?.readAllBytes() ?: byteArrayOf(), - type = SvgIcon.IconType.Default - ) + override val iconResource: ImageResource = jvmResourceReader().image("/icon.svg") - override val noEnvironmentsDescription: String = "Start a workspace from Dev Spaces Dashboard" + override val noEnvironmentsDescription: String = "No DevWorkspaces found. Create a new one from the Dev Spaces Dashboard" + override val loadingEnvironmentsDescription: LocalizableString = localizableStringFactory.ptrl("Loading DevWorkspaces...") override val environments: MutableStateFlow>> = repository.environments @@ -43,6 +46,8 @@ class DevSpacesRemoteProvider( override fun setVisible(visibilityState: ProviderVisibilityState) {} + override fun getNewEnvironmentUiPage(): UiPage = UiPage(localizableStringFactory.pnotr("Choose a DevWorkspace to connect to")) + /** * Handles an external request, typically comes from Che/DevSpaces Dashboard via the link as: * jetbrains://gateway/com.redhat.devtools.toolbox?... diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt index 7281105..346d52b 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds /** * Repository managing the lifecycle of remote environments. @@ -39,7 +40,7 @@ class EnvironmentRepository( private val logger: Logger, private val coroutineScope: CoroutineScope, private val contentsViewFactory: EnvironmentContentsViewFactory = SshEnvironmentContentsViewFactory(), - private val refreshInterval: Duration = 10.minutes, + private val refreshInterval: Duration = 10.seconds, private val localizableStringFactory: LocalizableStringFactory, private val clientFactory: OpenShiftClientFactory ) { @@ -59,13 +60,11 @@ class EnvironmentRepository( // Initial fetch refreshEnvironments() - // Periodic refresh - // TODO: enable it once fetching all workspaces implemented in DataSource - // Disabled to prevent loosing an environment came externally, through URL. -// while (isActive) { -// delay(refreshInterval) -// refreshEnvironments() -// } + // periodically sync the workspaces list with the remote + while (isActive) { + delay(refreshInterval) + refreshEnvironments() + } } } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt index bca2f3e..6d554d4 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt @@ -43,15 +43,14 @@ class DevWorkspacesDataSource( .map { workspace -> // TODO: figure out how to fetch the connection data EnvironmentConfig( - id = workspace.uid, + id = workspace.name, name = MutableStateFlow(workspace.name), description = "[API] DevWorkspace", username = "1001270000", port = 2022, - sshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n" + - "-----END OPENSSH PRIVATE KEY-----\n", availableIdeProductCodes = listOf("IU"), - projectPaths = listOf("/projects") + projectPaths = listOf("/projects"), + tags = mapOf("namespace" to workspace.namespace) ) } } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt index 51ec6f7..4b12009 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt @@ -26,6 +26,7 @@ import io.fabric8.kubernetes.client.LocalPortForward import io.fabric8.openshift.client.OpenShiftClient import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update /** @@ -62,9 +63,17 @@ class DevSpacesRemoteEnvironment( override val connectionRequest: Flow = _connectionRequest + // Hide the Toolbox-provided `Delete` action in the `Environment actions` menu. + // We don't need that for now. + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow(null) + override suspend fun getContentsView(): EnvironmentContentsView { logger.debug("PLUGIN: getContentsView called for id='${initialConfig.id}', name='${_currentConfig.name}'") - val view = contentsViewFactory.create(_currentConfig) { activePortForward?.localPort ?: 2022 } + val view = contentsViewFactory.create( + _currentConfig, + portProvider = { PortAllocator.get(initialConfig.id) ?: 0 }, + keyProvider = { SshKeyStore.get(initialConfig.id) ?: "" } + ) logger.debug("PLUGIN: Created view with ${_currentConfig.availableIdeProductCodes.size} IDEs, ${_currentConfig.projectPaths.size} projects") return view } @@ -105,20 +114,56 @@ class DevSpacesRemoteEnvironment( BeforeConnectionHook { logger.info("Running before connection hook for environment: ${initialConfig.id}") - // Close any previous port forward -// closePortForward() + val namespace = _currentConfig.tags["namespace"] + ?: throw IllegalStateException("Namespace not found in environment config") + val workspaceName = _currentConfig.name.value - // Create new client and port forward (kept alive until closePortForward is called) val client = clientFactory.create() activeClient = client - // TODO: figure out how to fetch the pod information + val pod = client.pods() + .inNamespace(namespace) + .withLabel("controller.devfile.io/devworkspace_name", workspaceName) + .list() + .items + .firstOrNull() + ?: throw IllegalStateException("No pod found for workspace $workspaceName in namespace $namespace") + + logger.info("Found pod: ${pod.metadata.name} for workspace: $workspaceName") + + val savedPort = PortAllocator.get(initialConfig.id) activePortForward = client.pods() - .inNamespace("azatsarynnyy-che") - .withName("workspacee64b5c5b8a6d4196-577c7ff96b-plbhx") - .portForward(_currentConfig.port, 0) + .inNamespace(namespace) + .withName(pod.metadata.name) + .portForward(_currentConfig.port, savedPort ?: 0) + + if (savedPort == null) { + PortAllocator.save(initialConfig.id, activePortForward!!.localPort) + } logger.info("Port forward established: localhost:${activePortForward?.localPort} -> pod:${_currentConfig.port}") + + + + // Read SSH key from container if not cached + if (SshKeyStore.get(initialConfig.id) == null) { + val output = java.io.ByteArrayOutputStream() + val latch = java.util.concurrent.CountDownLatch(1) + client.pods() + .inNamespace(namespace) + .withName(pod.metadata.name) + .inContainer("jetbrains-sshd-page") + .writingOutput(output) + .usingListener(object : io.fabric8.kubernetes.client.dsl.ExecListener { + override fun onClose(code: Int, reason: String?) { latch.countDown() } + override fun onFailure(t: Throwable?, response: io.fabric8.kubernetes.client.dsl.ExecListener.Response?) { latch.countDown() } + }) + .exec("cat", "/sshd/ssh_client_key") + latch.await(10, java.util.concurrent.TimeUnit.SECONDS) + val sshKey = output.toString(Charsets.UTF_8) + SshKeyStore.save(initialConfig.id, sshKey) + logger.info("SSH key fetched from container for workspace: $workspaceName") + } } ) } @@ -150,6 +195,9 @@ class DevSpacesRemoteEnvironment( logger.debug("Error closing client: ${e.message}") } } + + + } /** diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt index a831d51..2b6e4f2 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt @@ -30,5 +30,9 @@ fun interface EnvironmentContentsViewFactory { * @param config The environment's configuration data * @return An EnvironmentContentsView implementation */ - suspend fun create(config: EnvironmentConfig, portProvider: () -> Int): EnvironmentContentsView + suspend fun create( + config: EnvironmentConfig, + portProvider: () -> Int, + keyProvider: () -> String + ): EnvironmentContentsView } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/PortAllocator.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/PortAllocator.kt new file mode 100644 index 0000000..f45af39 --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/PortAllocator.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.toolbox.environment + +/** + * Manages local port assignments for environment connections. + * + * Toolbox queries [SshConnectionInfo.port] before [BeforeConnectionHook] runs, + * but the local port is only known after establishing the port forward in the hook. + * This creates a timing problem on first connection. + * + * Solution: on first connection, let the system assign a free port (portForward with 0), + * then save it here. On subsequent connections, reuse the saved port so [SshConnectionInfo] + * returns the correct value before the hook runs. + */ +object PortAllocator { + private val allocatedPorts = mutableMapOf() + + fun get(environmentId: String): Int? = allocatedPorts[environmentId] + + fun save(environmentId: String, port: Int) { + allocatedPorts[environmentId] = port + } + + fun release(environmentId: String) { + allocatedPorts.remove(environmentId) + } +} diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt index 0d361a5..cf5d2c0 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt @@ -23,7 +23,11 @@ import kotlinx.coroutines.flow.MutableStateFlow class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { - override suspend fun create(config: EnvironmentConfig, portProvider: () -> Int): EnvironmentContentsView { + override suspend fun create( + config: EnvironmentConfig, + portProvider: () -> Int, + keyProvider: () -> String + ): EnvironmentContentsView { val ides = config.availableIdeProductCodes.map { productCode -> SimpleIdeStub(productCode) } @@ -32,7 +36,7 @@ class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { CachedProject(path) } - return SimpleEnvironmentContentsView(ides, projects, config.username!!, config.sshKey!!, portProvider) + return SimpleEnvironmentContentsView(ides, projects, config.username!!, portProvider, keyProvider) } } @@ -42,9 +46,9 @@ class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { class SimpleEnvironmentContentsView( ides: List, projects: List, - val userName: String, - val sshKey: String, - private val portProvider: () -> Int + private val userName: String, + private val portProvider: () -> Int, + private val keyProvider: () -> String ) : ManualEnvironmentContentsView, SshEnvironmentContentsView { // Expose as immutable flows - data is set once at construction @@ -54,7 +58,7 @@ class SimpleEnvironmentContentsView( override val projectListState: Flow>> = MutableStateFlow(LoadableState.Value(projects)) - override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(userName, sshKey, portProvider) + override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(userName, portProvider, keyProvider) } data class SimpleIdeStub( @@ -66,15 +70,12 @@ data class SimpleIdeStub( private class WorkspaceSshConnectionInfo( val uName: String, - val sshKey: String, - private val portProvider: () -> Int + private val portProvider: () -> Int, + private val keyProvider: () -> String ) : SshConnectionInfo { override val host: String = "devspaces" - // TODO: the port value is read on first call only. - // But it's not re-read on the subsequent calls. - // Need to re-read it on each connection attempts as the port is chosen dynamically. override val port: Int get() = portProvider() override val userName: String = uName @@ -85,25 +86,19 @@ private class WorkspaceSshConnectionInfo( " StrictHostKeyChecking no" override val privateKeys: List - get() = listOf(sshKey.toByteArray()) + get() = listOf(keyProvider().toByteArray()) override val shouldUseSystemConfiguration: Boolean = false override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as WorkspaceSshConnectionInfo - - if (host != other.host) return false - if (port != other.port) return false - - return true - } - - override fun hashCode(): Int { - var result = port - result = 31 * result + host.hashCode() - return result + // Before establishing an SSH connection to a specific environment, + // Toolbox stores the `privateKeys` value to ~/Library/Caches/JetBrains/Toolbox/ssh_keys/LS0tLS1CRUdJTiBP + // and passes the flie path to `ssh -i` command line. + // + // With a proper `equals` implementation, Toolbox doesn't refresh an SSH key stored in the temp cache file. + // + // This is the only way I found so far to make the multiconnection mode work well. + // So, it updates an SSH key in the temp file for each new connection. + return false } } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshKeyStore.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshKeyStore.kt new file mode 100644 index 0000000..e32af94 --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshKeyStore.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.toolbox.environment + +/** + * Stores SSH private keys fetched from workspace containers. + * + * Similar to [PortAllocator], this solves a timing issue: Toolbox queries + * [SshConnectionInfo.privateKeys] before [BeforeConnectionHook] runs, + * but the key is only available after reading it from the container in the hook. + * + * On first connection, the key is read from the pod and saved here. + * On subsequent connections, the cached key is returned. + */ +object SshKeyStore { + private val keys = mutableMapOf() + + fun get(environmentId: String): String? = keys[environmentId] + + fun save(environmentId: String, key: String) { + keys[environmentId] = key + } + + fun release(environmentId: String) { + keys.remove(environmentId) + } +} From 80f13e68a294b8b2ac07831ba47c1ac14b3d73a4 Mon Sep 17 00:00:00 2001 From: Artem Zatsarynnyi Date: Tue, 19 May 2026 10:42:35 +0200 Subject: [PATCH 4/8] fetch ssh user name from CDE; list Toolbox-compatible CDEs only; UX improvments; simplify from-Dashboard flow Signed-off-by: Artem Zatsarynnyi --- .../toolbox/DevSpacesRemoteProvider.kt | 32 +-- .../devtools/toolbox/EnvironmentRepository.kt | 7 +- .../datasource/DevWorkspacesDataSource.kt | 31 +-- .../datasource/EnvironmentDataSource.kt | 6 - .../environment/DevSpacesRemoteEnvironment.kt | 207 ++++++++++++------ .../EnvironmentContentsViewFactory.kt | 2 +- .../environment/SshCredentialsStore.kt | 42 ++++ .../SshEnvironmentContentsViewFactory.kt | 16 +- .../toolbox/environment/SshKeyStore.kt | 36 --- .../toolbox/openshift/DevWorkspace.kt | 17 +- .../toolbox/openshift/DevWorkspaceTemplate.kt | 51 +++++ .../toolbox/openshift/DevWorkspaces.kt | 129 ++++++++++- 12 files changed, 396 insertions(+), 180 deletions(-) create mode 100644 plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshCredentialsStore.kt delete mode 100644 plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshKeyStore.kt create mode 100644 plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaceTemplate.kt diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt index ccdc5d9..2809ac0 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt @@ -23,20 +23,24 @@ import com.jetbrains.toolbox.platform.image.image import com.jetbrains.toolbox.platform.resource.jvm.jvmResourceReader import kotlinx.coroutines.flow.MutableStateFlow import com.redhat.devtools.toolbox.environment.DevSpacesRemoteEnvironment -import com.redhat.devtools.toolbox.environment.EnvironmentConfig import java.net.URI /** * [RemoteProvider] implementation that delegates the environment management to [EnvironmentRepository]. */ class DevSpacesRemoteProvider( - val repository: EnvironmentRepository, val localizableStringFactory: LocalizableStringFactory, val logger: Logger + val repository: EnvironmentRepository, + val localizableStringFactory: LocalizableStringFactory, + val logger: Logger ) : RemoteProvider("Dev Spaces") { override val iconResource: ImageResource = jvmResourceReader().image("/icon.svg") override val noEnvironmentsDescription: String = "No DevWorkspaces found. Create a new one from the Dev Spaces Dashboard" - override val loadingEnvironmentsDescription: LocalizableString = localizableStringFactory.ptrl("Loading DevWorkspaces...") + override val loadingEnvironmentsDescription: LocalizableString = localizableStringFactory.ptrl("Loading the Workspaces list...\r\n\r\n" + + "If the list does not load within a few seconds -\r\n" + + "make sure you are logged in to the correct cluster\r\n" + + "by running the 'oc login ...' command in the terminal.") override val environments: MutableStateFlow>> = repository.environments @@ -46,7 +50,7 @@ class DevSpacesRemoteProvider( override fun setVisible(visibilityState: ProviderVisibilityState) {} - override fun getNewEnvironmentUiPage(): UiPage = UiPage(localizableStringFactory.pnotr("Choose a DevWorkspace to connect to")) + override fun getNewEnvironmentUiPage(): UiPage = UiPage(localizableStringFactory.pnotr("Choose a Workspace to connect to")) /** * Handles an external request, typically comes from Che/DevSpaces Dashboard via the link as: @@ -63,25 +67,9 @@ class DevSpacesRemoteProvider( } ?: emptyMap() val dwID: String = queryParams["dwID"] ?: "" - val dwName: String = queryParams["dwName"] ?: "" - val userName: String = queryParams["username"] ?: "" - val sshKey: String = queryParams["key"] ?: "" - val project: String = queryParams["project"] ?: "" - // re-read the environments list with adding the additional env. - repository.refreshEnvironments( - EnvironmentConfig( - id = dwID, - name = MutableStateFlow(dwName), - username = userName, - sshKey = sshKey, - projectPaths = listOf(project), - ) - ) - - // Schedule connecting to a CDE from a Thin Client - // once the environment is added to Toolbox. - repository.updateConnectionRequest(dwID, true, "Error while connecting to remote") + // Schedule establishing the connection to the environment. + repository.updateConnectionRequest(dwID, true) } override fun close() {} diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt index 346d52b..ec9fdbc 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt @@ -70,15 +70,12 @@ class EnvironmentRepository( /** * Triggers updating the environments list. - * - * @param externalEnvironment - optional, may be provided in case of an external request. - * Typically, it comes from the Dashboard. */ - suspend fun refreshEnvironments(externalEnvironment: EnvironmentConfig? = null) { + suspend fun refreshEnvironments() { logger.debug("Refreshing environments from ${dataSource::class.simpleName}") try { - val configs = dataSource.fetchEnvironments() + listOfNotNull(externalEnvironment) + val configs = dataSource.fetchEnvironments() val environments = configs.map { config -> getOrCreateEnvironment(config) diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt index 6d554d4..af7f455 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/DevWorkspacesDataSource.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow /** * Data source returns the environment configurations from - * the DevWorkspaces fetched from a Dev Spaces instance. + * the DevWorkspaces fetched from the current Dev Spaces instance. */ class DevWorkspacesDataSource( private val clientFactory: OpenShiftClientFactory, @@ -41,14 +41,13 @@ class DevWorkspacesDataSource( DevWorkspaces(client, logger).list(namespace) } .map { workspace -> - // TODO: figure out how to fetch the connection data EnvironmentConfig( - id = workspace.name, + id = workspace.id, name = MutableStateFlow(workspace.name), - description = "[API] DevWorkspace", - username = "1001270000", - port = 2022, - availableIdeProductCodes = listOf("IU"), + description = workspace.phase, + port = 2022, // the port of in-container running sshd +// availableIdeProductCodes = listOf("IU"), + // TODO: implement fetching the PROJECT_SOURCES env. var. value projectPaths = listOf("/projects"), tags = mapOf("namespace" to workspace.namespace) ) @@ -56,21 +55,7 @@ class DevWorkspacesDataSource( } } catch (e: Exception) { logger.error("Failed to fetch environments: ${e.message}") - emptyList() + throw DataSourceException(e.message.toString(), e) } } - - override fun handleExternalRequest( - id: String, name: String, userName: String, sshKey: String, projects: List - ): EnvironmentConfig { - return EnvironmentConfig( - id = id, - name = MutableStateFlow(name), - description = "[External] DevWorkspace", - username = userName, - sshKey = sshKey, - availableIdeProductCodes = listOf("IU"), - projectPaths = projects - ) - } -} \ No newline at end of file +} diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/EnvironmentDataSource.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/EnvironmentDataSource.kt index 90fe7b3..f06f16e 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/EnvironmentDataSource.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/datasource/EnvironmentDataSource.kt @@ -31,12 +31,6 @@ interface EnvironmentDataSource { * @throws DataSourceException on failure */ suspend fun fetchEnvironments(): List - - /** - * Returns an additional environment configuration - * came from external request. - */ - fun handleExternalRequest(id: String, name: String, userName: String, sshKey: String, projects: List): EnvironmentConfig } /** diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt index 4b12009..51f4ef2 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/DevSpacesRemoteEnvironment.kt @@ -21,10 +21,12 @@ 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.remoteDev.states.StandardRemoteEnvironmentState +import com.redhat.devtools.toolbox.openshift.DevWorkspaces import com.redhat.devtools.toolbox.openshift.OpenShiftClientFactory import io.fabric8.kubernetes.client.LocalPortForward import io.fabric8.openshift.client.OpenShiftClient import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -38,7 +40,7 @@ class DevSpacesRemoteEnvironment( private val localizableStringFactory: LocalizableStringFactory, private val logger: Logger, private val clientFactory: OpenShiftClientFactory -) : RemoteProviderEnvironment(initialConfig.id) { +) : RemoteProviderEnvironment(initialConfig.id), BeforeConnectionHook, AfterDisconnectHook { // Mutable internal state private var _currentConfig: EnvironmentConfig = initialConfig @@ -72,7 +74,7 @@ class DevSpacesRemoteEnvironment( val view = contentsViewFactory.create( _currentConfig, portProvider = { PortAllocator.get(initialConfig.id) ?: 0 }, - keyProvider = { SshKeyStore.get(initialConfig.id) ?: "" } + credentialsProvider = { SshCredentialsStore.get(initialConfig.id) } ) logger.debug("PLUGIN: Created view with ${_currentConfig.availableIdeProductCodes.size} IDEs, ${_currentConfig.projectPaths.size} projects") return view @@ -109,74 +111,142 @@ class DevSpacesRemoteEnvironment( fun getConfig(): EnvironmentConfig = _currentConfig - override fun getBeforeConnectionHooks(): List { - return listOf( - BeforeConnectionHook { - logger.info("Running before connection hook for environment: ${initialConfig.id}") - - val namespace = _currentConfig.tags["namespace"] - ?: throw IllegalStateException("Namespace not found in environment config") - val workspaceName = _currentConfig.name.value - - val client = clientFactory.create() - activeClient = client - - val pod = client.pods() - .inNamespace(namespace) - .withLabel("controller.devfile.io/devworkspace_name", workspaceName) - .list() - .items - .firstOrNull() - ?: throw IllegalStateException("No pod found for workspace $workspaceName in namespace $namespace") - - logger.info("Found pod: ${pod.metadata.name} for workspace: $workspaceName") - - val savedPort = PortAllocator.get(initialConfig.id) - activePortForward = client.pods() - .inNamespace(namespace) - .withName(pod.metadata.name) - .portForward(_currentConfig.port, savedPort ?: 0) - - if (savedPort == null) { - PortAllocator.save(initialConfig.id, activePortForward!!.localPort) - } - - logger.info("Port forward established: localhost:${activePortForward?.localPort} -> pod:${_currentConfig.port}") - - - - // Read SSH key from container if not cached - if (SshKeyStore.get(initialConfig.id) == null) { - val output = java.io.ByteArrayOutputStream() - val latch = java.util.concurrent.CountDownLatch(1) - client.pods() - .inNamespace(namespace) - .withName(pod.metadata.name) - .inContainer("jetbrains-sshd-page") - .writingOutput(output) - .usingListener(object : io.fabric8.kubernetes.client.dsl.ExecListener { - override fun onClose(code: Int, reason: String?) { latch.countDown() } - override fun onFailure(t: Throwable?, response: io.fabric8.kubernetes.client.dsl.ExecListener.Response?) { latch.countDown() } - }) - .exec("cat", "/sshd/ssh_client_key") - latch.await(10, java.util.concurrent.TimeUnit.SECONDS) - val sshKey = output.toString(Charsets.UTF_8) - SshKeyStore.save(initialConfig.id, sshKey) - logger.info("SSH key fetched from container for workspace: $workspaceName") - } + override fun getBeforeConnectionHooks(): List = listOf(this) + + override fun getAfterDisconnectHooks(): List = listOf(this) + + /** + * Hook that runs on each attempt to establish an SSH connection to the remote environment. + * + * Its goals are: + * - start a workspace if needed + * - forward a remote SSH port to a random local one, which is used by Toolbox + * - fetch the SSH credentials from the workspace to pass to Toolbox + */ + override fun beforeConnection() { + logger.info("Running before connection hook for environment: ${initialConfig.id}") + + val namespace = _currentConfig.tags["namespace"] + ?: throw IllegalStateException("Namespace not found in environment config") + val workspaceName = _currentConfig.name.value + + val client = clientFactory.create() + activeClient = client + + + // Ensure workspace is up and running before establishing port forward + val devWorkspaces = DevWorkspaces(client, logger) + val workspace = devWorkspaces.get(namespace, workspaceName) + + if (!workspace.running) { + logger.info("Workspace $workspaceName is not running (phase: ${workspace.phase}). Starting...") + + val started = runBlocking { + devWorkspaces.startAndWait(namespace, workspaceName, timeoutSeconds = 300) } - ) - } - override fun getAfterDisconnectHooks(): List { - return listOf( - object : AfterDisconnectHook { - override fun afterDisconnect(isManual: Boolean) { - logger.info("Running after disconnect hook for environment: ${initialConfig.id}") - closePortForward() - } + if (!started) { + throw IllegalStateException("Workspace $workspaceName failed to start within timeout") } - ) + + logger.info("Workspace $workspaceName is now running") + } + + + // Establish port forwarding + val pod = client.pods() + .inNamespace(namespace) + .withLabel("controller.devfile.io/devworkspace_name", workspaceName) + .list() + .items + .firstOrNull() + ?: throw IllegalStateException("No pod found for workspace $workspaceName in namespace $namespace") + + logger.info("Found pod: ${pod.metadata.name} for workspace: $workspaceName") + + val savedPort = PortAllocator.get(initialConfig.id) + activePortForward = client.pods() + .inNamespace(namespace) + .withName(pod.metadata.name) + .portForward(_currentConfig.port, savedPort ?: 0) + + if (savedPort == null) { + PortAllocator.save(initialConfig.id, activePortForward!!.localPort) + } + + logger.info("Port forward established: localhost:${activePortForward?.localPort} -> pod:${_currentConfig.port}") + + + // Read SSH credentials from container if not cached + if (SshCredentialsStore.get(initialConfig.id) == null) { + val sshKey = fetchSshKeyFromContainer(client, namespace, pod.metadata.name) + val username = readFileInContainer(client, namespace, pod.metadata.name, "/sshd/username").trim() + + if (sshKey.isNotBlank() && username.isNotBlank()) { + SshCredentialsStore.save(initialConfig.id, SshCredentialsStore.Credentials(sshKey, username)) + logger.info("SSH credentials fetched from container for workspace: $workspaceName") + } else { + logger.error("Failed to fetch SSH credentials from container for workspace: $workspaceName") + } + } + } + + private fun fetchSshKeyFromContainer( + client: OpenShiftClient, + namespace: String, + podName: String + ): String { + // Try pre-configured key first + val preConfiguredKey = readFileInContainer(client, namespace, podName, "/etc/ssh/dwo_ssh_key.pub") + if (preConfiguredKey.isNotBlank()) { + logger.info("Using pre-configured SSH key") + return preConfiguredKey + } + + // Fall back to generated key + val generatedKey = readFileInContainer(client, namespace, podName, "/sshd/ssh_client_key") + if (generatedKey.isNotBlank()) { + logger.info("Using generated SSH key") + return generatedKey + } + + // In early versions (<3.28), of Dev Spaces ssd component the generated key has different location. + // To support backward compatibility, check it as well. + val generatedKeyAlt = readFileInContainer(client, namespace, podName, "/sshd/ssh_client_ed25519_key") + if (generatedKeyAlt.isNotBlank()) { + logger.info("Using generated SSH key") + return generatedKeyAlt + } + + return "" + } + + private fun readFileInContainer( + client: OpenShiftClient, + namespace: String, + podName: String, + filePath: String + ): String { + val output = java.io.ByteArrayOutputStream() + val latch = java.util.concurrent.CountDownLatch(1) + client.pods() + .inNamespace(namespace) + .withName(podName) + .inContainer("jetbrains-sshd-page") + .writingOutput(output) + .usingListener(object : io.fabric8.kubernetes.client.dsl.ExecListener { + override fun onClose(code: Int, reason: String?) { latch.countDown() } + override fun onFailure(t: Throwable?, response: io.fabric8.kubernetes.client.dsl.ExecListener.Response?) { latch.countDown() } + }) + .exec("cat", filePath) + latch.await(10, java.util.concurrent.TimeUnit.SECONDS) + return output.toString(Charsets.UTF_8) + } + + override fun afterDisconnect(isManual: Boolean) { + logger.info("Running after disconnect hook for environment: ${initialConfig.id}") + updateConnectionRequest(false) + closePortForward() } fun closePortForward() { @@ -195,9 +265,6 @@ class DevSpacesRemoteEnvironment( logger.debug("Error closing client: ${e.message}") } } - - - } /** diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt index 2b6e4f2..8b111c3 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/EnvironmentContentsViewFactory.kt @@ -33,6 +33,6 @@ fun interface EnvironmentContentsViewFactory { suspend fun create( config: EnvironmentConfig, portProvider: () -> Int, - keyProvider: () -> String + credentialsProvider: () -> SshCredentialsStore.Credentials? ): EnvironmentContentsView } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshCredentialsStore.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshCredentialsStore.kt new file mode 100644 index 0000000..b5aebed --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshCredentialsStore.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.toolbox.environment + +/** + * Stores SSH credentials (private key and username) fetched from workspace containers. + * + * Similar to [PortAllocator], this solves a timing issue: Toolbox queries + * [SshConnectionInfo.privateKeys] and [SshConnectionInfo.userName] before + * [BeforeConnectionHook] runs, but credentials are only available after + * reading them from the container in the hook. + * + * On first connection, credentials are read from the pod and saved here. + * On subsequent connections, the cached credentials are returned. + */ +object SshCredentialsStore { + data class Credentials( + val privateKey: String, + val username: String + ) + + private val cache = mutableMapOf() + + fun get(environmentId: String): Credentials? = cache[environmentId] + + fun save(environmentId: String, credentials: Credentials) { + cache[environmentId] = credentials + } + + fun release(environmentId: String) { + cache.remove(environmentId) + } +} diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt index cf5d2c0..73925d9 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshEnvironmentContentsViewFactory.kt @@ -26,7 +26,7 @@ class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { override suspend fun create( config: EnvironmentConfig, portProvider: () -> Int, - keyProvider: () -> String + credentialsProvider: () -> SshCredentialsStore.Credentials? ): EnvironmentContentsView { val ides = config.availableIdeProductCodes.map { productCode -> SimpleIdeStub(productCode) @@ -36,7 +36,7 @@ class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { CachedProject(path) } - return SimpleEnvironmentContentsView(ides, projects, config.username!!, portProvider, keyProvider) + return SimpleEnvironmentContentsView(ides, projects, portProvider, credentialsProvider) } } @@ -46,9 +46,8 @@ class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { class SimpleEnvironmentContentsView( ides: List, projects: List, - private val userName: String, private val portProvider: () -> Int, - private val keyProvider: () -> String + private val credentialsProvider: () -> SshCredentialsStore.Credentials? ) : ManualEnvironmentContentsView, SshEnvironmentContentsView { // Expose as immutable flows - data is set once at construction @@ -58,7 +57,7 @@ class SimpleEnvironmentContentsView( override val projectListState: Flow>> = MutableStateFlow(LoadableState.Value(projects)) - override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(userName, portProvider, keyProvider) + override suspend fun getConnectionInfo(): SshConnectionInfo = WorkspaceSshConnectionInfo(portProvider, credentialsProvider) } data class SimpleIdeStub( @@ -69,16 +68,15 @@ data class SimpleIdeStub( } private class WorkspaceSshConnectionInfo( - val uName: String, private val portProvider: () -> Int, - private val keyProvider: () -> String + private val credentialsProvider: () -> SshCredentialsStore.Credentials? ) : SshConnectionInfo { override val host: String = "devspaces" override val port: Int get() = portProvider() - override val userName: String = uName + override val userName: String get() = credentialsProvider()?.username ?: "" override val sshConfig: String = "Host $host\n" + " HostName 127.0.0.1\n" + @@ -86,7 +84,7 @@ private class WorkspaceSshConnectionInfo( " StrictHostKeyChecking no" override val privateKeys: List - get() = listOf(keyProvider().toByteArray()) + get() = listOf((credentialsProvider()?.privateKey ?: "").toByteArray()) override val shouldUseSystemConfiguration: Boolean = false diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshKeyStore.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshKeyStore.kt deleted file mode 100644 index e32af94..0000000 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/environment/SshKeyStore.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2026 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package com.redhat.devtools.toolbox.environment - -/** - * Stores SSH private keys fetched from workspace containers. - * - * Similar to [PortAllocator], this solves a timing issue: Toolbox queries - * [SshConnectionInfo.privateKeys] before [BeforeConnectionHook] runs, - * but the key is only available after reading it from the container in the hook. - * - * On first connection, the key is read from the pod and saved here. - * On subsequent connections, the cached key is returned. - */ -object SshKeyStore { - private val keys = mutableMapOf() - - fun get(environmentId: String): String? = keys[environmentId] - - fun save(environmentId: String, key: String) { - keys[environmentId] = key - } - - fun release(environmentId: String) { - keys.remove(environmentId) - } -} diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt index 5703653..1d7f5b1 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt @@ -16,25 +16,36 @@ import io.fabric8.kubernetes.api.model.GenericKubernetesResource data class DevWorkspace( val namespace: String, val name: String, + val id: String, val uid: String, val started: Boolean, - val phase: String + val phase: String, + val cheEditor: String? ) { val running: Boolean - get() = phase == "Running" + get() = phase == PHASE_RUNNING companion object { + const val PHASE_RUNNING = "Running" + const val PHASE_STOPPED = "Stopped" + const val PHASE_STARTING = "Starting" + const val PHASE_STOPPING = "Stopping" + const val PHASE_FAILED = "Failed" + fun from(resource: GenericKubernetesResource): DevWorkspace { val metadata = resource.metadata val spec = resource.additionalProperties["spec"] as? Map<*, *> ?: emptyMap() val status = resource.additionalProperties["status"] as? Map<*, *> ?: emptyMap() + val cheEditor = metadata.annotations?.get("che.eclipse.org/che-editor") return DevWorkspace( namespace = metadata.namespace ?: "", name = metadata.name ?: "", + id = status["devworkspaceId"] as? String ?: "", uid = metadata.uid ?: "", started = spec["started"] as? Boolean ?: false, - phase = status["phase"] as? String ?: "" + phase = status["phase"] as? String ?: "", + cheEditor = cheEditor ) } } diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaceTemplate.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaceTemplate.kt new file mode 100644 index 0000000..3b40919 --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaceTemplate.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.toolbox.openshift + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource + +data class DevWorkspaceTemplate( + val namespace: String, + val name: String, + val ownerReferenceUids: List, + val components: List> +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun from(resource: GenericKubernetesResource): DevWorkspaceTemplate { + val metadata = resource.metadata + val spec = resource.additionalProperties["spec"] as? Map<*, *> ?: emptyMap() + + val ownerRefs = metadata.ownerReferences ?: emptyList() + val ownerUids = ownerRefs + .filter { ref -> + ref.apiVersion?.equals("workspace.devfile.io/v1alpha2", ignoreCase = true) == true && + ref.kind?.equals("DevWorkspace", ignoreCase = true) == true + } + .mapNotNull { it.uid } + + val rawComponents = spec["components"] + val components = if (rawComponents is List<*>) { + rawComponents.filterIsInstance>() + } else { + emptyList() + } + + return DevWorkspaceTemplate( + namespace = metadata.namespace ?: "", + name = metadata.name ?: "", + ownerReferenceUids = ownerUids, + components = components + ) + } + } +} \ No newline at end of file diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt index 4a6133b..b1cfd70 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt @@ -12,12 +12,17 @@ package com.redhat.devtools.toolbox.openshift import com.jetbrains.toolbox.api.core.diagnostics.Logger -import io.fabric8.kubernetes.api.model.GenericKubernetesResource import io.fabric8.kubernetes.client.KubernetesClient import io.fabric8.kubernetes.client.KubernetesClientException import io.fabric8.kubernetes.client.dsl.base.CustomResourceDefinitionContext +import io.fabric8.kubernetes.client.dsl.base.PatchContext +import io.fabric8.kubernetes.client.dsl.base.PatchType +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds -class DevWorkspaces(private val client: KubernetesClient, private val logger: Logger? = null) { +class DevWorkspaces(private val client: KubernetesClient, private val logger: Logger) { private val devWorkspaceContext = CustomResourceDefinitionContext.Builder() .withGroup("workspace.devfile.io") @@ -26,6 +31,16 @@ class DevWorkspaces(private val client: KubernetesClient, private val logger: Lo .withScope("Namespaced") .build() + private val devWorkspaceTemplateContext = CustomResourceDefinitionContext.Builder() + .withGroup("workspace.devfile.io") + .withVersion("v1alpha2") + .withPlural("devworkspacetemplates") + .withScope("Namespaced") + .build() + + /** + * Lists DevWorkspaces filtered to only Toolbox-compatible workspaces. + */ fun list(namespace: String): List { return try { val resources = client.genericKubernetesResources(devWorkspaceContext) @@ -33,17 +48,121 @@ class DevWorkspaces(private val client: KubernetesClient, private val logger: Lo .list() .items - resources.map { resource -> DevWorkspace.from(resource) } + val workspaces = resources.map { resource -> DevWorkspace.from(resource) } + val templateMap = getDevWorkspaceTemplateMap(namespace) + + workspaces.filter { isToolboxCompatible(it, templateMap) } } catch (e: KubernetesClientException) { - logger?.info("API error listing DevWorkspaces in namespace $namespace: ${e.message}") + logger.info("API error listing DevWorkspaces in namespace $namespace: ${e.message}") when (e.code) { + // This is mostly for handling two cases: + // 1) there might be some namespaces (OpenShift projects) in which the user is not allowed to list the resources "devworkspaces", e.g. "openshift-virtualization-os-images" on Red Hat Dev Sandbox + // 2) the cluster doesn't have the RedHat DevSpaces operator installed + // + // In those cases, it doesn't make much sense to show an error to the user, so let's skip it silently by returning an empty workspaces list. 403, 404 -> emptyList() else -> { - logger?.error("Kubernetes API error ${e.code}: ${e.message}") + logger.error("Kubernetes API error ${e.code}: ${e.message}") throw e } } } } + + private fun getDevWorkspaceTemplateMap(namespace: String): Map> { + return try { + val resources = client.genericKubernetesResources(devWorkspaceTemplateContext) + .inNamespace(namespace) + .list() + .items + + resources + .map { DevWorkspaceTemplate.from(it) } + .flatMap { template -> + template.ownerReferenceUids.map { uid -> uid to template } + } + .groupBy( + keySelector = { it.first }, + valueTransform = { it.second } + ) + } catch (e: KubernetesClientException) { + logger.debug("Error fetching DevWorkspaceTemplates: ${e.message}") + emptyMap() + } + } + + private fun isToolboxCompatible( + workspace: DevWorkspace, + templateMap: Map> + ): Boolean { + val templates = templateMap[workspace.uid] ?: return false + return templates.any { template -> + template.components.any { component -> + val attributes = component["attributes"] as? Map<*, *> + attributes?.get("app.kubernetes.io/component") == "jetbrains-sshd" + } + } + } + + fun get(namespace: String, name: String): DevWorkspace { + val resource = client.genericKubernetesResources(devWorkspaceContext) + .inNamespace(namespace) + .withName(name) + .get() + ?: throw KubernetesClientException("DevWorkspace '$name' not found in namespace '$namespace'") + + return DevWorkspace.from(resource) + } + + fun start(namespace: String, name: String) { + val patch = """[{"op":"replace","path":"/spec/started","value":true}]""" + client.genericKubernetesResources(devWorkspaceContext) + .inNamespace(namespace) + .withName(name) + .patch(PatchContext.of(PatchType.JSON), patch) + logger.info("Started DevWorkspace '$name' in namespace '$namespace'") + } + + suspend fun startAndWait( + namespace: String, + name: String, + timeoutSeconds: Long = 300 + ): Boolean { + val workspace = get(namespace, name) + + if (!workspace.started) { + start(namespace, name) + } + + return waitUntilRunning(namespace, name, timeoutSeconds) + } + + suspend fun waitUntilRunning( + namespace: String, + name: String, + timeoutSeconds: Long = 300 + ): Boolean { + return withTimeoutOrNull(timeoutSeconds.seconds) { + while (true) { + val workspace = try { + get(namespace, name) + } catch (e: Exception) { + logger.debug("Error fetching workspace status: ${e.message}") + delay(500.milliseconds) + continue + } + + when (workspace.phase) { + DevWorkspace.PHASE_RUNNING -> return@withTimeoutOrNull true + DevWorkspace.PHASE_FAILED -> return@withTimeoutOrNull false + } + + delay(500.milliseconds) + } + + @Suppress("UNREACHABLE_CODE") + false + } ?: false + } } \ No newline at end of file From 82d335191298172a198604f7e5a726fc8b6f2bb4 Mon Sep 17 00:00:00 2001 From: Artem Zatsarynnyi Date: Tue, 19 May 2026 10:48:24 +0200 Subject: [PATCH 5/8] Bump up the plugin version Signed-off-by: Artem Zatsarynnyi --- plugin/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index 15335b9..c5a75aa 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -10,7 +10,7 @@ plugins { // Note, the `group` value is used as a provider ID // when handling the URLs like `jetbrains://gateway/provider.ID` group = "com.redhat.devtools.toolbox" -version = "0.0.1" +version = "0.0.2" configurations.named("runtimeClasspath") { exclude(group = "org.jetbrains.kotlin") From 8576f559a9ae27d27d83424aca183c98402ba9a2 Mon Sep 17 00:00:00 2001 From: Artem Zatsarynnyi Date: Tue, 19 May 2026 11:49:15 +0200 Subject: [PATCH 6/8] fix build Signed-off-by: Artem Zatsarynnyi --- plugin/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index c5a75aa..d5c9fe7 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -12,6 +12,8 @@ plugins { group = "com.redhat.devtools.toolbox" version = "0.0.2" +extra["vendor"] = "Red-Hat" + configurations.named("runtimeClasspath") { exclude(group = "org.jetbrains.kotlin") exclude(group = "org.jetbrains.kotlinx") From 6351a39ed8cbfa7e99b9d26640319b4de135ba66 Mon Sep 17 00:00:00 2001 From: Artem Zatsarynnyi Date: Tue, 19 May 2026 18:56:13 +0200 Subject: [PATCH 7/8] Update plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt index b1cfd70..30ca534 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt @@ -147,6 +147,8 @@ class DevWorkspaces(private val client: KubernetesClient, private val logger: Lo while (true) { val workspace = try { get(namespace, name) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { logger.debug("Error fetching workspace status: ${e.message}") delay(500.milliseconds) From 1e7fec404ae5ba7b0a68e82083347c4f9b5ad618 Mon Sep 17 00:00:00 2001 From: Artem Zatsarynnyi Date: Tue, 19 May 2026 19:09:19 +0200 Subject: [PATCH 8/8] add URL parameter validation Signed-off-by: Artem Zatsarynnyi --- .../com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt index 2809ac0..2ff1b9b 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt @@ -68,6 +68,11 @@ class DevSpacesRemoteProvider( val dwID: String = queryParams["dwID"] ?: "" + if (dwID.isBlank()) { + logger.warn("Received URI without valid dwID parameter: $uri") + return + } + // Schedule establishing the connection to the environment. repository.updateConnectionRequest(dwID, true) }