Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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

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 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
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 @@ -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
Expand Down Expand Up @@ -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<ActionDescription>()
context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus")
Expand Down
44 changes: 44 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 All @@ -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.
Expand Down Expand Up @@ -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<String>,
timeoutSeconds: Long = 120,
Comment thread
fioan89 marked this conversation as resolved.
Resolved
) = 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")
Comment thread
fioan89 marked this conversation as resolved.
Resolved
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()
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
}
}
39 changes: 39 additions & 0 deletions src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<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
Loading
Loading