Skip to content

Commit 58760ce

Browse files
authored
feat: add Coder Support Bundle command (#878)
Add `Coder: Create Support Bundle` command that runs `coder support bundle` against a workspace and saves the resulting zip via a save dialog. Gated behind CLI v2.10.0 feature flag. Also adds progress reporting to `speedTest` and `pingWorkspace` commands, and refactors CLI/feature-set tests for conciseness. Closes #751
1 parent 0d52e7d commit 58760ce

File tree

7 files changed

+244
-129
lines changed

7 files changed

+244
-129
lines changed

package.json

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,11 @@
324324
"title": "Speed Test Workspace",
325325
"category": "Coder"
326326
},
327+
{
328+
"command": "coder.supportBundle",
329+
"title": "Create Support Bundle",
330+
"category": "Coder"
331+
},
327332
{
328333
"command": "coder.viewLogs",
329334
"title": "Coder: View Logs",
@@ -381,6 +386,10 @@
381386
{
382387
"command": "coder.pingWorkspace:views",
383388
"title": "Ping"
389+
},
390+
{
391+
"command": "coder.supportBundle:views",
392+
"title": "Support Bundle"
384393
}
385394
],
386395
"menus": {
@@ -405,6 +414,10 @@
405414
"command": "coder.speedTest",
406415
"when": "coder.authenticated"
407416
},
417+
{
418+
"command": "coder.supportBundle",
419+
"when": "coder.authenticated"
420+
},
408421
{
409422
"command": "coder.navigateToWorkspace",
410423
"when": "coder.workspace.connected"
@@ -425,6 +438,10 @@
425438
"command": "coder.pingWorkspace:views",
426439
"when": "false"
427440
},
441+
{
442+
"command": "coder.supportBundle:views",
443+
"when": "false"
444+
},
428445
{
429446
"command": "coder.workspace.update",
430447
"when": "coder.workspace.updatable"
@@ -535,12 +552,17 @@
535552
{
536553
"command": "coder.pingWorkspace:views",
537554
"when": "coder.authenticated && viewItem =~ /\\+running/",
538-
"group": "navigation"
555+
"group": "navigation@1"
539556
},
540557
{
541558
"command": "coder.speedTest:views",
542559
"when": "coder.authenticated && viewItem =~ /\\+running/",
543-
"group": "navigation"
560+
"group": "navigation@2"
561+
},
562+
{
563+
"command": "coder.supportBundle:views",
564+
"when": "coder.authenticated && viewItem =~ /\\+running/",
565+
"group": "navigation@3"
544566
}
545567
],
546568
"statusBar/remoteIndicator": [

src/commands.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type WorkspaceAgent,
44
} from "coder/site/src/api/typesGenerated";
55
import * as fs from "node:fs/promises";
6+
import * as os from "node:os";
67
import * as path from "node:path";
78
import * as semver from "semver";
89
import * as vscode from "vscode";
@@ -22,7 +23,7 @@ import { type SecretsManager } from "./core/secretsManager";
2223
import { type DeploymentManager } from "./deployment/deploymentManager";
2324
import { CertificateError } from "./error/certificateError";
2425
import { toError } from "./error/errorUtils";
25-
import { featureSetForVersion } from "./featureSet";
26+
import { type FeatureSet, featureSetForVersion } from "./featureSet";
2627
import { type Logger } from "./logging/logger";
2728
import { type LoginCoordinator } from "./login/loginCoordinator";
2829
import { withCancellableProgress, withProgress } from "./progress";
@@ -196,15 +197,17 @@ export class Commands {
196197
const trimmedDuration = duration.trim();
197198

198199
const result = await withCancellableProgress(
199-
async ({ signal }) => {
200+
async ({ signal, progress }) => {
201+
progress.report({ message: "Resolving CLI..." });
200202
const env = await this.resolveCliEnv(client);
203+
progress.report({ message: "Running..." });
201204
return cliExec.speedtest(env, workspaceId, trimmedDuration, signal);
202205
},
203206
{
204207
location: vscode.ProgressLocation.Notification,
205208
title: trimmedDuration
206-
? `Running speed test (${trimmedDuration})...`
207-
: "Running speed test...",
209+
? `Speed test for ${workspaceId} (${trimmedDuration})`
210+
: `Speed test for ${workspaceId}`,
208211
cancellable: true,
209212
},
210213
);
@@ -228,6 +231,70 @@ export class Commands {
228231
);
229232
}
230233

234+
public async supportBundle(item?: OpenableTreeItem): Promise<void> {
235+
const resolved = await this.resolveClientAndWorkspace(item);
236+
if (!resolved) {
237+
return;
238+
}
239+
240+
const { client, workspaceId } = resolved;
241+
242+
const outputUri = await this.promptSupportBundlePath();
243+
if (!outputUri) {
244+
return;
245+
}
246+
247+
const result = await withCancellableProgress(
248+
async ({ signal, progress }) => {
249+
progress.report({ message: "Resolving CLI..." });
250+
const env = await this.resolveCliEnv(client);
251+
if (!env.featureSet.supportBundle) {
252+
throw new Error(
253+
"Support bundles require Coder CLI v2.10.0 or later. Please update your Coder deployment.",
254+
);
255+
}
256+
257+
progress.report({ message: "Collecting diagnostics..." });
258+
await cliExec.supportBundle(env, workspaceId, outputUri.fsPath, signal);
259+
return outputUri;
260+
},
261+
{
262+
location: vscode.ProgressLocation.Notification,
263+
title: `Creating support bundle for ${workspaceId}`,
264+
cancellable: true,
265+
},
266+
);
267+
268+
if (result.ok) {
269+
const action = await vscode.window.showInformationMessage(
270+
`Support bundle saved to ${result.value.fsPath}`,
271+
"Reveal in File Explorer",
272+
);
273+
if (action === "Reveal in File Explorer") {
274+
await vscode.commands.executeCommand("revealFileInOS", result.value);
275+
}
276+
return;
277+
}
278+
279+
if (result.cancelled) {
280+
return;
281+
}
282+
283+
this.logger.error("Support bundle failed", result.error);
284+
vscode.window.showErrorMessage(
285+
`Support bundle failed: ${toError(result.error).message}`,
286+
);
287+
}
288+
289+
private promptSupportBundlePath(): Thenable<vscode.Uri | undefined> {
290+
const defaultName = `coder-support-${Math.floor(Date.now() / 1000)}.zip`;
291+
return vscode.window.showSaveDialog({
292+
defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)),
293+
filters: { "Zip files": ["zip"] },
294+
title: "Save Support Bundle",
295+
});
296+
}
297+
231298
/**
232299
* View the logs for the currently connected workspace.
233300
*/
@@ -720,8 +787,10 @@ export class Commands {
720787
location: vscode.ProgressLocation.Notification,
721788
title: `Starting ping for ${workspaceId}...`,
722789
},
723-
async () => {
790+
async (progress) => {
791+
progress.report({ message: "Resolving CLI..." });
724792
const env = await this.resolveCliEnv(client);
793+
progress.report({ message: "Starting..." });
725794
cliExec.ping(env, workspaceId);
726795
},
727796
);
@@ -763,7 +832,9 @@ export class Commands {
763832
}
764833

765834
/** Resolve a CliEnv, preferring a locally cached binary over a network fetch. */
766-
private async resolveCliEnv(client: CoderApi): Promise<cliExec.CliEnv> {
835+
private async resolveCliEnv(
836+
client: CoderApi,
837+
): Promise<cliExec.CliEnv & { featureSet: FeatureSet }> {
767838
const baseUrl = client.getAxiosInstance().defaults.baseURL;
768839
if (!baseUrl) {
769840
throw new Error("You are not logged in");
@@ -780,7 +851,7 @@ export class Commands {
780851
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
781852
const configs = vscode.workspace.getConfiguration();
782853
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
783-
return { binary, configs, auth };
854+
return { binary, configs, auth, featureSet };
784855
}
785856

786857
/**

src/core/cliExec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,32 @@ export async function speedtest(
104104
}
105105
}
106106

107+
/**
108+
* Run `coder support bundle` and save the output zip to the given path.
109+
*/
110+
export async function supportBundle(
111+
env: CliEnv,
112+
workspaceName: string,
113+
outputPath: string,
114+
signal?: AbortSignal,
115+
): Promise<void> {
116+
const globalFlags = getGlobalFlags(env.configs, env.auth);
117+
const args = [
118+
...globalFlags,
119+
"support",
120+
"bundle",
121+
workspaceName,
122+
"--output-file",
123+
outputPath,
124+
"--yes",
125+
];
126+
try {
127+
await execFileAsync(env.binary, args, { signal });
128+
} catch (error) {
129+
throw cliError(error);
130+
}
131+
}
132+
107133
/**
108134
* Run `coder ping` in a PTY terminal with Ctrl+C support.
109135
*/

src/extension.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
323323
"coder.speedTest:views",
324324
commands.speedTest.bind(commands),
325325
),
326+
vscode.commands.registerCommand(
327+
"coder.supportBundle",
328+
commands.supportBundle.bind(commands),
329+
),
330+
vscode.commands.registerCommand(
331+
"coder.supportBundle:views",
332+
commands.supportBundle.bind(commands),
333+
),
326334
);
327335

328336
const remote = new Remote(serviceContainer, commands, ctx);

src/featureSet.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface FeatureSet {
77
buildReason: boolean;
88
keyringAuth: boolean;
99
keyringTokenRead: boolean;
10+
supportBundle: boolean;
1011
}
1112

1213
/**
@@ -47,5 +48,7 @@ export function featureSetForVersion(
4748
keyringAuth: versionAtLeast(version, "2.29.0"),
4849
// `coder login token` for reading tokens from the keyring
4950
keyringTokenRead: versionAtLeast(version, "2.31.0"),
51+
// `coder support bundle` (officially released/unhidden in 2.10.0)
52+
supportBundle: versionAtLeast(version, "2.10.0"),
5053
};
5154
}

0 commit comments

Comments
 (0)