diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ef1e85d..50ac8107 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 @@ -14,7 +19,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..0a9ba2f1 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 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 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 6d8a5d6e..0852be0e 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 @@ -25,6 +26,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 @@ -126,6 +128,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 suspend fun withProgress(message: String, action: suspend () -> T): T { description.value = EnvironmentDescription.Progress(context.i18n.ptrl(message), indeterminate = true) return try { diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index dae90ae6..240ac88e 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 @@ -599,6 +600,41 @@ 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 and terminates it on cancellation. */ + internal suspend fun runSupportBundleProcess( + command: List, + ) = runInterruptible(Dispatchers.IO) { + val builder = ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + context.settingsStore.headerCommand?.let { builder.environment()["CODER_HEADER_COMMAND"] = it } + val process = builder.start() + try { + process.outputStream.close() + process.waitFor() + 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 72d476c2..8c7064d9 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import java.nio.file.Files import java.time.Instant import java.util.UUID import java.util.concurrent.CountDownLatch @@ -50,6 +51,42 @@ import kotlin.test.assertSame 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 8f3d3d72..cce5dc20 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -50,9 +50,18 @@ 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 @@ -62,6 +71,7 @@ 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 kotlin.test.BeforeTest @@ -77,6 +87,96 @@ 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 `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"))) + } +}