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/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 53b078a..d5c9fe7 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") id("com.redhat.devtools.toolbox.publish") @@ -10,10 +10,15 @@ 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" extra["vendor"] = "Red-Hat" +configurations.named("runtimeClasspath") { + exclude(group = "org.jetbrains.kotlin") + exclude(group = "org.jetbrains.kotlinx") +} + kotlin { jvmToolchain(21) } @@ -21,6 +26,9 @@ kotlin { dependencies { compileOnly(libs.bundles.toolbox.plugin.api) compileOnly(libs.coroutines.core) + implementation("io.fabric8:openshift-client:7.6.1") { + 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..0425578 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,26 +39,28 @@ 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 repository.startPolling() logger.info("DevSpacesRemoteProvider initialized with ${dataSource::class.simpleName}") - return DevSpacesRemoteProvider(repository, logger) + return DevSpacesRemoteProvider(repository, localizableStringFactory, 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..2ff1b9b 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/DevSpacesRemoteProvider.kt @@ -12,28 +12,35 @@ 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 import java.net.URI /** * [RemoteProvider] implementation that delegates the environment management to [EnvironmentRepository]. */ class DevSpacesRemoteProvider( - val repository: EnvironmentRepository, val logger: Logger -) : RemoteProvider("Red Hat OpenShift Dev Spaces") { + 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 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 @@ -43,6 +50,8 @@ class DevSpacesRemoteProvider( override fun setVisible(visibilityState: ProviderVisibilityState) {} + 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: * jetbrains://gateway/com.redhat.devtools.toolbox?... @@ -58,25 +67,14 @@ 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), - ) - ) + if (dwID.isBlank()) { + logger.warn("Received URI without valid dwID parameter: $uri") + return + } - // 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 9173c46..ec9fdbc 100644 --- a/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/EnvironmentRepository.kt @@ -18,11 +18,13 @@ 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 import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds /** * Repository managing the lifecycle of remote environments. @@ -38,8 +40,9 @@ class EnvironmentRepository( private val logger: Logger, private val coroutineScope: CoroutineScope, private val contentsViewFactory: EnvironmentContentsViewFactory = SshEnvironmentContentsViewFactory(), - private val refreshInterval: Duration = 10.minutes, - private val localizableStringFactory: LocalizableStringFactory + private val refreshInterval: Duration = 10.seconds, + private val localizableStringFactory: LocalizableStringFactory, + private val clientFactory: OpenShiftClientFactory ) { // Internal mutable state private val _environments = MutableStateFlow>>( @@ -57,27 +60,22 @@ 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() + } } } /** * 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) @@ -106,7 +104,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..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 @@ -12,36 +12,50 @@ 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. + * the DevWorkspaces fetched from the current 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() - 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 - ) + projects + .mapNotNull { it.metadata?.name } + .flatMap { namespace -> + DevWorkspaces(client, logger).list(namespace) + } + .map { workspace -> + EnvironmentConfig( + id = workspace.id, + name = MutableStateFlow(workspace.name), + 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) + ) + } + } + } catch (e: Exception) { + logger.error("Failed to fetch environments: ${e.message}") + throw DataSourceException(e.message.toString(), e) + } } } 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 99b4147..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 @@ -13,14 +13,22 @@ 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.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 /** @@ -30,8 +38,9 @@ class DevSpacesRemoteEnvironment( private val initialConfig: EnvironmentConfig, private val contentsViewFactory: EnvironmentContentsViewFactory = SshEnvironmentContentsViewFactory(), private val localizableStringFactory: LocalizableStringFactory, - private val logger: Logger -) : RemoteProviderEnvironment(initialConfig.id) { + private val logger: Logger, + private val clientFactory: OpenShiftClientFactory +) : RemoteProviderEnvironment(initialConfig.id), BeforeConnectionHook, AfterDisconnectHook { // Mutable internal state private var _currentConfig: EnvironmentConfig = initialConfig @@ -43,6 +52,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 @@ -52,9 +65,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) + val view = contentsViewFactory.create( + _currentConfig, + portProvider = { PortAllocator.get(initialConfig.id) ?: 0 }, + credentialsProvider = { SshCredentialsStore.get(initialConfig.id) } + ) logger.debug("PLUGIN: Created view with ${_currentConfig.availableIdeProductCodes.size} IDEs, ${_currentConfig.projectPaths.size} projects") return view } @@ -89,6 +110,161 @@ class DevSpacesRemoteEnvironment( } fun getConfig(): EnvironmentConfig = _currentConfig + + 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) + } + + 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() { + 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 +273,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..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 @@ -30,5 +30,9 @@ 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, + credentialsProvider: () -> SshCredentialsStore.Credentials? + ): 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/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 bd1da84..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 @@ -23,7 +23,11 @@ import kotlinx.coroutines.flow.MutableStateFlow class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { - override suspend fun create(config: EnvironmentConfig): EnvironmentContentsView { + override suspend fun create( + config: EnvironmentConfig, + portProvider: () -> Int, + credentialsProvider: () -> SshCredentialsStore.Credentials? + ): 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!!) + return SimpleEnvironmentContentsView(ides, projects, portProvider, credentialsProvider) } } @@ -42,8 +46,8 @@ class SshEnvironmentContentsViewFactory : EnvironmentContentsViewFactory { class SimpleEnvironmentContentsView( ides: List, projects: List, - val userName: String, - val sshKey: String + private val portProvider: () -> Int, + private val credentialsProvider: () -> SshCredentialsStore.Credentials? ) : ManualEnvironmentContentsView, SshEnvironmentContentsView { // Expose as immutable flows - data is set once at construction @@ -53,7 +57,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(portProvider, credentialsProvider) } data class SimpleIdeStub( @@ -63,14 +67,16 @@ data class SimpleIdeStub( override fun isRunning(): Boolean? = running } -private class WorkspaceSshConnectionInfo(val uName: String, val sshKey: String) : SshConnectionInfo { +private class WorkspaceSshConnectionInfo( + private val portProvider: () -> Int, + private val credentialsProvider: () -> SshCredentialsStore.Credentials? +) : SshConnectionInfo { override val host: String = "devspaces" - // TODO: constant local port means we only support one active connection - override val port: Int = 2022 + 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" + @@ -78,25 +84,19 @@ private class WorkspaceSshConnectionInfo(val uName: String, val sshKey: String) " StrictHostKeyChecking no" override val privateKeys: List - get() = listOf(sshKey.toByteArray()) + get() = listOf((credentialsProvider()?.privateKey ?: "").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/openshift/DevWorkspace.kt b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt new file mode 100644 index 0000000..1d7f5b1 --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspace.kt @@ -0,0 +1,52 @@ +/* + * 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 id: String, + val uid: String, + val started: Boolean, + val phase: String, + val cheEditor: String? +) { + val running: Boolean + 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 ?: "", + cheEditor = cheEditor + ) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..30ca534 --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/DevWorkspaces.kt @@ -0,0 +1,170 @@ +/* + * 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.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) { + + private val devWorkspaceContext = CustomResourceDefinitionContext.Builder() + .withGroup("workspace.devfile.io") + .withVersion("v1alpha2") + .withPlural("devworkspaces") + .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) + .inNamespace(namespace) + .list() + .items + + 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}") + + 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}") + 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: kotlinx.coroutines.CancellationException) { + throw e + } 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 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..33882d5 --- /dev/null +++ b/plugin/src/main/kotlin/com/redhat/devtools/toolbox/openshift/OpenShiftClientFactory.kt @@ -0,0 +1,19 @@ +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 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