diff --git a/registry/coder/modules/vscode-web/README.md b/registry/coder/modules/vscode-web/README.md index 43b1eb9d0..59a98de43 100644 --- a/registry/coder/modules/vscode-web/README.md +++ b/registry/coder/modules/vscode-web/README.md @@ -8,13 +8,13 @@ tags: [ide, vscode, web] # VS Code Web -Automatically install [Visual Studio Code Server](https://code.visualstudio.com/docs/remote/vscode-server) in a workspace and create an app to access it via the dashboard. +Automatically install the [VS Code CLI](https://code.visualstudio.com/docs/editor/command-line) and run `code serve-web` in a workspace to access VS Code via the browser. ```tf module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "2.0.0" agent_id = coder_agent.example.id accept_license = true } @@ -30,7 +30,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "2.0.0" agent_id = coder_agent.example.id install_prefix = "/home/coder/.vscode-web" folder = "/home/coder" @@ -44,7 +44,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "2.0.0" agent_id = coder_agent.example.id extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] accept_license = true @@ -59,7 +59,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "2.0.0" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -69,32 +69,50 @@ module "vscode-web" { } ``` -### Pin a specific VS Code Web version +### Open an existing workspace on startup -By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases). +To open an existing workspace on startup the `workspace` parameter can be used to represent a path on disk to a `code-workspace` file. +Note: Either `workspace` or `folder` can be used, but not both simultaneously. The `code-workspace` file must already be present on disk. ```tf module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "2.0.0" agent_id = coder_agent.example.id - commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" + workspace = "/home/coder/coder.code-workspace" accept_license = true } ``` -### Open an existing workspace on startup +### Use VS Code Insiders -To open an existing workspace on startup the `workspace` parameter can be used to represent a path on disk to a `code-workspace` file. -Note: Either `workspace` or `folder` can be used, but not both simultaneously. The `code-workspace` file must already be present on disk. +Use the VS Code Insiders release channel to get the latest features and bug fixes: + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/vscode-web/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + release_channel = "insiders" + accept_license = true +} +``` + +### Pin a specific VS Code version + +Use the `commit_id` variable to pin a specific VS Code Server version by its commit SHA: ```tf module "vscode-web" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" - agent_id = coder_agent.example.id - workspace = "/home/coder/coder.code-workspace" + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/vscode-web/coder" + version = "2.0.0" + agent_id = coder_agent.example.id + commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" + accept_license = true } ``` + +You can find the commit SHA for a specific VS Code version on the [VS Code releases page](https://code.visualstudio.com/updates) or by checking the "About" dialog in VS Code. diff --git a/registry/coder/modules/vscode-web/main.test.ts b/registry/coder/modules/vscode-web/main.test.ts index 860fc176c..2b7ad9a21 100644 --- a/registry/coder/modules/vscode-web/main.test.ts +++ b/registry/coder/modules/vscode-web/main.test.ts @@ -1,42 +1,784 @@ -import { describe, expect, it } from "bun:test"; -import { runTerraformApply, runTerraformInit } from "~test"; +import { + describe, + expect, + it, + beforeAll, + afterEach, + setDefaultTimeout, +} from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + runContainer, + execContainer, + removeContainer, + findResourceInstance, +} from "~test"; + +// Set timeout to 5 minutes for tests that download VS Code CLI +setDefaultTimeout(5 * 60 * 1000); + +let cleanupContainers: string[] = []; + +afterEach(async () => { + for (const id of cleanupContainers) { + try { + await removeContainer(id); + } catch { + // Ignore cleanup errors + } + } + cleanupContainers = []; +}); describe("vscode-web", async () => { - await runTerraformInit(import.meta.dir); + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + describe("terraform validation", () => { + it("accept_license should be set to true", async () => { + try { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: false, + }); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain("Invalid value for variable"); + } + }); + + it("use_cached and offline can not be used together", async () => { + try { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + offline: true, + }); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain( + "Offline and Use Cached can not be used together", + ); + } + }); + + it("offline and extensions can not be used together", async () => { + try { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + offline: true, + extensions: '["ms-python.python"]', + }); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain( + "Offline mode does not allow extensions to be installed", + ); + } + }); + + it("workspace and folder can not be used together", async () => { + try { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + folder: "/home/coder", + workspace: "/home/coder/test.code-workspace", + }); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain( + "Set only one of `workspace` or `folder`", + ); + } + }); + }); - it("accept_license should be set to true", () => { - const t = async () => { - await runTerraformApply(import.meta.dir, { + describe("script generation", () => { + it("generates script with correct port", async () => { + const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", - accept_license: "false", + accept_license: true, + port: 8080, }); - }; - expect(t).toThrow("Invalid value for variable"); - }); + const script = findResourceInstance(state, "coder_script"); + expect(script.script).toContain("--port 8080"); + }); - it("use_cached and offline can not be used together", () => { - const t = async () => { - await runTerraformApply(import.meta.dir, { + it("generates script with extensions directory", async () => { + const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", - accept_license: "true", - use_cached: "true", - offline: "true", + accept_license: true, + extensions_dir: "/custom/extensions", }); - }; - expect(t).toThrow("Offline and Use Cached can not be used together"); - }); + const script = findResourceInstance(state, "coder_script"); + expect(script.script).toContain("--extensions-dir=/custom/extensions"); + }); + + it("generates script with telemetry level", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + telemetry_level: "off", + }); + const script = findResourceInstance(state, "coder_script"); + expect(script.script).toContain("--telemetry-level off"); + }); + + it("generates script with disable trust", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + disable_trust: true, + }); + const script = findResourceInstance(state, "coder_script"); + expect(script.script).toContain("--disable-workspace-trust"); + }); + + it("generates script with serve-web command", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + }); + const script = findResourceInstance(state, "coder_script"); + expect(script.script).toContain("serve-web"); + expect(script.script).toContain("--accept-server-license-terms"); + expect(script.script).toContain("--without-connection-token"); + }); + + it("generates script with stable release channel by default", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + }); + const script = findResourceInstance(state, "coder_script"); + expect(script.script).toContain("build=stable"); + }); + + it("generates script with insiders release channel", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + release_channel: "insiders", + }); + const script = findResourceInstance(state, "coder_script"); + expect(script.script).toContain("build=insiders"); + }); + + it("generates script without commit-id value when not specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + }); + const script = findResourceInstance(state, "coder_script"); + // The if condition should have an empty string, so no commit-id value is passed + expect(script.script).toContain('if [ -n "" ]; then'); + // Should not contain any actual commit hash + expect(script.script).not.toMatch(/--commit-id [a-f0-9]{40}/); + }); - it("offline and extensions can not be used together", () => { - const t = async () => { - await runTerraformApply(import.meta.dir, { + it("generates script with commit-id when specified", async () => { + const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", - accept_license: "true", - offline: "true", - extensions: '["1", "2"]', + accept_license: true, + commit_id: "e54c774e0add60467559eb0d1e229c6452cf8447", }); - }; - expect(t).toThrow("Offline mode does not allow extensions to be installed"); + const script = findResourceInstance(state, "coder_script"); + expect(script.script).toContain( + "--commit-id e54c774e0add60467559eb0d1e229c6452cf8447", + ); + }); }); - // More tests depend on shebang refactors + describe("container integration tests", () => { + it("uses existing code CLI in PATH", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code CLI that logs when serve-web is called + await execContainer(containerId, [ + "bash", + "-c", + `cat > /usr/local/bin/code << 'MOCKEOF' +#!/bin/bash +if [ "\$1" = "serve-web" ]; then + echo "MOCK_SERVER_STARTED with args: \$@" + exit 0 +fi +echo "code mock called: \$@" +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + // Run the script - the mock will capture the serve-web call + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Found VS Code CLI"); + }); + + it("offline mode fails when CLI not present", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + offline: true, + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + const script = findResourceInstance(state, "coder_script"); + + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain( + "Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found", + ); + }); + + it("offline mode uses code-server as fallback", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + offline: true, + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Install mock code-server in PATH + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /usr/local/bin && cat > /usr/local/bin/code-server << 'MOCKEOF' +#!/bin/bash +echo "MOCK_CODE_SERVER_STARTED with args: $@" +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code-server`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("offline fallback"); + expect(result.stdout).toContain("Starting code-server"); + }); + + it("offline mode works with pre-installed CLI", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + offline: true, + install_prefix: "/tmp/vscode-web", + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Pre-install mock code CLI at expected location + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF' +#!/bin/bash +if [ "\$1" = "serve-web" ]; then + echo "MOCK_OFFLINE_SERVER_STARTED" + exit 0 +fi +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Using cached VS Code CLI"); + expect(result.stdout).toContain("Starting VS Code Web"); + }); + + it("use_cached mode works with pre-installed CLI", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + install_prefix: "/tmp/vscode-web", + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Pre-install mock code CLI + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF' +#!/bin/bash +if [ "\$1" = "serve-web" ]; then + echo "MOCK_CACHED_SERVER_STARTED" + exit 0 +fi +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Using cached VS Code CLI"); + }); + + it("creates settings file with correct content", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + settings: '{"editor.fontSize": 14}', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code CLI + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF' +#!/bin/bash +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + await execContainer(containerId, ["bash", "-c", script.script]); + + // Check that settings file was created + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + expect(settingsResult.stdout).toContain("editor.fontSize"); + expect(settingsResult.stdout).toContain("14"); + }); + + it("creates settings file with multiple settings", async () => { + const settings = { + "editor.fontSize": 16, + "editor.tabSize": 2, + "workbench.colorTheme": "Dracula", + "editor.formatOnSave": true, + }; + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + settings: JSON.stringify(settings), + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code CLI + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF' +#!/bin/bash +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + await execContainer(containerId, ["bash", "-c", script.script]); + + // Check that settings file was created with all settings + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + expect(settingsResult.stdout).toContain("editor.fontSize"); + expect(settingsResult.stdout).toContain("16"); + expect(settingsResult.stdout).toContain("editor.tabSize"); + expect(settingsResult.stdout).toContain("2"); + expect(settingsResult.stdout).toContain("workbench.colorTheme"); + expect(settingsResult.stdout).toContain("Dracula"); + expect(settingsResult.stdout).toContain("editor.formatOnSave"); + expect(settingsResult.stdout).toContain("true"); + }); + + it("creates settings file in correct directory structure", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + settings: '{"test.setting": "value"}', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code CLI + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF' +#!/bin/bash +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + await execContainer(containerId, ["bash", "-c", script.script]); + + // Verify directory structure was created + const dirResult = await execContainer(containerId, [ + "ls", + "-la", + "/root/.vscode-server/data/Machine/", + ]); + + expect(dirResult.exitCode).toBe(0); + expect(dirResult.stdout).toContain("settings.json"); + + // Verify parent directories exist + const parentDirResult = await execContainer(containerId, [ + "ls", + "-la", + "/root/.vscode-server/data/", + ]); + + expect(parentDirResult.exitCode).toBe(0); + expect(parentDirResult.stdout).toContain("Machine"); + }); + + it("does not overwrite existing settings file", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + settings: '{"new.setting": "new_value"}', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code CLI + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF' +#!/bin/bash +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code`, + ]); + + // Pre-create an existing settings file + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + await execContainer(containerId, ["bash", "-c", script.script]); + + // Check that existing settings file was NOT overwritten + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + // Should contain existing setting, not the new one + expect(settingsResult.stdout).toContain("existing.setting"); + expect(settingsResult.stdout).toContain("existing_value"); + expect(settingsResult.stdout).not.toContain("new.setting"); + }); + + it("creates valid JSON settings file", async () => { + const settings = { + "editor.fontSize": 14, + "editor.wordWrap": "on", + "files.autoSave": "afterDelay", + "files.autoSaveDelay": 1000, + }; + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + settings: JSON.stringify(settings), + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Install jq and create mock code CLI + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, [ + "apt-get", + "install", + "-y", + "-qq", + "jq", + ]); + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF' +#!/bin/bash +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + await execContainer(containerId, ["bash", "-c", script.script]); + + // Validate JSON using jq + const jsonValidResult = await execContainer(containerId, [ + "bash", + "-c", + "jq '.' /root/.vscode-server/data/Machine/settings.json", + ]); + + expect(jsonValidResult.exitCode).toBe(0); + + // Extract specific values using jq + const fontSizeResult = await execContainer(containerId, [ + "bash", + "-c", + "jq '.\"editor.fontSize\"' /root/.vscode-server/data/Machine/settings.json", + ]); + expect(fontSizeResult.stdout.trim()).toBe("14"); + + const wordWrapResult = await execContainer(containerId, [ + "bash", + "-c", + "jq '.\"editor.wordWrap\"' /root/.vscode-server/data/Machine/settings.json", + ]); + expect(wordWrapResult.stdout.trim()).toBe('"on"'); + + const autoSaveDelayResult = await execContainer(containerId, [ + "bash", + "-c", + "jq '.\"files.autoSaveDelay\"' /root/.vscode-server/data/Machine/settings.json", + ]); + expect(autoSaveDelayResult.stdout.trim()).toBe("1000"); + }); + + it("installs extensions", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + extensions: '["ms-python.python", "golang.go"]', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code CLI that logs extension installs + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF' +#!/bin/bash +if [ "\$1" = "--install-extension" ]; then + echo "MOCK_EXTENSION_INSTALL: \$2" +fi +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Installing extension"); + expect(result.stdout).toContain("ms-python.python"); + expect(result.stdout).toContain("golang.go"); + }); + + it("runs with correct server arguments", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + port: 9999, + telemetry_level: "off", + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code CLI that captures all arguments + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF' +#!/bin/bash +echo "MOCK_CODE_ARGS: \$@" +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const result = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(result.exitCode).toBe(0); + // Check the output contains expected port message + expect(result.stdout).toContain("Starting VS Code Web on port 9999"); + }); + + it("passes commit-id to code CLI when specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + commit_id: "abc123def456", + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code CLI that logs arguments to the log file (where output is redirected) + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF' +#!/bin/bash +echo "MOCK_CODE_ARGS: $@" +exit 0 +MOCKEOF +chmod +x /usr/local/bin/code`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + await execContainer(containerId, ["bash", "-c", script.script]); + + // Wait briefly for background process to write to log + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Check the log file for the arguments (code CLI output goes there) + const logResult = await execContainer(containerId, [ + "cat", + "/tmp/vscode-web.log", + ]); + + expect(logResult.exitCode).toBe(0); + expect(logResult.stdout).toContain("--commit-id abc123def456"); + }); + + // This test downloads and starts the real VS Code server + it("starts real VS Code CLI and responds to healthcheck (requires network)", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + port: 13338, + install_prefix: "/tmp/vscode-web", + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Install curl for downloading CLI and healthcheck + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, [ + "apt-get", + "install", + "-y", + "-qq", + "curl", + ]); + + const script = findResourceInstance(state, "coder_script"); + + // Run the script - it will start the server in background + const startResult = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + + expect(startResult.exitCode).toBe(0); + expect(startResult.stdout).toContain("Starting VS Code Web"); + + // Wait for server to start and check healthcheck + await new Promise((resolve) => setTimeout(resolve, 10000)); + + const healthResult = await execContainer(containerId, [ + "curl", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "http://127.0.0.1:13338/healthz", + ]); + + // Server should respond (200, 202, or 404 is acceptable - means server is running) + expect(["200", "202", "404"]).toContain(healthResult.stdout.trim()); + }); + }); }); diff --git a/registry/coder/modules/vscode-web/main.tf b/registry/coder/modules/vscode-web/main.tf index 7a2029c87..88c312109 100644 --- a/registry/coder/modules/vscode-web/main.tf +++ b/registry/coder/modules/vscode-web/main.tf @@ -59,12 +59,6 @@ variable "install_prefix" { default = "/tmp/vscode-web" } -variable "commit_id" { - type = string - description = "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used." - default = "" -} - variable "extensions" { type = list(string) description = "A list of extensions to install." @@ -148,19 +142,25 @@ variable "subdomain" { default = true } -variable "platform" { +variable "workspace" { type = string - description = "The platform to use for the VS Code Web." + description = "Path to a .code-workspace file to open in vscode-web." default = "" +} + +variable "release_channel" { + type = string + description = "The release channel for VS Code CLI (stable or insiders)." + default = "stable" validation { - condition = var.platform == "" || var.platform == "linux" || var.platform == "darwin" || var.platform == "alpine" || var.platform == "win32" - error_message = "Incorrect value. Please set either 'linux', 'darwin', or 'alpine' or 'win32'." + condition = var.release_channel == "stable" || var.release_channel == "insiders" + error_message = "Incorrect value. Please set either 'stable' or 'insiders'." } } -variable "workspace" { +variable "commit_id" { type = string - description = "Path to a .code-workspace file to open in vscode-web." + description = "The commit SHA to use for the VS Code Server. Leave empty to use the latest version." default = "" } @@ -187,8 +187,8 @@ resource "coder_script" "vscode-web" { WORKSPACE : var.workspace, AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, SERVER_BASE_PATH : local.server_base_path, + RELEASE_CHANNEL : var.release_channel, COMMIT_ID : var.commit_id, - PLATFORM : var.platform, }) run_on_start = true diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh index 57bb760f9..6029053c0 100644 --- a/registry/coder/modules/vscode-web/run.sh +++ b/registry/coder/modules/vscode-web/run.sh @@ -1,138 +1,326 @@ #!/usr/bin/env bash BOLD='\033[0;1m' +RESET='\033[0m' +CODE='\033[36;40;1m' EXTENSIONS=("${EXTENSIONS}") -VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server" -# Set extension directory +# Set extension directory argument EXTENSION_ARG="" if [ -n "${EXTENSIONS_DIR}" ]; then EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" fi -# Set extension directory +# Set server base path argument SERVER_BASE_PATH_ARG="" if [ -n "${SERVER_BASE_PATH}" ]; then SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}" fi -# Set disable workspace trust +# Set disable workspace trust argument DISABLE_TRUST_ARG="" if [ "${DISABLE_TRUST}" = true ]; then DISABLE_TRUST_ARG="--disable-workspace-trust" fi -run_vscode_web() { - echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG $DISABLE_TRUST_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..." - echo "Check logs at ${LOG_PATH}!" - "$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 & +# Check if code CLI is installed +check_code_cli() { + if command -v code > /dev/null 2>&1; then + echo "code" + return 0 + fi + if [ -f "${INSTALL_PREFIX}/bin/code" ]; then + echo "${INSTALL_PREFIX}/bin/code" + return 0 + fi + return 1 } -# Check if the settings file exists... -if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then - echo "⚙️ Creating settings file..." - mkdir -p ~/.vscode-server/data/Machine - echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json -fi - -# Check if vscode-server is already installed for offline or cached mode -if [ -f "$VSCODE_WEB" ]; then - if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then - echo "🥳 Found a copy of VS Code Web" - run_vscode_web - exit 0 +# Check if code-server is installed (fallback option) +check_code_server() { + if command -v code-server > /dev/null 2>&1; then + echo "code-server" + return 0 fi -fi -# Offline mode always expects a copy of vscode-server to be present -if [ "${OFFLINE}" = true ]; then - echo "Failed to find a copy of VS Code Web" - exit 1 -fi + if [ -f "${INSTALL_PREFIX}/bin/code-server" ]; then + echo "${INSTALL_PREFIX}/bin/code-server" + return 0 + fi + return 1 +} -# Create install prefix -mkdir -p ${INSTALL_PREFIX} +# Find existing vscode-server binary (used by code serve-web internally) +find_vscode_server() { + # Check common locations for pre-downloaded vscode-server + local server_dirs=( + "$HOME/.vscode-server/bin" + "$HOME/.vscode/cli/serve-web" + ) + for dir in "$${server_dirs[@]}"; do + if [ -d "$dir" ]; then + # Find the most recent server version + local latest + latest=$(ls -t "$dir" 2> /dev/null | head -1) + if [ -n "$latest" ] && [ -f "$dir/$latest/bin/code-server" ]; then + echo "$dir/$latest/bin/code-server" + return 0 + fi + if [ -n "$latest" ] && [ -f "$dir/$latest/code-server" ]; then + echo "$dir/$latest/code-server" + return 0 + fi + fi + done + return 1 +} -printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n" +# Install VS Code CLI if not present +install_code_cli() { + printf "$${BOLD}Installing VS Code CLI...$${RESET}\n" -# Download and extract vscode-server -ARCH=$(uname -m) -case "$ARCH" in - x86_64) ARCH="x64" ;; - aarch64) ARCH="arm64" ;; - *) - echo "Unsupported architecture" + # Detect architecture + ARCH=$(uname -m) + case "$ARCH" in + x86_64) ARCH="x64" ;; + aarch64 | arm64) ARCH="arm64" ;; + armv7l) ARCH="armhf" ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; + esac + + # Detect platform + # Note: VS Code CLI uses 'alpine' for all Linux distributions + PLATFORM=$(uname -s) + case "$PLATFORM" in + Linux) + PLATFORM="alpine" + ;; + Darwin) + PLATFORM="darwin" + ;; + *) + echo "Unsupported platform: $PLATFORM" + exit 1 + ;; + esac + + # Create install directory + mkdir -p "${INSTALL_PREFIX}/bin" + + # Download VS Code CLI + CLI_URL="https://code.visualstudio.com/sha/download?build=${RELEASE_CHANNEL}&os=cli-$PLATFORM-$ARCH" + printf "Downloading VS Code CLI from %s\n" "$CLI_URL" + + if command -v curl > /dev/null 2>&1; then + curl -fsSL "$CLI_URL" -o "/tmp/vscode-cli.tar.gz" + elif command -v wget > /dev/null 2>&1; then + wget -q "$CLI_URL" -O "/tmp/vscode-cli.tar.gz" + else + echo "Neither curl nor wget is available. Please install one of them." exit 1 - ;; -esac - -# Detect the platform -if [ -n "${PLATFORM}" ]; then - DETECTED_PLATFORM="${PLATFORM}" -elif [ -f /etc/alpine-release ] || grep -qi 'ID=alpine' /etc/os-release 2> /dev/null || command -v apk > /dev/null 2>&1; then - DETECTED_PLATFORM="alpine" -elif [ "$(uname -s)" = "Darwin" ]; then - DETECTED_PLATFORM="darwin" -else - DETECTED_PLATFORM="linux" -fi + fi -# Check if a specific VS Code Web commit ID was provided -if [ -n "${COMMIT_ID}" ]; then - HASH="${COMMIT_ID}" -else - HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-$DETECTED_PLATFORM-$ARCH-web | cut -d '"' -f 2) -fi -printf "$${BOLD}VS Code Web commit id version $HASH.\n" + # Extract CLI + tar -xzf /tmp/vscode-cli.tar.gz -C "${INSTALL_PREFIX}/bin" + rm -f /tmp/vscode-cli.tar.gz -output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-$DETECTED_PLATFORM-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1) + # The CLI binary is named 'code' + if [ -f "${INSTALL_PREFIX}/bin/code" ]; then + chmod +x "${INSTALL_PREFIX}/bin/code" + export PATH="${INSTALL_PREFIX}/bin:$PATH" + printf "$${BOLD}VS Code CLI installed successfully.$${RESET}\n" + else + echo "Failed to install VS Code CLI" + exit 1 + fi +} -if [ $? -ne 0 ]; then - echo "Failed to install Microsoft Visual Studio Code Server: $output" - exit 1 -fi -printf "$${BOLD}VS Code Web has been installed.\n" +# Run VS Code Web using the code CLI (serve-web command) +run_vscode_web_cli() { + local CODE_CMD="$1" + + # Build the command arguments + ARGS="serve-web --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL}" + + if [ -n "$EXTENSION_ARG" ]; then + ARGS="$ARGS $EXTENSION_ARG" + fi -# Install each extension... -IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" -# shellcheck disable=SC2066 -for extension in "$${EXTENSIONLIST[@]}"; do - if [ -z "$extension" ]; then - continue + if [ -n "$SERVER_BASE_PATH_ARG" ]; then + ARGS="$ARGS $SERVER_BASE_PATH_ARG" fi - printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n" - output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force) - if [ $? -ne 0 ]; then - echo "Failed to install extension: $extension: $output" + + if [ -n "$DISABLE_TRUST_ARG" ]; then + ARGS="$ARGS $DISABLE_TRUST_ARG" fi -done -if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then - if ! command -v jq > /dev/null; then - echo "jq is required to install extensions from a workspace file." - else - # Prefer WORKSPACE if set and points to a file - if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then - printf "🧩 Installing extensions from %s...\n" "${WORKSPACE}" - # Strip single-line comments then parse .extensions.recommendations[] - extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]') - for extension in $extensions; do - $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force - done + if [ -n "${COMMIT_ID}" ]; then + ARGS="$ARGS --commit-id ${COMMIT_ID}" + fi + + printf "Starting VS Code Web on port ${PORT}...\n" + printf "Check logs at ${LOG_PATH}\n" + + # shellcheck disable=SC2086 + "$CODE_CMD" $ARGS > "${LOG_PATH}" 2>&1 & +} + +# Run VS Code Web using code-server (fallback for offline mode) +run_code_server() { + local SERVER_CMD="$1" + + printf "Starting code-server on port ${PORT}...\n" + printf "Check logs at ${LOG_PATH}\n" + + # Build arguments for code-server + ARGS="--port ${PORT} --host 127.0.0.1 --auth none" + + if [ -n "$EXTENSION_ARG" ]; then + ARGS="$ARGS $EXTENSION_ARG" + fi + + # shellcheck disable=SC2086 + "$SERVER_CMD" $ARGS > "${LOG_PATH}" 2>&1 & +} + +# Run VS Code Web using vscode-server binary directly +run_vscode_server() { + local SERVER_CMD="$1" + + printf "Starting VS Code Server on port ${PORT}...\n" + printf "Check logs at ${LOG_PATH}\n" + + # Build arguments for vscode-server + ARGS="--port ${PORT} --host 127.0.0.1 --without-connection-token --accept-server-license-terms --telemetry-level ${TELEMETRY_LEVEL}" + + if [ -n "$EXTENSION_ARG" ]; then + ARGS="$ARGS $EXTENSION_ARG" + fi + + if [ -n "$SERVER_BASE_PATH_ARG" ]; then + ARGS="$ARGS $SERVER_BASE_PATH_ARG" + fi + + # shellcheck disable=SC2086 + "$SERVER_CMD" serve-local $ARGS > "${LOG_PATH}" 2>&1 & +} + +install_extensions() { + local CODE_CMD="$1" + + # Install specified extensions + IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" + for extension in "$${EXTENSIONLIST[@]}"; do + if [ -z "$extension" ]; then + continue + fi + printf "Installing extension $${CODE}$extension$${RESET}...\n" + output=$("$CODE_CMD" $EXTENSION_ARG --install-extension "$extension" --force 2>&1) + if [ $? -ne 0 ]; then + echo "Failed to install extension: $extension: $output" + fi + done + + # Auto-install extensions from workspace or folder + if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then + if ! command -v jq > /dev/null; then + echo "jq is required to install extensions from a workspace file." else - # Fallback to folder-based .vscode/extensions.json (existing behavior) - WORKSPACE_DIR="$HOME" - if [ -n "${FOLDER}" ]; then - WORKSPACE_DIR="${FOLDER}" - fi - if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then - printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" - extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]') + if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then + printf "Installing extensions from %s...\n" "${WORKSPACE}" + extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]') for extension in $extensions; do - $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force + "$CODE_CMD" $EXTENSION_ARG --install-extension "$extension" --force done + else + WORKSPACE_DIR="$HOME" + if [ -n "${FOLDER}" ]; then + WORKSPACE_DIR="${FOLDER}" + fi + if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then + printf "Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" + extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]') + for extension in $extensions; do + "$CODE_CMD" $EXTENSION_ARG --install-extension "$extension" --force + done + fi fi fi fi +} + +# Create settings file if it doesn't exist +if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then + printf "Creating settings file...\n" + mkdir -p ~/.vscode-server/data/Machine + echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json fi -run_vscode_web +# Determine which command to use +CODE_CMD="" +RUN_MODE="" + +# Check for code CLI first (preferred) +if CODE_CMD=$(check_code_cli); then + printf "$${BOLD}Found VS Code CLI at $CODE_CMD$${RESET}\n" + RUN_MODE="cli" +fi + +# Handle offline mode +if [ "${OFFLINE}" = true ]; then + if [ -n "$CODE_CMD" ]; then + # Check if vscode-server is already downloaded (code serve-web won't need to download) + if VSCODE_SERVER=$(find_vscode_server); then + printf "Found cached VS Code Server at $VSCODE_SERVER\n" + printf "Using cached VS Code CLI.\n" + run_vscode_web_cli "$CODE_CMD" + exit 0 + fi + # Code CLI exists but vscode-server not cached - try using it anyway + # (it might work if server was pre-downloaded, or fail gracefully) + printf "Warning: VS Code Server may not be cached. Attempting to start...\n" + printf "Using cached VS Code CLI.\n" + run_vscode_web_cli "$CODE_CMD" + exit 0 + fi + + # Try code-server as fallback for offline mode + if SERVER_CMD=$(check_code_server); then + printf "$${BOLD}Found code-server at $SERVER_CMD (offline fallback)$${RESET}\n" + run_code_server "$SERVER_CMD" + exit 0 + fi + + # Try vscode-server binary directly + if VSCODE_SERVER=$(find_vscode_server); then + printf "$${BOLD}Found VS Code Server at $VSCODE_SERVER (offline fallback)$${RESET}\n" + run_vscode_server "$VSCODE_SERVER" + exit 0 + fi + + echo "Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found." + exit 1 +fi + +# Handle use_cached mode +if [ "${USE_CACHED}" = true ] && [ -n "$CODE_CMD" ]; then + printf "Using cached VS Code CLI.\n" + install_extensions "$CODE_CMD" + run_vscode_web_cli "$CODE_CMD" + exit 0 +fi + +# Install VS Code CLI if not present +if [ -z "$CODE_CMD" ]; then + install_code_cli + CODE_CMD="${INSTALL_PREFIX}/bin/code" + RUN_MODE="cli" +fi + +# Install extensions +install_extensions "$CODE_CMD" + +# Run VS Code Web +run_vscode_web_cli "$CODE_CMD" diff --git a/registry/coder/modules/vscode-web/vscode-web.tftest.hcl b/registry/coder/modules/vscode-web/vscode-web.tftest.hcl new file mode 100644 index 000000000..5a2d8d98e --- /dev/null +++ b/registry/coder/modules/vscode-web/vscode-web.tftest.hcl @@ -0,0 +1,151 @@ +run "required_vars" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + } +} + +run "accept_license_required" { + command = plan + + variables { + agent_id = "foo" + accept_license = false + } + + expect_failures = [ + var.accept_license + ] +} + +run "offline_and_use_cached_conflict" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + use_cached = true + offline = true + } + + expect_failures = [ + resource.coder_script.vscode-web + ] +} + +run "offline_disallows_extensions" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + offline = true + extensions = ["ms-python.python", "golang.go"] + } + + expect_failures = [ + resource.coder_script.vscode-web + ] +} + +run "workspace_and_folder_conflict" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + folder = "/home/coder/project" + workspace = "/home/coder/project.code-workspace" + } + + expect_failures = [ + resource.coder_script.vscode-web + ] +} + +run "url_with_folder_query" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + folder = "/home/coder/project" + port = 13338 + } + + assert { + condition = resource.coder_app.vscode-web.url == "http://localhost:13338?folder=%2Fhome%2Fcoder%2Fproject" + error_message = "coder_app URL must include encoded folder query param" + } +} + +run "url_with_workspace_query" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + workspace = "/home/coder/project.code-workspace" + port = 13338 + } + + assert { + condition = resource.coder_app.vscode-web.url == "http://localhost:13338?workspace=%2Fhome%2Fcoder%2Fproject.code-workspace" + error_message = "coder_app URL must include encoded workspace query param" + } +} + +run "release_channel_stable" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + release_channel = "stable" + } +} + +run "release_channel_insiders" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + release_channel = "insiders" + } +} + +run "release_channel_invalid" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + release_channel = "invalid" + } + + expect_failures = [ + var.release_channel + ] +} + +run "commit_id_empty_by_default" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + } +} + +run "commit_id_with_value" { + command = plan + + variables { + agent_id = "foo" + accept_license = true + commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" + } +}