Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 <T> withProgress(message: String, action: suspend () -> T): T {
description.value = EnvironmentDescription.Progress(context.i18n.ptrl(message), indeterminate = true)
return try {
Expand Down
36 changes: 36 additions & 0 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>,
) = 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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
37 changes: 37 additions & 0 deletions src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<CoderCLIManager>(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)
Expand Down
100 changes: 100 additions & 0 deletions src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<String> = 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<Unit> {
assertFailsWith<IllegalStateException> {
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<ToolboxUi>(relaxed = true)
private val underlyingLogger = mockk<Logger>(relaxed = true)
private val context = CoderToolboxContext(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CancellationException> { collector.collectAdditionalDiagnostics(root) }
assertFalse(Files.exists(root.resolve("coder-support.zip")))
assertFalse(Files.exists(root.resolve("coder-support-error.txt")))
}
}
Loading