diff --git a/package.json b/package.json index 38f491f9e0..7578f7de51 100644 --- a/package.json +++ b/package.json @@ -597,6 +597,7 @@ "axios": "1.15.0", "date-fns": "catalog:", "eventsource": "^4.1.0", + "fflate": "^0.8.2", "find-process": "^2.1.1", "jsonc-parser": "^3.3.1", "openpgp": "^6.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f87fa8591e..154e93d333 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: eventsource: specifier: ^4.1.0 version: 4.1.0 + fflate: + specifier: ^0.8.2 + version: 0.8.2 find-process: specifier: ^2.1.1 version: 2.1.1 @@ -188,7 +191,7 @@ importers: version: 4.1.0 coder: specifier: 'catalog:' - version: https://codeload.github.com/coder/coder/tar.gz/6b0bb02e5dcb1766e5d8c7aa88471019c80e49b4 + version: https://codeload.github.com/coder/coder/tar.gz/ad1906589d5a794de86b94ecf22af19448a471d1 concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -1955,8 +1958,8 @@ packages: resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} engines: {node: '>=16'} - coder@https://codeload.github.com/coder/coder/tar.gz/6b0bb02e5dcb1766e5d8c7aa88471019c80e49b4: - resolution: {tarball: https://codeload.github.com/coder/coder/tar.gz/6b0bb02e5dcb1766e5d8c7aa88471019c80e49b4} + coder@https://codeload.github.com/coder/coder/tar.gz/ad1906589d5a794de86b94ecf22af19448a471d1: + resolution: {tarball: https://codeload.github.com/coder/coder/tar.gz/ad1906589d5a794de86b94ecf22af19448a471d1} version: 0.0.0 color-convert@2.0.1: @@ -2462,6 +2465,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -6138,7 +6144,7 @@ snapshots: cockatiel@3.2.1: {} - coder@https://codeload.github.com/coder/coder/tar.gz/6b0bb02e5dcb1766e5d8c7aa88471019c80e49b4: {} + coder@https://codeload.github.com/coder/coder/tar.gz/ad1906589d5a794de86b94ecf22af19448a471d1: {} color-convert@2.0.1: dependencies: @@ -6726,6 +6732,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 diff --git a/src/commands.ts b/src/commands.ts index 3d7d51eaa3..3155b87069 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -20,6 +20,7 @@ import { type ServiceContainer } from "./core/container"; import { type MementoManager } from "./core/mementoManager"; import { type PathResolver } from "./core/pathResolver"; import { type SecretsManager } from "./core/secretsManager"; +import { appendVsCodeLogs } from "./core/supportBundleLogs"; import { type DeploymentManager } from "./deployment/deploymentManager"; import { CertificateError } from "./error/certificateError"; import { toError } from "./error/errorUtils"; @@ -256,6 +257,18 @@ export class Commands { progress.report({ message: "Collecting diagnostics..." }); await cliExec.supportBundle(env, workspaceId, outputUri.fsPath, signal); + + progress.report({ message: "Adding VS Code logs..." }); + await appendVsCodeLogs( + outputUri.fsPath, + { + remoteSshLogPath: this.workspaceLogPath, + proxyLogDir: this.pathResolver.getProxyLogPath(), + extensionLogDir: this.pathResolver.getCodeLogDir(), + }, + this.logger, + ); + return outputUri; }, { diff --git a/src/core/supportBundleLogs.ts b/src/core/supportBundleLogs.ts new file mode 100644 index 0000000000..db8e7cbb2b --- /dev/null +++ b/src/core/supportBundleLogs.ts @@ -0,0 +1,158 @@ +import { unzip, zip } from "fflate"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { promisify } from "node:util"; + +import { type Logger } from "../logging/logger"; +import { renameWithRetry } from "../util"; + +export interface LogSources { + remoteSshLogPath?: string; + proxyLogDir?: string; + extensionLogDir?: string; +} + +// 3 days is enough context for recent issues; matching the 7-day +// rotation would bloat the bundle. +const LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; + +const unzipAsync = promisify(unzip); +const zipAsync = promisify(zip); + +async function collectDirFiles( + dirPath: string, + logger: Logger, +): Promise> { + const results = new Map(); + + let entries: string[]; + try { + entries = await fs.readdir(dirPath); + } catch (error) { + logger.warn(`Could not read log directory ${dirPath}`, error); + return results; + } + + const cutoff = Date.now() - LOG_MAX_AGE_MS; + + await Promise.all( + entries.map(async (entry) => { + const filePath = path.join(dirPath, entry); + try { + const stat = await fs.stat(filePath); + if (!stat.isFile() || stat.mtimeMs < cutoff) { + return; + } + results.set(entry, await fs.readFile(filePath)); + } catch (error) { + logger.warn(`Could not read log file ${filePath}`, error); + } + }), + ); + + return results; +} + +/** + * Gather log files from each source independently so a failure in one + * does not affect the others. + */ +async function collectLogFiles( + sources: LogSources, + logger: Logger, +): Promise> { + const files = new Map(); + + if (sources.remoteSshLogPath) { + try { + files.set( + `vscode-logs/remote-ssh/${path.basename(sources.remoteSshLogPath)}`, + await fs.readFile(sources.remoteSshLogPath), + ); + } catch (error) { + logger.warn("Could not read Remote SSH log", error); + } + } + + if (sources.proxyLogDir) { + for (const [name, data] of await collectDirFiles( + sources.proxyLogDir, + logger, + )) { + files.set(`vscode-logs/proxy/${name}`, data); + } + } + + if (sources.extensionLogDir) { + for (const [name, data] of await collectDirFiles( + sources.extensionLogDir, + logger, + )) { + files.set(`vscode-logs/extension/${name}`, data); + } + } + + return files; +} + +/** + * Best-effort: append VS Code logs to a support bundle zip. + * Uses atomic rename to avoid corrupting the original bundle on failure. + */ +export async function appendVsCodeLogs( + zipPath: string, + sources: LogSources, + logger: Logger, +): Promise { + try { + const logFiles = await collectLogFiles(sources, logger); + if (logFiles.size === 0) { + logger.info("No VS Code logs found to add to support bundle"); + return; + } + + logger.info( + `Adding ${logFiles.size} VS Code log file(s) to support bundle`, + ); + + // Write to a named temporary path first so a failure at the rename step + // leaves the user with a properly named file containing VS Code logs. + const parsed = path.parse(zipPath); + const vscodeBundlePath = path.join( + parsed.dir, + `${parsed.name}-vscode${parsed.ext}`, + ); + + try { + const entries = await unzipAsync(await fs.readFile(zipPath)); + for (const [name, data] of logFiles) { + entries[name] = data; + } + const updated = await zipAsync(entries); + await fs.writeFile(vscodeBundlePath, updated); + } catch (error) { + logger.error("Failed to add VS Code logs to support bundle", error); + try { + await fs.rm(vscodeBundlePath, { force: true }); + } catch (cleanupError) { + logger.warn( + `Could not clean up partial bundle at ${vscodeBundlePath}`, + cleanupError, + ); + } + return; + } + + try { + await renameWithRetry(fs.rename, vscodeBundlePath, zipPath); + } catch (error) { + logger.warn( + `Could not replace original bundle; VS Code logs saved separately at ${vscodeBundlePath}`, + error, + ); + } + } catch (error) { + // Best-effort: never let a failure here lose the user's bundle. + logger.error("Unexpected error appending VS Code logs", error); + } +} diff --git a/test/unit/core/supportBundleLogs.test.ts b/test/unit/core/supportBundleLogs.test.ts new file mode 100644 index 0000000000..bdd7a1647c --- /dev/null +++ b/test/unit/core/supportBundleLogs.test.ts @@ -0,0 +1,254 @@ +import { strToU8, unzipSync, zipSync } from "fflate"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { appendVsCodeLogs } from "@/core/supportBundleLogs"; +import { renameWithRetry } from "@/util"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +// Wrap renameWithRetry so individual tests can override it via +// mockRejectedValueOnce; by default it calls through to the real impl. +vi.mock("@/util", async () => { + const actual = await vi.importActual("@/util"); + return { ...actual, renameWithRetry: vi.fn(actual.renameWithRetry) }; +}); + +// chmod to 0o000 is a no-op as root and on Windows. +const canTestUnreadable = + process.getuid?.() !== 0 && process.platform !== "win32"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "support-bundle-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +const logger = createMockLogger(); + +/** Set a file's mtime to N days in the past. */ +async function setAge(filePath: string, daysAgo: number): Promise { + const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + await fs.utimes(filePath, past, past); +} + +async function makeBundle(): Promise { + const zipPath = path.join(tmpDir, "coder-support-123.zip"); + await fs.writeFile( + zipPath, + zipSync({ "server/info.txt": strToU8("server data") }), + ); + return zipPath; +} + +async function readZip(zipPath: string): Promise> { + const entries = unzipSync(await fs.readFile(zipPath)); + return Object.fromEntries( + Object.entries(entries).map(([name, data]) => [ + name, + Buffer.from(data).toString(), + ]), + ); +} + +function vsCodeLogKeys(entries: Record): string[] { + return Object.keys(entries) + .filter((k) => k.startsWith("vscode-logs/")) + .sort(); +} + +describe("appendVsCodeLogs", () => { + it("merges logs from all three sources and skips subdirectories", async () => { + const zipPath = await makeBundle(); + + const sshLog = path.join(tmpDir, "ssh.log"); + await fs.writeFile(sshLog, "ssh"); + + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + await fs.writeFile(path.join(proxyDir, "coder-ssh-recent.log"), "proxy"); + await fs.mkdir(path.join(proxyDir, "subdir")); + + const extDir = path.join(tmpDir, "ext"); + await fs.mkdir(extDir); + await fs.writeFile(path.join(extDir, "Coder.log"), "ext"); + + await appendVsCodeLogs( + zipPath, + { + remoteSshLogPath: sshLog, + proxyLogDir: proxyDir, + extensionLogDir: extDir, + }, + logger, + ); + + const entries = await readZip(zipPath); + expect(Object.keys(entries).sort()).toEqual([ + "server/info.txt", + "vscode-logs/extension/Coder.log", + "vscode-logs/proxy/coder-ssh-recent.log", + "vscode-logs/remote-ssh/ssh.log", + ]); + expect(entries["server/info.txt"]).toBe("server data"); + expect(entries["vscode-logs/proxy/coder-ssh-recent.log"]).toBe("proxy"); + }); + + it("does not touch the zip when no logs are found", async () => { + const zipPath = await makeBundle(); + const beforeStat = await fs.stat(zipPath); + const beforeBytes = await fs.readFile(zipPath); + + await appendVsCodeLogs(zipPath, {}, logger); + + expect((await fs.stat(zipPath)).mtimeMs).toBe(beforeStat.mtimeMs); + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); + }); + + it("merges a large number of files without dropping any", async () => { + const zipPath = await makeBundle(); + + const proxyDir = path.join(tmpDir, "proxy"); + const extDir = path.join(tmpDir, "ext"); + await fs.mkdir(proxyDir); + await fs.mkdir(extDir); + + const fileCount = 60; + await Promise.all( + Array.from({ length: fileCount }, (_, i) => + Promise.all([ + fs.writeFile(path.join(proxyDir, `proxy-${i}.log`), `proxy-${i}`), + fs.writeFile(path.join(extDir, `ext-${i}.log`), `ext-${i}`), + ]), + ), + ); + + await appendVsCodeLogs( + zipPath, + { proxyLogDir: proxyDir, extensionLogDir: extDir }, + logger, + ); + + const entries = await readZip(zipPath); + const keys = vsCodeLogKeys(entries); + expect(keys).toHaveLength(fileCount * 2); + for (let i = 0; i < fileCount; i++) { + expect(entries[`vscode-logs/proxy/proxy-${i}.log`]).toBe(`proxy-${i}`); + expect(entries[`vscode-logs/extension/ext-${i}.log`]).toBe(`ext-${i}`); + } + }); + + it("filters proxy logs older than 3 days by mtime", async () => { + const zipPath = await makeBundle(); + + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + await fs.writeFile(path.join(proxyDir, "recent.log"), "recent"); + await fs.writeFile(path.join(proxyDir, "old.log"), "old"); + await setAge(path.join(proxyDir, "old.log"), 5); + + await appendVsCodeLogs(zipPath, { proxyLogDir: proxyDir }, logger); + + expect(vsCodeLogKeys(await readZip(zipPath))).toEqual([ + "vscode-logs/proxy/recent.log", + ]); + }); + + it("filters extension logs older than 3 days by mtime", async () => { + const zipPath = await makeBundle(); + + const extDir = path.join(tmpDir, "ext"); + await fs.mkdir(extDir); + await fs.writeFile(path.join(extDir, "Coder-recent.log"), "recent"); + await fs.writeFile(path.join(extDir, "Coder-old.log"), "old"); + await setAge(path.join(extDir, "Coder-old.log"), 5); + + await appendVsCodeLogs(zipPath, { extensionLogDir: extDir }, logger); + + expect(vsCodeLogKeys(await readZip(zipPath))).toEqual([ + "vscode-logs/extension/Coder-recent.log", + ]); + }); + + it.runIf(canTestUnreadable)( + "skips missing or unreadable sources and includes the rest", + async () => { + const zipPath = await makeBundle(); + + const proxyDir = path.join(tmpDir, "proxy"); + await fs.mkdir(proxyDir); + await fs.writeFile(path.join(proxyDir, "good.log"), "ok"); + const badLog = path.join(proxyDir, "bad.log"); + await fs.writeFile(badLog, "secret"); + await fs.chmod(badLog, 0o000); + + try { + await appendVsCodeLogs( + zipPath, + { + remoteSshLogPath: path.join(tmpDir, "nonexistent.log"), + proxyLogDir: proxyDir, + extensionLogDir: path.join(tmpDir, "no-such-dir"), + }, + logger, + ); + + expect(vsCodeLogKeys(await readZip(zipPath))).toEqual([ + "vscode-logs/proxy/good.log", + ]); + } finally { + await fs.chmod(badLog, 0o644); + } + }, + ); + + it("keeps the -vscode.zip sibling when rename fails", async () => { + const zipPath = await makeBundle(); + const beforeStat = await fs.stat(zipPath); + const beforeBytes = await fs.readFile(zipPath); + + const sshLog = path.join(tmpDir, "ssh.log"); + await fs.writeFile(sshLog, "ssh content"); + + vi.mocked(renameWithRetry).mockRejectedValueOnce( + new Error("simulated rename failure"), + ); + + await appendVsCodeLogs(zipPath, { remoteSshLogPath: sshLog }, logger); + + expect((await fs.stat(zipPath)).mtimeMs).toBe(beforeStat.mtimeMs); + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); + + const siblingPath = path.join(tmpDir, "coder-support-123-vscode.zip"); + const entries = await readZip(siblingPath); + expect(Object.keys(entries).sort()).toEqual([ + "server/info.txt", + "vscode-logs/remote-ssh/ssh.log", + ]); + expect(entries["vscode-logs/remote-ssh/ssh.log"]).toBe("ssh content"); + }); + + it("leaves the original zip intact and cleans up the partial sibling when corrupted", async () => { + const zipPath = path.join(tmpDir, "coder-support-123.zip"); + await fs.writeFile(zipPath, "not a zip"); + const beforeStat = await fs.stat(zipPath); + const beforeBytes = await fs.readFile(zipPath); + + const logPath = path.join(tmpDir, "ssh.log"); + await fs.writeFile(logPath, "content"); + + await appendVsCodeLogs(zipPath, { remoteSshLogPath: logPath }, logger); + + expect((await fs.stat(zipPath)).mtimeMs).toBe(beforeStat.mtimeMs); + expect(Buffer.compare(beforeBytes, await fs.readFile(zipPath))).toBe(0); + expect(await fs.readdir(tmpDir)).not.toContain( + "coder-support-123-vscode.zip", + ); + }); +});