From 52e16ead586291f9173338d611490fc2e6b14cb0 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 16 Sep 2026 22:44:21 +0300 Subject: [PATCH 1/2] Include Coder support bundles in workspace diagnostics Workspace log archives previously lacked Coder deployment and workspace diagnostics, so troubleshooting connection issues required collecting a separate support bundle. In this change, we added a Coder support bundle to the Toolbox ZIP generated by an environment's Collect logs action. The plugin uses the existing deployment login to collect diagnostics for the selected workspace and its agent, when available. Collection supports cancellation and has a two-minute timeout. If it fails, the plugin preserves the other Toolbox diagnostics and adds a failure report instead of a partial bundle. We also documented where bundle collection is available and added tests for collection, cancellation, timeout, and cleanup. --- CHANGELOG.md | 8 +- README.md | 21 +++ .../coder/toolbox/CoderRemoteEnvironment.kt | 7 + .../com/coder/toolbox/cli/CoderCLIManager.kt | 44 ++++++ .../CoderSupportBundleCollector.kt | 39 ++++++ .../toolbox/CoderRemoteEnvironmentTest.kt | 39 ++++++ .../coder/toolbox/cli/CoderCLIManagerTest.kt | 131 +++++++++++++++++- .../CoderSupportBundleCollectorTest.kt | 53 +++++++ 8 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt create mode 100644 src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2962f020..284b099f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Added + +- include a Coder support bundle for the selected workspace and agent only when using the environment's + _Collect logs_ action in Toolbox + ### Changed - upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2 @@ -12,7 +17,8 @@ - support for configuring the SSH config path, letting Toolbox manage a separate SSH config file instead of always writing to `~/.ssh/config` -- the header command falls back to the `CODER_HEADER_COMMAND` environment variable when the setting is blank, matching the Coder CLI and the VS Code extension +- the header command falls back to the `CODER_HEADER_COMMAND` environment variable when the setting is blank, matching + the Coder CLI and the VS Code extension ### Fixed diff --git a/README.md b/README.md index 498f6483..8909ab13 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,27 @@ About > Show log files. Alternatively, you can generate a ZIP file using the Workspace action menu, available either on the main Workspaces page in Coder or within the individual workspace view, under the option labeled _Collect logs_. +### Coder Support Bundles + +The plugin includes a Coder support bundle **only when you use the environment's _Collect logs_ action** in Toolbox. +Open the action menu for the environment on Coder's Workspaces page or within its workspace view, then select +_Collect logs_. The general **Settings > About > Collect logs and diagnostic data** action does not generate a +Coder support bundle. + +The plugin runs `coder support bundle` using the deployment's existing CLI login and targets the selected workspace +and its agent, when available. It places `coder-support.zip` inside the environment's diagnostic directory in the +Toolbox log archive. Collection does not start a stopped workspace. + +The bundle can contain deployment health and configuration, network diagnostics, workspace build logs, template +source, and agent diagnostics. Available information depends on your permissions and workspace connectivity. +Review the bundle before sharing, following +[Coder's support-bundle guidance](https://coder.com/docs/support/support-bundle). + +Support bundles require a Coder CLI that supports `coder support bundle` (Coder 2.10 or newer). Collection has a +two-minute timeout and stops if you cancel log collection. If the CLI is unavailable or unsupported, authentication +or connectivity fails, or collection times out, the plugin removes any partial bundle and attempts to include +`coder-support-error.txt` instead. Toolbox's other diagnostics remain available. + ### HTTP Request Logging The Coder Toolbox plugin includes comprehensive HTTP request logging capabilities to help diagnose API communication diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 6df71fc6..9b142a24 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -4,6 +4,7 @@ import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.SshCommandProcessHandle import com.coder.toolbox.cli.WorkspaceAddress +import com.coder.toolbox.diagnostics.CoderSupportBundleCollector import com.coder.toolbox.models.WorkspaceAndAgentStatus import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException @@ -24,6 +25,7 @@ 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.deploy.DiagnosticInfoCollector import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState @@ -101,6 +103,11 @@ class CoderRemoteEnvironment( internal fun currentSessionId(): SessionId? = agent?.let { SessionIdRegistry.findSession(workspace.name, it.name) } + override val diagnosticInfoCollector: DiagnosticInfoCollector = + CoderSupportBundleCollector(context.logger) { outputFile -> + cli.supportBundle(WorkspaceAddress.from(workspace, agent), outputFile) + } + private fun refreshAvailableActions() { val actions = mutableListOf() context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus") diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 9dca7450..23b31a03 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -23,6 +23,7 @@ import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonDataException import com.squareup.moshi.Moshi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.zeroturnaround.exec.ProcessExecutor import retrofit2.Retrofit @@ -31,6 +32,8 @@ import java.io.FileNotFoundException import java.net.URL import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException /** * Version output from the CLI's version command. @@ -585,6 +588,47 @@ class CoderCLIManager( return stdout } + /** Generates a support bundle for the workspace and optional agent, saving it to [outputFile]. */ + internal suspend fun supportBundle(address: WorkspaceAddress, outputFile: Path) { + val command = listOfNotNull( + localBinaryPath.toString(), + "--global-config", coderConfigPath.toString(), + "--url", deploymentURL.toString(), + "support", "bundle", "--yes", "--output-file", outputFile.toAbsolutePath().toString(), + "--", address.ownerAndWsName, address.agentName, + ) + runSupportBundleProcess(command) + check(Files.isRegularFile(outputFile) && Files.size(outputFile) > 0) { + "Coder CLI did not produce a support bundle" + } + } + + /** Runs the support-bundle command with a timeout and terminates it on cancellation. */ + internal suspend fun runSupportBundleProcess( + command: List, + timeoutSeconds: Long = 120, + ) = runInterruptible(Dispatchers.IO) { + val builder = ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + // An inherited token must not override the selected deployment's stored login. + builder.environment().remove("CODER_SESSION_TOKEN") + builder.environment().remove("CODER_HEADER_COMMAND") + context.settingsStore.headerCommand?.let { builder.environment()["CODER_HEADER_COMMAND"] = it } + val process = builder.start() + try { + process.outputStream.close() + if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + throw TimeoutException("Coder support bundle collection timed out") + } + check(process.exitValue() == 0) { + "Coder support bundle failed with exit code ${process.exitValue()}" + } + } finally { + if (process.isAlive) process.destroyForcibly() + } + } + val features: Features get() { val version = tryVersion() diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt new file mode 100644 index 00000000..77e4c0b8 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt @@ -0,0 +1,39 @@ +package com.coder.toolbox.diagnostics + +import com.jetbrains.toolbox.api.remoteDev.deploy.DiagnosticInfoCollector +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Files +import java.nio.file.Path + +/** Collects a Coder support bundle for a workspace and its optional agent. */ +internal class CoderSupportBundleCollector( + private val logger: CoderLogger, + private val collectBundle: suspend (Path) -> Unit, +) : DiagnosticInfoCollector { + // Toolbox discards the additional-diagnostics directory if the collector throws. + @Suppress("TooGenericExceptionCaught") + override suspend fun collectAdditionalDiagnostics(logsRootFolder: Path) = withContext(Dispatchers.IO) { + val bundle = logsRootFolder.resolve("coder-support.zip") + try { + Files.createDirectories(logsRootFolder) + collectBundle(bundle) + } catch (ex: CancellationException) { + runCatching { Files.deleteIfExists(bundle) } + throw ex + } catch (ex: Exception) { + logger.warn(ex, "Could not collect the Coder support bundle") + runCatching { + Files.deleteIfExists(bundle) + Files.writeString( + logsRootFolder.resolve("coder-support-error.txt"), + "Coder support bundle collection failed (${ex.javaClass.simpleName}). " + + "Check deployment connectivity, login, and CLI support for 'coder support bundle' " + + "(Coder 2.10 or newer). Other Toolbox logs are still available.\n", + ) + }.onFailure { logger.warn(it, "Could not write the Coder diagnostic failure report") } + } + Unit + } +} diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt index 9e186fac..7fc90542 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt @@ -19,6 +19,8 @@ import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.ui.ToolboxUi import io.mockk.Called import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -28,6 +30,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import java.nio.file.Files import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -37,6 +40,42 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class CoderRemoteEnvironmentTest { + @Test + fun `diagnostic collector uses current workspace agent and CLI after refresh`() = runTest { + val fixture = fixture(backgroundScope) + val collector = fixture.environment.diagnosticInfoCollector + val refreshedCli = mockk(relaxed = true) + val updatedAgent = fixture.agent.copy(name = "updated-agent") + fixture.environment.update( + fixture.workspace.copy(latestBuild = fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING)), + updatedAgent, + ) + fixture.environment.updateClientAndCli(mockk(relaxed = true), refreshedCli) + val root = Files.createTempDirectory("coder-diagnostics-test") + try { + coEvery { refreshedCli.supportBundle(any(), any()) } answers { + Files.writeString(secondArg(), "bundle") + Unit + } + collector.collectAdditionalDiagnostics(root) + coVerify(exactly = 1) { + refreshedCli.supportBundle( + match { + it.ownerAndWsName == "${fixture.workspace.ownerName}/${fixture.workspace.name}" && + it.agentName == "updated-agent" + }, + root.resolve("coder-support.zip"), + ) + } + fixture.environment.update(fixture.workspace, null) + collector.collectAdditionalDiagnostics(root) + coVerify(exactly = 1) { refreshedCli.supportBundle(match { it.agentName == null }, any()) } + } finally { + root.toFile().deleteRecursively() + fixture.environment.dispose() + } + } + @Test fun `auto-connect requests SSH while the environment is initialized`() = runTest { val fixture = fixture(backgroundScope, autoConnect = true) diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index fbbaf6a1..475efd88 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -49,12 +49,6 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.assertDoesNotThrow -import org.zeroturnaround.exec.InvalidExitValueException -import org.zeroturnaround.exec.ProcessInitException import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.Proxy @@ -62,8 +56,10 @@ import java.net.ProxySelector import java.net.URI import java.net.URL import java.nio.file.AccessDeniedException +import java.nio.file.Files import java.nio.file.Path import java.util.UUID +import java.util.concurrent.TimeoutException import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains @@ -72,11 +68,134 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS.LINUX +import org.junit.jupiter.api.condition.OS.MAC +import org.junit.jupiter.api.io.TempDir +import org.zeroturnaround.exec.InvalidExitValueException +import org.zeroturnaround.exec.ProcessInitException private const val VERSION_FOR_PROGRESS_REPORTING = "v2.13.1-devel+de07351b8" private val noOpTextProgress: (String) -> Unit = { _ -> } internal class CoderCLIManagerTest { + private val supportBundleCli get() = CoderCLIManager(context, URI("https://coder.example.test").toURL()) + + @TempDir + lateinit var supportBundleProcessRoot: Path + + private fun waitingCommand(): List = listOf( + "/bin/sh", "-c", "echo $$ > \"\$1\"; exec sleep 30", "test", supportBundleProcessRoot.resolve("pid").toString(), + ) + + private suspend fun awaitProcess(): ProcessHandle = withTimeout(5_000) { + val pidFile = supportBundleProcessRoot.resolve("pid") + while (!Files.exists(pidFile) || Files.size(pidFile) == 0L) delay(10) + ProcessHandle.of(Files.readString(pidFile).trim().toLong()).orElseThrow() + } + + private suspend fun awaitExit(process: ProcessHandle) = withTimeout(5_000) { + while (process.isAlive) delay(10) + } + + @Test + @EnabledOnOs(LINUX, MAC) + fun `cancellation terminates the running CLI`() = runBlocking { + val job = launch(Dispatchers.Default) { supportBundleCli.runSupportBundleProcess(waitingCommand()) } + val process = awaitProcess() + try { + job.cancelAndJoin() + awaitExit(process) + } finally { + job.cancelAndJoin() + process.destroyForcibly() + } + } + + @Test + @EnabledOnOs(LINUX, MAC) + fun `timeout terminates the running CLI`() = runBlocking { + val job = launch(Dispatchers.Default) { + assertFailsWith { + supportBundleCli.runSupportBundleProcess(waitingCommand(), timeoutSeconds = 1) + } + } + val process = awaitProcess() + try { + job.join() + awaitExit(process) + } finally { + job.cancelAndJoin() + process.destroyForcibly() + } + } + + @Test + @EnabledOnOs(LINUX, MAC) + fun `unsupported CLI exit is reported`() = runBlocking { + assertFailsWith { + supportBundleCli.runSupportBundleProcess(listOf("/bin/sh", "-c", "exit 1")) + } + } + + @Test + @EnabledOnOs(LINUX, MAC) + fun `support bundle scopes deployment workspace and optional agent without shell interpolation`() = runBlocking { + val root = java.nio.file.Files.createTempDirectory("coder bundle test") + val binary = root.resolve("coder") + binary.toFile().writeText( + """#!/bin/sh + |for arg in "${'$'}@"; do + | if [ "${'$'}previous" = "--output-file" ]; then output="${'$'}arg"; fi + | previous="${'$'}arg" + |done + |printf '%s\n' "${'$'}@" > "${'$'}output" + |printf '%s' "${'$'}CODER_HEADER_COMMAND" > "${'$'}output.header" + |""".trimMargin(), + ) + binary.toFile().setExecutable(true) + val settings = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_DESTINATION to binary.toString(), + ENABLE_DOWNLOADS to "false", + DATA_DIRECTORY to root.toString(), + HEADER_COMMAND to "custom header", + ), + Environment(), + context.logger, + ) + try { + val url = "https://coder.example.test".toURL() + val cli = CoderCLIManager(context.copy(settingsStore = settings), url) + val ws = workspace("diagnostic-workspace", agents = mapOf("main" to UUID.randomUUID().toString())) + val agent = ws.latestBuild.resources.flatMap { it.agents.orEmpty() }.first() + for (selectedAgent in listOf(agent, null)) { + val output = root.resolve("bundle with spaces.zip") + cli.supportBundle(WorkspaceAddress.from(ws, selectedAgent), output) + assertEquals( + listOfNotNull( + "--global-config", cli.coderConfigPath.toString(), "--url", url.toString(), + "support", "bundle", "--yes", "--output-file", output.toString(), + "--", "${ws.ownerName}/${ws.name}", selectedAgent?.name, + ), + output.toFile().readLines(), + ) + assertEquals("custom header", root.resolve("bundle with spaces.zip.header").toFile().readText()) + } + } finally { + root.toFile().deleteRecursively() + } + } + private val ui = mockk(relaxed = true) private val underlyingLogger = mockk(relaxed = true) private val context = CoderToolboxContext( diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt new file mode 100644 index 00000000..13eb7bbc --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.diagnostics + +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoderSupportBundleCollectorTest { + @TempDir + lateinit var root: Path + + @Test + fun `writes the bundle inside Toolbox diagnostics`() = runTest { + val collector = CoderSupportBundleCollector(mockk(relaxed = true)) { + Files.writeString(it, "bundle") + } + collector.collectAdditionalDiagnostics(root.resolve("diagnostics")) + assertEquals("bundle", Files.readString(root.resolve("diagnostics/coder-support.zip"))) + } + + @Test + fun `failure removes partial bundle and preserves other diagnostics without leaking error details`() = runTest { + Files.writeString(root.resolve("other.log"), "other diagnostics") + val collector = CoderSupportBundleCollector(mockk(relaxed = true)) { + Files.writeString(it, "partial") + error("sensitive command output") + } + collector.collectAdditionalDiagnostics(root) + assertFalse(Files.exists(root.resolve("coder-support.zip"))) + assertEquals("other diagnostics", Files.readString(root.resolve("other.log"))) + val report = Files.readString(root.resolve("coder-support-error.txt")) + assertTrue(report.contains("IllegalStateException")) + assertFalse(report.contains("sensitive command output")) + } + + @Test + fun `cancellation propagates and removes partial output`() = runTest { + val collector = CoderSupportBundleCollector(mockk(relaxed = true)) { + Files.writeString(it, "partial") + throw CancellationException("cancelled") + } + assertFailsWith { collector.collectAdditionalDiagnostics(root) } + assertFalse(Files.exists(root.resolve("coder-support.zip"))) + assertFalse(Files.exists(root.resolve("coder-support-error.txt"))) + } +} From 76ab01c3c7b4c46d302a157fd4f51706345d1374 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 17 Sep 2026 23:10:21 +0300 Subject: [PATCH 2/2] Remove the support-bundle timeout and preserve inherited credentials The fixed two-minute limit could interrupt a valid diagnostic collection, and the plugin discarded an inherited session token that would normally take precedence over stored credentials. We changed support-bundle collection to wait until the Coder CLI finishes or the user cancels. The plugin still terminates the process on cancellation, while the CLI can now use inherited credentials according to its normal precedence. We also removed redundant header-command cleanup without changing the existing header configuration behavior. We updated the README to describe the revised behavior and removed the obsolete timeout test. The CLI manager and diagnostic collector tests passed, including cancellation and process cleanup coverage. --- README.md | 10 ++-- .../com/coder/toolbox/cli/CoderCLIManager.kt | 12 +---- .../coder/toolbox/cli/CoderCLIManagerTest.kt | 49 ++++++------------- 3 files changed, 22 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 8909ab13..0a9ba2f1 100644 --- a/README.md +++ b/README.md @@ -386,8 +386,8 @@ in Coder or within the individual workspace view, under the option labeled _Coll ### Coder Support Bundles The plugin includes a Coder support bundle **only when you use the environment's _Collect logs_ action** in Toolbox. -Open the action menu for the environment on Coder's Workspaces page or within its workspace view, then select -_Collect logs_. The general **Settings > About > Collect logs and diagnostic data** action does not generate a +Open the action menu for the environment on Coder's Workspaces page or within its workspace view, then select _Collect +logs_. The general **Settings > About > Collect logs and diagnostic data** action does not generate a Coder support bundle. The plugin runs `coder support bundle` using the deployment's existing CLI login and targets the selected workspace @@ -399,9 +399,9 @@ source, and agent diagnostics. Available information depends on your permissions Review the bundle before sharing, following [Coder's support-bundle guidance](https://coder.com/docs/support/support-bundle). -Support bundles require a Coder CLI that supports `coder support bundle` (Coder 2.10 or newer). Collection has a -two-minute timeout and stops if you cancel log collection. If the CLI is unavailable or unsupported, authentication -or connectivity fails, or collection times out, the plugin removes any partial bundle and attempts to include +Support bundles require a Coder CLI that supports `coder support bundle` (Coder 2.10 or newer). Collection runs until +the CLI finishes or you cancel log collection. If the CLI is unavailable or unsupported, or authentication or +connectivity fails, the plugin removes any partial bundle and attempts to include `coder-support-error.txt` instead. Toolbox's other diagnostics remain available. ### HTTP Request Logging diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 4d08b508..240ac88e 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -32,8 +32,6 @@ import java.io.FileNotFoundException import java.net.URL import java.nio.file.Files import java.nio.file.Path -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException /** * Version output from the CLI's version command. @@ -617,24 +615,18 @@ class CoderCLIManager( } } - /** Runs the support-bundle command with a timeout and terminates it on cancellation. */ + /** Runs the support-bundle command and terminates it on cancellation. */ internal suspend fun runSupportBundleProcess( command: List, - timeoutSeconds: Long = 120, ) = runInterruptible(Dispatchers.IO) { val builder = ProcessBuilder(command) .redirectOutput(ProcessBuilder.Redirect.DISCARD) .redirectError(ProcessBuilder.Redirect.DISCARD) - // An inherited token must not override the selected deployment's stored login. - builder.environment().remove("CODER_SESSION_TOKEN") - builder.environment().remove("CODER_HEADER_COMMAND") context.settingsStore.headerCommand?.let { builder.environment()["CODER_HEADER_COMMAND"] = it } val process = builder.start() try { process.outputStream.close() - if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { - throw TimeoutException("Coder support bundle collection timed out") - } + process.waitFor() check(process.exitValue() == 0) { "Coder support bundle failed with exit code ${process.exitValue()}" } diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 7af15c1e..cce5dc20 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -49,6 +49,21 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS.LINUX +import org.junit.jupiter.api.condition.OS.MAC +import org.junit.jupiter.api.io.TempDir +import org.zeroturnaround.exec.InvalidExitValueException +import org.zeroturnaround.exec.ProcessInitException import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.Proxy @@ -59,7 +74,6 @@ import java.nio.file.AccessDeniedException import java.nio.file.Files import java.nio.file.Path import java.util.UUID -import java.util.concurrent.TimeoutException import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains @@ -68,21 +82,6 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.assertDoesNotThrow -import org.junit.jupiter.api.condition.EnabledOnOs -import org.junit.jupiter.api.condition.OS.LINUX -import org.junit.jupiter.api.condition.OS.MAC -import org.junit.jupiter.api.io.TempDir -import org.zeroturnaround.exec.InvalidExitValueException -import org.zeroturnaround.exec.ProcessInitException private const val VERSION_FOR_PROGRESS_REPORTING = "v2.13.1-devel+de07351b8" private val noOpTextProgress: (String) -> Unit = { _ -> } @@ -121,24 +120,6 @@ internal class CoderCLIManagerTest { } } - @Test - @EnabledOnOs(LINUX, MAC) - fun `timeout terminates the running CLI`() = runBlocking { - val job = launch(Dispatchers.Default) { - assertFailsWith { - supportBundleCli.runSupportBundleProcess(waitingCommand(), timeoutSeconds = 1) - } - } - val process = awaitProcess() - try { - job.join() - awaitExit(process) - } finally { - job.cancelAndJoin() - process.destroyForcibly() - } - } - @Test @EnabledOnOs(LINUX, MAC) fun `unsupported CLI exit is reported`() = runBlocking {