diff --git a/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnection.kt b/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnection.kt index 049ff86d..f2ca57bf 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnection.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnection.kt @@ -14,11 +14,12 @@ package com.github.devspaces.gateway import com.github.devspaces.gateway.openshift.DevWorkspaces import com.github.devspaces.gateway.openshift.Pods import com.github.devspaces.gateway.openshift.Utils +import com.github.devspaces.gateway.server.RemoteServer import com.jetbrains.gateway.thinClientLink.LinkedClientManager import com.jetbrains.gateway.thinClientLink.ThinClientHandle import com.jetbrains.rd.util.lifetime.Lifetime -import io.kubernetes.client.openapi.ApiException -import io.kubernetes.client.openapi.models.V1Pod +import org.bouncycastle.util.Arrays +import java.io.Closeable import java.io.IOException import java.net.URI @@ -33,68 +34,33 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { if (!isStarted) { DevWorkspaces(devSpacesContext.client).start(dwNamespace, dwName) } - DevWorkspaces(devSpacesContext.client).waitRunning(dwNamespace, dwName) - val dwPods = Pods(devSpacesContext.client) - .list( - dwNamespace, - String.format("controller.devfile.io/devworkspace_name=%s", dwName) - ) + val remoteServer = RemoteServer(devSpacesContext) + remoteServer.waitProjects() + val projectStatus = remoteServer.getProjectStatus() - if (dwPods.items.size != 1) throw IOException( + if (projectStatus.joinLink.isEmpty()) throw IOException( String.format( - "Expected 1 pod, but found %d", - dwPods.items.size + "Connection link to the remote server not found in the DevWorkspace: %s", + dwName ) ) - val dwPod = dwPods.items[0] - val tcpLink = getConnectionLink(dwPod) - if (tcpLink == "") throw IOException( - String.format( - "Connection link to the remote server not found in the Pod: %s", - dwPod.metadata?.name + val client = LinkedClientManager.getInstance().startNewClient(Lifetime.Eternal, URI(projectStatus.joinLink), "") + val forwarder = Pods(devSpacesContext.client).forward(remoteServer.pod, 5990, 5990) + client.run { + lifetime.onTermination(forwarder) + lifetime.onTermination( + Closeable { + val projectStatus = remoteServer.getProjectStatus() + if (Arrays.isNullOrEmpty(projectStatus.projects)) { + DevWorkspaces(devSpacesContext.client).stop(dwNamespace, dwName) + } + } ) - ) - - val client = LinkedClientManager.getInstance().startNewClient(Lifetime.Eternal, URI(tcpLink), "") - - val forwarder = Pods(devSpacesContext.client).forward(dwPod, 5990, 5990) - client.run { lifetime.onTermination(forwarder) } - - return client - } - - @Throws(IOException::class, ApiException::class) - private fun getConnectionLink(pod: V1Pod): String { - val container = - pod.spec?.containers!!.find { container -> container.ports!!.any { port -> port.name == "idea-server" } } - if (container == null) throw IOException( - String.format( - "Remote server container not found in the Pod: %s", - pod.metadata?.name - ) - ) - - // 1 minute to grab connection link - for (i in 1..12) { - val result = Pods(devSpacesContext.client).exec( - pod, - arrayOf("/bin/sh", "-c", "/idea-server/bin/remote-dev-server.sh status \$PROJECT_SOURCE | grep -Eo -m1 'tcp://[^\"]+'"), - container.name - ).trim() - if (result.startsWith("tcp://")) return result - - // wait a bit, maybe remote server hasn't been started yet - Thread.sleep(5 * 1000) } - throw IOException( - String.format( - "Connection link to the remote server not found in the Pod: %s", - pod.metadata?.name - ) - ) + return client } } diff --git a/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspaces.kt b/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspaces.kt index 92689f1b..bec9e5e9 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspaces.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspaces.kt @@ -19,6 +19,9 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit class DevWorkspaces(private val client: ApiClient) { + + private var devWorkspaceStartingTimeout: Long = 300 + @Throws(ApiException::class) fun list(namespace: String): Any { val customApi = CustomObjectsApi(client) @@ -53,50 +56,68 @@ class DevWorkspaces(private val client: ApiClient) { @Throws(ApiException::class) fun patch(namespace: String, name: String, body: Any) { - val customApi = CustomObjectsApi(client) - customApi.patchNamespacedCustomObject( - "workspace.devfile.io", - "v1alpha2", - namespace, - "devworkspaces", - name, - body, - null, - null, - null - ) + doPatch(namespace, name, body) } @Throws(ApiException::class) fun start(namespace: String, name: String) { val patch = arrayOf(mapOf("op" to "replace", "path" to "/spec/started", "value" to true)) - patch(namespace, name, patch) + doPatch(namespace, name, patch) + } + + @Throws(ApiException::class) + fun stop(namespace: String, name: String) { + val patch = arrayOf(mapOf("op" to "replace", "path" to "/spec/started", "value" to false)) + doPatch(namespace, name, patch) } @Throws(ApiException::class, IOException::class) fun waitRunning(namespace: String, name: String) { - val lock = Object() val dwPhase = java.util.concurrent.atomic.AtomicReference() - - val executor = Executors.newScheduledThreadPool(1) + val executor = Executors.newSingleThreadScheduledExecutor() executor.scheduleAtFixedRate( { val devWorkspace = get(namespace, name) dwPhase.set(Utils.getValue(devWorkspace, arrayOf("status", "phase")) as String) if (dwPhase.get() == "Running" || dwPhase.get() == "Failed") { - synchronized(lock) { - lock.notify() - } + executor.shutdown() } - }, - 0, 5, TimeUnit.SECONDS + }, 0, 5, TimeUnit.SECONDS ) - synchronized(lock) { - lock.wait() + try { + executor.awaitTermination(devWorkspaceStartingTimeout, TimeUnit.SECONDS) + } finally { + executor.shutdown() } - if (dwPhase.get() != "Running") throw IOException("Failed to start Dev Workspace") + if (dwPhase.get() == "Failed") throw IOException( + String.format("DevWorkspace '%s' failed to start", name) + ) + + if (dwPhase.get() != "Running") throw IOException( + String.format( + "DevWorkspace '%s' is not running after %d seconds", + name, + devWorkspaceStartingTimeout + ) + ) + } + + @Throws(ApiException::class) + private fun doPatch(namespace: String, name: String, body: Any) { + val customApi = CustomObjectsApi(client) + customApi.patchNamespacedCustomObject( + "workspace.devfile.io", + "v1alpha2", + namespace, + "devworkspaces", + name, + body, + null, + null, + null + ) } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/devspaces/gateway/openshift/Pods.kt b/src/main/kotlin/com/github/devspaces/gateway/openshift/Pods.kt index ae8af657..42fa175f 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/openshift/Pods.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/openshift/Pods.kt @@ -16,6 +16,7 @@ import io.ktor.utils.io.jvm.nio.* import io.kubernetes.client.Exec import io.kubernetes.client.PortForward import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CoreV1Api import io.kubernetes.client.openapi.models.V1Pod import io.kubernetes.client.openapi.models.V1PodList @@ -29,22 +30,9 @@ import java.nio.channels.* class Pods(private val client: ApiClient) { - @Throws(Exception::class) - fun list(namespace: String, labelSelector: String = ""): V1PodList { - val customApi = CoreV1Api(client) - return customApi.listNamespacedPod( - namespace, - "false", - false, - "", - "", - labelSelector, - -1, - "", - "", - -1, - false - ) + @Throws(ApiException::class) + fun list(namespace: String): V1PodList { + return doList(namespace) } // Sample: @@ -102,7 +90,11 @@ class Pods(private val client: ApiClient) { } @Throws(IOException::class) - private suspend fun copyStreams(clientSocket: Socket, forwardResult: PortForward.PortForwardResult, remotePort: Int) { + private suspend fun copyStreams( + clientSocket: Socket, + forwardResult: PortForward.PortForwardResult, + remotePort: Int + ) { coroutineScope { ensureActive() launch { @@ -124,4 +116,27 @@ class Pods(private val client: ApiClient) { } destination.run { flush() } } + + @Throws(ApiException::class) + fun findFirst(namespace: String, labelSelector: String): V1Pod? { + val pods = doList(namespace, labelSelector) + return pods.items[0] + } + + @Throws(ApiException::class) + private fun doList(namespace: String, labelSelector: String = ""): V1PodList { + return CoreV1Api(client).listNamespacedPod( + namespace, + "false", + false, + "", + "", + labelSelector, + -1, + "", + "", + -1, + false + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/devspaces/gateway/server/ProjectInfo.kt b/src/main/kotlin/com/github/devspaces/gateway/server/ProjectInfo.kt new file mode 100644 index 00000000..278cf1b7 --- /dev/null +++ b/src/main/kotlin/com/github/devspaces/gateway/server/ProjectInfo.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 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.github.devspaces.gateway.server + +data class ProjectInfo( + val projectName: String, + val projectPath: String, + val joinLink: String, + val httpLink: String, + val gatewayLink: String, +) + diff --git a/src/main/kotlin/com/github/devspaces/gateway/server/ProjectStatus.kt b/src/main/kotlin/com/github/devspaces/gateway/server/ProjectStatus.kt new file mode 100644 index 00000000..ad35a500 --- /dev/null +++ b/src/main/kotlin/com/github/devspaces/gateway/server/ProjectStatus.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 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.github.devspaces.gateway.server + +data class ProjectStatus( + val joinLink: String, + val httpLink: String, + val gatewayLink: String, + val appVersion: String, + val runtimeVersion: String, + val projects: Array +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ProjectStatus + + if (joinLink != other.joinLink) return false + if (httpLink != other.httpLink) return false + if (gatewayLink != other.gatewayLink) return false + if (appVersion != other.appVersion) return false + if (runtimeVersion != other.runtimeVersion) return false + if (!projects.contentEquals(other.projects)) return false + + return true + } + + override fun hashCode(): Int { + var result = joinLink.hashCode() + result = 31 * result + httpLink.hashCode() + result = 31 * result + gatewayLink.hashCode() + result = 31 * result + appVersion.hashCode() + result = 31 * result + runtimeVersion.hashCode() + result = 31 * result + projects.contentHashCode() + return result + } + +} + diff --git a/src/main/kotlin/com/github/devspaces/gateway/server/RemoteServer.kt b/src/main/kotlin/com/github/devspaces/gateway/server/RemoteServer.kt new file mode 100644 index 00000000..562a6942 --- /dev/null +++ b/src/main/kotlin/com/github/devspaces/gateway/server/RemoteServer.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2024 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.github.devspaces.gateway.server + +import com.github.devspaces.gateway.DevSpacesContext +import com.github.devspaces.gateway.openshift.Pods +import com.github.devspaces.gateway.openshift.Utils +import com.google.gson.Gson +import io.kubernetes.client.openapi.models.V1Container +import io.kubernetes.client.openapi.models.V1Pod +import org.bouncycastle.util.Arrays +import java.io.IOException +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + + +class RemoteServer(private val devSpacesContext: DevSpacesContext) { + var pod: V1Pod + private var container: V1Container + private var waitingTimeout: Long = 60 + + init { + pod = findPod() + container = findContainer() + } + + fun getProjectStatus(): ProjectStatus { + val result = Pods(devSpacesContext.client).exec( + pod, arrayOf( + "/bin/sh", + "-c", + "/idea-server/bin/remote-dev-server.sh status \$PROJECT_SOURCE | awk '/STATUS:/{p=1; next} p'" + ), container.name + ).trim() + + return Gson().fromJson(result, ProjectStatus::class.java) + } + + @Throws(IOException::class) + fun waitProjects() { + val projectsReady = AtomicBoolean() + val executor = Executors.newSingleThreadScheduledExecutor() + executor.scheduleAtFixedRate( + { + val projectStatus = getProjectStatus() + if (!Arrays.isNullOrEmpty(projectStatus.projects)) { + projectsReady.set(true) + executor.shutdown() + } + }, 0, 5, TimeUnit.SECONDS + ) + + try { + executor.awaitTermination(waitingTimeout, TimeUnit.SECONDS) + } finally { + executor.shutdown() + } + + if (!projectsReady.get()) throw IOException( + String.format( + "Projects are not ready after %d seconds.", + waitingTimeout + ) + ) + } + + @Throws(IOException::class) + private fun findPod(): V1Pod { + val name = Utils.getValue(devSpacesContext.devWorkspace, arrayOf("metadata", "name")) as String + val namespace = Utils.getValue(devSpacesContext.devWorkspace, arrayOf("metadata", "namespace")) as String + val selector = String.format("controller.devfile.io/devworkspace_name=%s", name) + + return Pods(devSpacesContext.client).findFirst(namespace, selector) ?: throw IOException( + String.format( + "DevWorkspace '%s' is not running.", name + ) + ) + } + + @Throws(IOException::class) + private fun findContainer(): V1Container { + return pod.spec!!.containers.find { container -> container.ports?.any { port -> port.name == "idea-server" } != null } + ?: throw IOException( + String.format( + "Remote server container not found in the Pod: %s", pod.metadata?.name + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/devspaces/gateway/view/DevSpacesWizardView.kt b/src/main/kotlin/com/github/devspaces/gateway/view/DevSpacesWizardView.kt index d60513b7..4e0f5853 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/view/DevSpacesWizardView.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/view/DevSpacesWizardView.kt @@ -13,6 +13,9 @@ package com.github.devspaces.gateway.view import com.github.devspaces.gateway.DevSpacesConnection import com.github.devspaces.gateway.DevSpacesContext +import com.github.devspaces.gateway.openshift.DevWorkspaces +import com.github.devspaces.gateway.openshift.Utils +import com.github.devspaces.gateway.server.RemoteServer import com.github.devspaces.gateway.view.steps.DevSpacesDevWorkspaceSelectingStepView import com.github.devspaces.gateway.view.steps.DevSpacesOpenShiftConnectionStepView import com.github.devspaces.gateway.view.steps.DevSpacesWizardStep @@ -25,6 +28,7 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import com.jetbrains.gateway.api.GatewayUI import com.jetbrains.rd.util.lifetime.waitTermination +import okio.Closeable import java.awt.Component import javax.swing.JButton @@ -71,6 +75,7 @@ class DevSpacesWizardView(private val devSpacesContext: DevSpacesContext) : Bord } } + @Suppress("UnstableApiUsage") private fun nextStep() { if (!steps[currentStep].onNext()) return diff --git a/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesDevWorkspaceSelectingStepView.kt b/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesDevWorkspaceSelectingStepView.kt index a08bd4d5..d8eeca3b 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesDevWorkspaceSelectingStepView.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesDevWorkspaceSelectingStepView.kt @@ -12,33 +12,35 @@ package com.github.devspaces.gateway.view.steps import com.github.devspaces.gateway.DevSpacesBundle -import com.github.devspaces.gateway.openshift.DevWorkspaces import com.github.devspaces.gateway.DevSpacesContext +import com.github.devspaces.gateway.openshift.DevWorkspaces import com.github.devspaces.gateway.openshift.Projects import com.github.devspaces.gateway.openshift.Utils -import com.github.devspaces.gateway.view.Dialog import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.RightGap import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.util.minimumHeight import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import java.awt.Component -import java.awt.Font -import javax.swing.DefaultListModel -import javax.swing.JList -import javax.swing.ListCellRenderer +import javax.swing.* +import javax.swing.event.ListSelectionEvent +import javax.swing.event.ListSelectionListener class DevSpacesDevWorkspaceSelectingStepView(private var devSpacesContext: DevSpacesContext) : DevSpacesWizardStep { override val nextActionText = DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.button.next") override val previousActionText = DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.button.previous") + private var listDWDataModel = DefaultListModel() private var listDevWorkspaces = JBList(listDWDataModel) + private lateinit var stopDevWorkspaceButton: JButton + override val component = panel { row { label(DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.title")).applyToComponent { @@ -49,6 +51,13 @@ class DevSpacesDevWorkspaceSelectingStepView(private var devSpacesContext: DevSp cell(JBScrollPane(listDevWorkspaces)).align(AlignX.FILL) } row { + label("").resizableColumn().align(AlignX.FILL) + + stopDevWorkspaceButton = + button(DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.button.stop")) { + stopDevWorkspace() + }.gap(RightGap.SMALL).align(AlignX.RIGHT).component + button(DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.button.refresh")) { refreshDevWorkspaces() }.align(AlignX.RIGHT) @@ -59,12 +68,14 @@ class DevSpacesDevWorkspaceSelectingStepView(private var devSpacesContext: DevSp } override fun onInit() { + listDevWorkspaces.selectionModel.addListSelectionListener(DevWorkspaceSelection()) listDevWorkspaces.cellRenderer = DevWorkspaceListRenderer() + listDevWorkspaces.minimumHeight = 150 listDevWorkspaces.setEmptyText(DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.list.empty_text")) + refreshDevWorkspaces() - if (listDWDataModel.size > 0) { - listDevWorkspaces.selectedIndex = 0 - } + + listDevWorkspaces.selectedIndex = if (listDWDataModel.size > 0) 0 else -1 } override fun onPrevious(): Boolean { @@ -82,28 +93,28 @@ class DevSpacesDevWorkspaceSelectingStepView(private var devSpacesContext: DevSp private fun refreshDevWorkspaces() { listDWDataModel.clear() - try { - val projects = Projects(devSpacesContext.client).list() as Map<*, *> - val projectItems = projects["items"] as List<*> + val projects = Projects(devSpacesContext.client).list() as Map<*, *> + val projectItems = projects["items"] as List<*> - projectItems.forEach { projectItem -> - val name = Utils.getValue(projectItem, arrayOf("metadata", "name")) as String + projectItems.forEach { projectItem -> + val name = Utils.getValue(projectItem, arrayOf("metadata", "name")) as String - val devWorkspaces = DevWorkspaces(devSpacesContext.client).list(name) as Map<*, *> - val devWorkspaceItems = devWorkspaces["items"] as List<*> - devWorkspaceItems.forEach{devWorkspaceItem -> listDWDataModel.addElement(devWorkspaceItem)} - } - } catch (e: Exception) { - val dialog = Dialog( - "Failed to list DevWorkspaces", - String.format("Caused: %s", e.toString()), - component - ) - dialog.show() + val devWorkspaces = DevWorkspaces(devSpacesContext.client).list(name) as Map<*, *> + val devWorkspaceItems = devWorkspaces["items"] as List<*> + devWorkspaceItems.forEach { devWorkspaceItem -> listDWDataModel.addElement(devWorkspaceItem) } } } - class DevWorkspaceListRenderer : ListCellRenderer { + private fun stopDevWorkspace() { + if (listDevWorkspaces.selectedIndex != -1) { + val devWorkspace = listDWDataModel.get(listDevWorkspaces.selectedIndex) + val dwName = Utils.getValue(devWorkspace, arrayOf("metadata", "name")) as String + val dwNamespace = Utils.getValue(devWorkspace, arrayOf("metadata", "namespace")) as String + DevWorkspaces(devSpacesContext.client).stop(dwNamespace, dwName) + } + } + + inner class DevWorkspaceListRenderer : ListCellRenderer { override fun getListCellRendererComponent( list: JList?, devWorkspace: Any, @@ -118,4 +129,18 @@ class DevSpacesDevWorkspaceSelectingStepView(private var devSpacesContext: DevSp return item } } + + inner class DevWorkspaceSelection() : ListSelectionListener { + override fun valueChanged(e: ListSelectionEvent) { + val selectionModel = (e.source as ListSelectionModel) + + if (selectionModel.isSelectionEmpty) { + stopDevWorkspaceButton.isEnabled = false + return + } + + val devWorkspace = listDWDataModel.get(selectionModel.minSelectionIndex) + stopDevWorkspaceButton.isEnabled = Utils.getValue(devWorkspace, arrayOf("spec", "started")) as Boolean + } + } } \ No newline at end of file diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index 6abb7431..eab7cb86 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -14,6 +14,7 @@ connector.wizard_step.openshift_connection.button.next=Check connection and cont connector.wizard_step.devworkspace_selecting.title=Select running DevWorkspace connector.wizard_step.devworkspace_selecting.button.previous=Back connector.wizard_step.devworkspace_selecting.button.next=Connect +connector.wizard_step.devworkspace_selecting.button.stop=Stop connector.wizard_step.devworkspace_selecting.button.refresh=Refresh connector.wizard_step.devworkspace_selecting.list.empty_text=There are no running DevWorkspace