diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt index f6cb7fb6..b5cbb7d0 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt @@ -29,6 +29,8 @@ import com.redhat.devtools.gateway.util.ProgressCountdown import com.redhat.devtools.gateway.util.isCancellationException import com.redhat.devtools.gateway.view.ui.Dialogs import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.openapi.ApiException +import io.kubernetes.client.openapi.models.V1Pod import kotlinx.coroutines.* import java.io.Closeable import java.io.IOException @@ -78,7 +80,8 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { remoteIdeServer = RemoteIDEServer(devSpacesContext) remoteIdeServerStatus = runCatching { - remoteIdeServer.apply { waitServerReady(checkCancelled) }.getStatus() + remoteIdeServer.waitServerReady(checkCancelled) + remoteIdeServer.fetchStatus(checkCancelled) }.getOrElse { e -> if (e.isCancellationException()) throw e RemoteIDEServerStatus.empty() @@ -114,7 +117,12 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { val pods = DevWorkspacePods(devSpacesContext.client) val localPort = findFreePort() - forwarder = pods.forward(remoteIdeServer.pod, localPort, 5990) + forwarder = pods.forward( + { refreshPod(remoteIdeServer) }, + localPort, + 5990, + RemoteIDEServer.readyTimeout, + ) pods.waitForForwardReady(localPort) val effectiveJoinLink = joinLink.replace(":5990", ":$localPort") @@ -172,7 +180,13 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { client } catch (e: Exception) { runCatching { client?.close() } - onClientClosed(client, onDisconnected, onDevWorkspaceStopped, remoteIdeServer, forwarder) + onClientClosed( + client, + onDisconnected, + onDevWorkspaceStopped, + remoteIdeServer, + forwarder + ) throw e } } @@ -264,6 +278,27 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { ) } + private fun refreshPod(remoteIdeServer: RemoteIDEServer?): V1Pod? { + if (remoteIdeServer == null) { + thisLogger().warn("Cannot refresh workspace pod: remote IDE server is not available") + return null + } + return runCatching { remoteIdeServer.refreshPod() } + .onFailure { e -> logPodRefreshFailure(e) } + .getOrNull() + } + + private fun logPodRefreshFailure(e: Throwable) { + when { + e is ApiException -> + thisLogger().warn("Failed to refresh workspace pod: ${e.message}", e) + e is IOException && e.message?.contains("not running", ignoreCase = true) == true -> + thisLogger().info("Workspace pod not ready yet, will retry: ${e.message}") + else -> + thisLogger().info("Failed to refresh workspace pod, will retry: ${e.message}", e) + } + } + private fun findFreePort(): Int { ServerSocket(0).use { socket -> socket.reuseAddress = true diff --git a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt index 1c2a026b..b2f57a67 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePods.kt @@ -19,6 +19,7 @@ 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 +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.* import java.io.Closeable import java.io.IOException @@ -33,8 +34,7 @@ class DevWorkspacePods(private val client: ApiClient) { companion object { const val WORKSPACE_LABEL_KEY = "controller.devfile.io/devworkspace_name" - private const val CONNECT_ATTEMPTS = 5 - private const val RECONNECT_DELAY: Long = 1000 + private const val RECONNECT_DELAY: Long = 1 } private val logger = logger() @@ -135,13 +135,24 @@ class DevWorkspacePods(private val client: ApiClient) { ApiClientUtils.cloneForExec(base) @Throws(IOException::class) - fun forward(pod: V1Pod, localPort: Int, remotePort: Int): Closeable { + fun forward( + fetchPod: suspend () -> V1Pod?, + localPort: Int, + remotePort: Int, + reconnectTimeoutSeconds: Long, + ): Closeable { val serverSocket = ServerSocket(localPort, 50, InetAddress.getLoopbackAddress()) val scope = CoroutineScope( // dont cancel if child coroutine fails + use blocking I/O scope SupervisorJob() + Dispatchers.IO ) - scope.acceptConnections(serverSocket, pod, localPort, remotePort) + scope.acceptConnections( + serverSocket, + fetchPod, + localPort, + remotePort, + reconnectTimeoutSeconds + ) return Closeable { runCatching { serverSocket.close() } scope.cancel() @@ -150,9 +161,10 @@ class DevWorkspacePods(private val client: ApiClient) { private fun CoroutineScope.acceptConnections( serverSocket: ServerSocket, - pod: V1Pod, + fetchPod: suspend () -> V1Pod?, localPort: Int, - remotePort: Int + remotePort: Int, + reconnectTimeout: Long, ) { launch { logger.info("Starting port forward on local port $localPort...") @@ -163,9 +175,10 @@ class DevWorkspacePods(private val client: ApiClient) { launch { handleConnection( clientSocket, - pod, + fetchPod, localPort, - remotePort + remotePort, + reconnectTimeout, ) } } @@ -186,39 +199,52 @@ class DevWorkspacePods(private val client: ApiClient) { private suspend fun CoroutineScope.handleConnection( clientSocket: Socket, - pod: V1Pod, + fetchPod: suspend () -> V1Pod?, localPort: Int, - remotePort: Int + remotePort: Int, + reconnectTimeout: Long, ) { try { - repeat(CONNECT_ATTEMPTS) { attempt -> - if (!isActive) return@repeat + withTimeout(reconnectTimeout.seconds) { + while (isActive) { + if (!clientSocket.isConnected + || clientSocket.isClosed) { + return@withTimeout + } - var forwardResult: PortForward.PortForwardResult? = null - try { - logger.info("Attempt #${attempt + 1}: Connecting $localPort -> $remotePort...") - val portForward = PortForward(client) - forwardResult = portForward.forward(pod, listOf(remotePort)) - logger.info("forward successful: $localPort -> $remotePort...") - copyStreams(clientSocket, forwardResult, remotePort) - return - } catch (e: Exception) { - if (e.isCancellationException()) throw e - logger.info( - "Could not port forward $localPort -> $remotePort: ${e.message}. Retrying in ${RECONNECT_DELAY}ms..." - ) - if (isActive) { - delay(RECONNECT_DELAY) + val pod = fetchPod() + if (pod == null) { + delay(RECONNECT_DELAY.seconds) + continue + } + + var forwardResult: PortForward.PortForwardResult? = null + try { + logger.info( + "Connecting $localPort -> $remotePort to pod ${pod.metadata?.name}..." + ) + val portForward = PortForward(client) + forwardResult = portForward.forward(pod, listOf(remotePort)) + logger.info("forward successful: $localPort -> $remotePort...") + copyStreams(clientSocket, forwardResult, remotePort) + return@withTimeout + } catch (e: Exception) { + if (e.isCancellationException()) throw e + logger.info("Could not port forward $localPort -> $remotePort: ${e.message}. Retrying in ${RECONNECT_DELAY}s...") + delay(RECONNECT_DELAY.seconds) + } finally { + closeStreams(remotePort, forwardResult) } - } finally { - closeStreams(remotePort, forwardResult) } } - } catch(e: Exception) { + } catch (_: TimeoutCancellationException) { + logger.warn("Could not port forward using port $localPort -> $remotePort within ${reconnectTimeout}s") + } catch (e: Exception) { if (e.isCancellationException()) throw e logger.warn( - "Could not port forward to pod ${pod.metadata?.name} using port $localPort -> $remotePort", - e) + "Could not port forward using port $localPort -> $remotePort", + e + ) } finally { runCatching { clientSocket.close() } } @@ -295,7 +321,22 @@ class DevWorkspacePods(private val client: ApiClient) { @Throws(ApiException::class) fun findFirst(namespace: String, labelSelector: String): V1Pod? { val pods = list(namespace, labelSelector) - return pods.items[0] + return pods.items.firstOrNull() + } + + @Throws(ApiException::class) + fun findFirstRunning(namespace: String, labelSelector: String): V1Pod? { + val pods = list(namespace, labelSelector) + return pods.items.firstOrNull { isRunningAndReady(it) } + } + + private fun isRunningAndReady(pod: V1Pod): Boolean { + if (pod.status?.phase != "Running") { + return false + } + return pod.status?.conditions?.any { condition -> + condition.type == "Ready" && condition.status == "True" + } == true } @Throws(ApiException::class) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt index d66ad3fc..afe7ca2d 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt @@ -28,25 +28,43 @@ import kotlin.time.Duration.Companion.seconds * Represent an IDE server running in a CDE. */ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { - var pod: V1Pod - private var container: V1Container + + private var cachedPod: V1Pod? = null companion object { var readyTimeout: Long = 60 // seconds } - init { - pod = findPod() - container = findContainer() + /** + * Returns the cached workspace pod, or fetches it from the cluster if not yet cached. + */ + @Throws(IOException::class) + fun fetchPod(): V1Pod { + cachedPod?.let { return it } + return refreshPod() } /** - * Asks the CDE for the remote IDE server status. + * Re-queries the cluster for the workspace pod and updates the cache. */ - @Throws(CancellationException::class) - suspend fun getStatus(checkCancelled: (() -> Unit)? = null): RemoteIDEServerStatus = + @Throws(IOException::class) + fun refreshPod(): V1Pod = findPod().also { cachedPod = it } + + /** + * Returns the IDE container from the workspace pod. + */ + @Throws(IOException::class) + fun fetchContainer(): V1Container = findContainer(fetchPod()) + + /** + * Fetches the workspace pod and IDE container, then asks the CDE for the remote IDE server status. + */ + @Throws(CancellationException::class, IOException::class) + suspend fun fetchStatus(checkCancelled: (() -> Unit)? = null): RemoteIDEServerStatus = withContext(Dispatchers.IO) { checkCancelled?.invoke() + val pod = fetchPod() + val container = findContainer(pod) val output = DevWorkspacePods(devSpacesContext.client).exec( pod = pod, container = container.name, @@ -93,7 +111,7 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { checkCancelled: (() -> Unit)? = null ): Boolean { return try { - getStatus(checkCancelled).isReady == isReadyState + fetchStatus(checkCancelled).isReady == isReadyState } catch (e: Exception) { if (e.isCancellationException()) throw e thisLogger().debug("Failed to check workspace IDE state.", e) @@ -134,21 +152,22 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { false } ?: false + private fun labelSelector(): String = + "${DevWorkspacePods.WORKSPACE_LABEL_KEY}=${devSpacesContext.devWorkspace.name}" + @Throws(IOException::class) private fun findPod(): V1Pod { - val selector = "controller.devfile.io/devworkspace_name=${devSpacesContext.devWorkspace.name}" - return DevWorkspacePods(devSpacesContext.client) - .findFirst( + .findFirstRunning( devSpacesContext.devWorkspace.namespace, - selector + labelSelector() ) ?: throw IOException( "DevWorkspace '${devSpacesContext.devWorkspace.name}' is not running.", ) } @Throws(IOException::class) - private fun findContainer(): V1Container { + private fun findContainer(pod: V1Pod): V1Container { return pod.spec!!.containers.find { container -> container.ports?.any { port -> port.name == "idea-server" } != null } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt index fe058afe..54ababbe 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt @@ -326,7 +326,7 @@ class DevSpacesWorkspacesStepView( val remoteIdeServer = RemoteIDEServer(devSpacesContext) status = runBlocking { remoteIdeServer.waitServerReady(checkCancelled) - remoteIdeServer.getStatus() + remoteIdeServer.fetchStatus(checkCancelled) } } catch (e: Exception) { if (e.isCancellationException()) { diff --git a/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt index 7f48f2b2..4589ddc0 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/openshift/DevWorkspacePodsTest.kt @@ -26,8 +26,10 @@ import io.mockk.slot import io.mockk.unmockkConstructor import io.mockk.verify import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import java.util.concurrent.atomic.AtomicInteger import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.AfterEach @@ -35,6 +37,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.Closeable import java.io.IOException import java.net.ServerSocket import java.net.Socket @@ -76,9 +79,13 @@ class DevWorkspacePodsTest { fun `#forward copies from server to client`() { // given val portForwardResult = mockk(relaxed = true) + val connectionReady = CompletableDeferred() every { anyConstructed().forward(pod, listOf(remotePort)) - } returns portForwardResult + } answers { + connectionReady.complete(Unit) + portForwardResult + } val serverIn = ByteArrayInputStream(serverData.toByteArray()) every { portForwardResult.getInputStream(remotePort) @@ -89,44 +96,203 @@ class DevWorkspacePodsTest { } returns serverOut // when - val closeable = pods.forward(pod, localPort, remotePort) - - // then - // wait for the server to start - runBlocking { delay(100.milliseconds) } - - closeable.use { closeable -> - // Verify that data from server input stream is received by client - val bytesRead = sendClientData("ping") // Send data to trigger server response + val closeable = forwardPod() + + // then — keep socket open for data exchange, then close + runBlocking { + val socket = Socket("127.0.0.1", localPort) + connectionReady.await() + socket.outputStream.write("ping".toByteArray()) + socket.outputStream.flush() + val bytesRead = socket.inputStream.read(buffer) assertThat(String(buffer, 0, bytesRead)).isEqualTo(serverData) + socket.close() } + + closeable.use {} } @Test fun `#forward tries several times if connecting fails`() { // given + val forwardCount = AtomicInteger(0) + val handlerFinished = CompletableDeferred() every { anyConstructed().forward(pod, listOf(remotePort)) - } throws mockk(relaxed = true) + } answers { + forwardCount.incrementAndGet() + if (forwardCount.get() >= 2) handlerFinished.complete(Unit) + throw IOException("unavailable") + } // when - val closeable = pods.forward(pod, localPort, remotePort) + val closeable = pods.forward( + fetchPod = { + if (forwardCount.get() >= 2) throw SignalExit() else pod + }, + localPort = localPort, + remotePort = remotePort, + reconnectTimeoutSeconds = 30, + ) + + runBlocking { + delay(100.milliseconds) + Socket("127.0.0.1", localPort).use { it.close() } + handlerFinished.await() + } // then - // wait for the server to start - runBlocking { delay(100.milliseconds) } - Socket("127.0.0.1", localPort).apply { - close() // trigger retry + closeable.use { + verify(atLeast = 2) { + anyConstructed().forward(pod, listOf(remotePort)) + } } - runBlocking { delay(6000.milliseconds) } // 5 attempts * 1 second + } - closeable.use { closeable -> - verify(atLeast = 2) { // 2+ retries - anyConstructed().forward(pod, listOf(remotePort)) + @Test + fun `#forward reconnects to new pod after stream failure`() { + // given + val podA = podNamed("pod-a") + val podB = podNamed("pod-b") + var resolveCall = 0 + val connectionReady = CompletableDeferred() + val portForwardResult = mockk(relaxed = true) + every { + portForwardResult.getInputStream(remotePort) + } answers { + connectionReady.complete(Unit) // signal that successful forward is established + ByteArrayInputStream(serverData.toByteArray()) + } + every { + portForwardResult.getOutboundStream(remotePort) + } returns ByteArrayOutputStream() + every { + anyConstructed().forward(any(), listOf(remotePort)) + } answers { + when ((args[0] as V1Pod).metadata?.name) { + "pod-a" -> throw IOException("pod unavailable") + else -> portForwardResult + } + } + + // when — cap resolveCall so all connections after first get podB + val closeable = pods.forward( + fetchPod = { + when (resolveCall++) { + 0 -> podA + else -> podB + } + }, + localPort = localPort, + remotePort = remotePort, + reconnectTimeoutSeconds = 10, + ) + + // Connect client, wait for forward to succeed, verify data flow, then close + runBlocking { + val socket = Socket("127.0.0.1", localPort) + connectionReady.await() // waits until forward succeeds (podB) + // Send data and read response + socket.outputStream.write("ping".toByteArray()) + socket.outputStream.flush() + val bytesRead = socket.inputStream.read(buffer) + assertThat(String(buffer, 0, bytesRead)).isEqualTo(serverData) + socket.close() + } + + // then + closeable.use { + verify(atLeast = 2) { + anyConstructed().forward(any(), listOf(remotePort)) + } + } + } + + @Test + fun `#forward stops retrying after reconnectTimeoutSeconds`() { + // given — forward mock signals after 2 calls; fetchPod exits on signal + val forwardCount = AtomicInteger(0) + val stopRetry = CompletableDeferred() + val handlerFinished = CompletableDeferred() + + every { + anyConstructed().forward(any(), listOf(remotePort)) + } answers { + val count = forwardCount.incrementAndGet() + if (count >= 2 && !handlerFinished.isCompleted) handlerFinished.complete(Unit) + throw IOException("unavailable") + } + + val closeable = pods.forward( + fetchPod = { + if (stopRetry.isCompleted) throw SignalExit() else pod + }, + localPort = localPort, + remotePort = remotePort, + reconnectTimeoutSeconds = 4, + ) + + runBlocking { + delay(100.milliseconds) + Socket("127.0.0.1", localPort).use { it.close() } + handlerFinished.await() // wait for 2 forward calls + stopRetry.complete(Unit) // signal fetchPod to exit on next retry + delay(2500.milliseconds) // let retry loop process the signal (delay + fetchPod) + } + + // then — retries bounded by timeout (2 attempts with 4s timeout / 2s delay) + closeable.use { + assertThat(forwardCount.get()).isLessThanOrEqualTo(2) + verify(atMost = 2) { + anyConstructed().forward(any(), listOf(remotePort)) } } } + @Test + fun `#forward stops retrying when client disconnects`() { + // given — signal after first forward call, then fetchPod exits on next retry + val firstForwardDone = CompletableDeferred() + every { + anyConstructed().forward(any(), listOf(remotePort)) + } answers { + if (!firstForwardDone.isCompleted) firstForwardDone.complete(Unit) + throw IOException("unavailable") + } + + val closeable = pods.forward( + fetchPod = { + if (firstForwardDone.isCompleted) throw SignalExit() else pod + }, + localPort = localPort, + remotePort = remotePort, + reconnectTimeoutSeconds = 30, + ) + + runBlocking { + Socket("127.0.0.1", localPort).use { it.close() } + firstForwardDone.await() // wait for first forward call + delay(3000.milliseconds) // let retry loop process signal (delay + fetchPod) + } + + // then + closeable.use { + verify(exactly = 1) { + anyConstructed().forward(any(), listOf(remotePort)) + } + } + } + + private fun forwardPod(reconnectTimeoutSeconds: Long = 30L): Closeable = + pods.forward({ pod }, localPort, remotePort, reconnectTimeoutSeconds) + + /** Exception used by tests to signal the retry loop to exit deterministically. */ + private class SignalExit : RuntimeException() + + private fun podNamed(name: String): V1Pod = V1Pod().apply { + metadata = V1ObjectMeta().apply { this.name = name } + } + private fun sendClientData(data: String): Int { Socket("127.0.0.1", localPort).use { // client to server diff --git a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt index 76215d3c..2eac926c 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt @@ -16,7 +16,9 @@ import com.redhat.devtools.gateway.openshift.DevWorkspacePods import io.kubernetes.client.openapi.models.V1Container import io.kubernetes.client.openapi.models.V1ObjectMeta import io.kubernetes.client.openapi.models.V1Pod +import io.kubernetes.client.openapi.models.V1PodCondition import io.kubernetes.client.openapi.models.V1PodSpec +import io.kubernetes.client.openapi.models.V1PodStatus import io.mockk.* import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat @@ -31,31 +33,16 @@ class RemoteIDEServerTest { private lateinit var devSpacesContext: DevSpacesContext private lateinit var remoteIDEServer: RemoteIDEServer + private lateinit var mockPod: V1Pod @BeforeEach fun beforeEach() { devSpacesContext = mockk(relaxed = true) mockkConstructor(DevWorkspacePods::class) - val mockPod = V1Pod().apply { - metadata = V1ObjectMeta().apply { - name = "test-pod" - } - spec = V1PodSpec().apply { - containers = listOf( - V1Container().apply { - name = "test-container" - ports = listOf( - mockk(relaxed = true) { - every { name } returns "idea-server" - } - ) - } - ) - } - } - coEvery { - anyConstructed().findFirst(any(), any()) + mockPod = runningPod("test-pod") + every { + anyConstructed().findFirstRunning(any(), any()) } returns mockPod remoteIDEServer = spyk(RemoteIDEServer(devSpacesContext), recordPrivateCalls = true) @@ -66,6 +53,58 @@ class RemoteIDEServerTest { unmockkAll() } + @Test + fun `#constructor does not query cluster`() { + verify(exactly = 0) { + anyConstructed().findFirstRunning(any(), any()) + } + } + + @Test + fun `#fetchPod queries cluster and returns cached pod on subsequent calls`() { + val first = remoteIDEServer.fetchPod() + val second = remoteIDEServer.fetchPod() + + assertThat(first.metadata?.name).isEqualTo("test-pod") + assertThat(second).isSameAs(first) + verify(exactly = 1) { + anyConstructed().findFirstRunning(any(), any()) + } + } + + @Test + fun `#fetchContainer returns IDE container from workspace pod`() { + val container = remoteIDEServer.fetchContainer() + + assertThat(container.name).isEqualTo("test-container") + verify(exactly = 1) { + anyConstructed().findFirstRunning(any(), any()) + } + } + + @Test + fun `#refreshPod re-queries cluster and returns new pod when it changes`() { + val firstPod = runningPod("pod-v1") + val secondPod = runningPod("pod-v2") + every { + anyConstructed().findFirstRunning(any(), any()) + } returnsMany listOf(firstPod, secondPod) + + assertThat(remoteIDEServer.refreshPod().metadata?.name).isEqualTo("pod-v1") + assertThat(remoteIDEServer.refreshPod().metadata?.name).isEqualTo("pod-v2") + } + + @Test + fun `#refreshPod throws when no running pod exists`() { + every { + anyConstructed().findFirstRunning(any(), any()) + } returns null + + assertThrows { + remoteIDEServer.refreshPod() + } + } + @Test fun `#waitServerReady should reach timeout and throw if server status has no join link`() { // given @@ -76,7 +115,7 @@ class RemoteIDEServerTest { ) ) coEvery { - remoteIDEServer.getStatus() + remoteIDEServer.fetchStatus() } returns withoutJoinLink // when, then @@ -95,7 +134,7 @@ class RemoteIDEServerTest { null ) coEvery { - remoteIDEServer.getStatus() + remoteIDEServer.fetchStatus() } returns withoutProjects // when, then @@ -116,7 +155,7 @@ class RemoteIDEServerTest { ) ) coEvery { - remoteIDEServer.getStatus() + remoteIDEServer.fetchStatus() } returns withoutJoinLink // when @@ -136,7 +175,7 @@ class RemoteIDEServerTest { null ) coEvery { - remoteIDEServer.getStatus() + remoteIDEServer.fetchStatus() } returns withoutProjects // when @@ -152,7 +191,7 @@ class RemoteIDEServerTest { fun `#waitServerTerminated should return false on timeout`() { // given coEvery { - remoteIDEServer.getStatus() + remoteIDEServer.fetchStatus() } returns remoteIDEServerStatus( // running server has join link and projects "https://starwars.galaxy?peridea", @@ -174,7 +213,7 @@ class RemoteIDEServerTest { fun `#waitServerTerminated should return false on exception`() { // given coEvery { - remoteIDEServer.getStatus() + remoteIDEServer.fetchStatus() } throws IOException("error") // when @@ -186,6 +225,32 @@ class RemoteIDEServerTest { assertThat(result).isFalse } + private fun runningPod(name: String): V1Pod { + return V1Pod().apply { + metadata = V1ObjectMeta().apply { + this.name = name + uid = name + } + spec = V1PodSpec().apply { + containers = listOf( + V1Container().apply { + this.name = "test-container" + ports = listOf(mockk(relaxed = true)) + } + ) + } + status = V1PodStatus().apply { + phase = "Running" + conditions = listOf( + V1PodCondition().apply { + type = "Ready" + status = "True" + } + ) + } + } + } + private fun remoteIDEServerStatus(joinLink: String? = null, projects: Array?): RemoteIDEServerStatus { return RemoteIDEServerStatus( joinLink,