From 69407746285d9985880a73a1fb04ca23def586c6 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 17 Mar 2026 10:07:35 +0100 Subject: [PATCH] feat: add the portabledesktop module (#805) ## Description Add a module to install https://github.com/coder/portabledesktop in a workspace. This will be required for the virtual desktop feature in Coder Agents. ## Type of Change - [x] New module - [ ] New template - [ ] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/portabledesktop` **New version:** `v1.0.0` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues None --- .../coder/modules/portabledesktop/README.md | 46 ++++ .../modules/portabledesktop/main.test.ts | 242 ++++++++++++++++++ .../coder/modules/portabledesktop/main.tf | 65 +++++ .../portabledesktop.tftest.hcl | 36 +++ registry/coder/modules/portabledesktop/run.sh | 132 ++++++++++ 5 files changed, 521 insertions(+) create mode 100644 registry/coder/modules/portabledesktop/README.md create mode 100644 registry/coder/modules/portabledesktop/main.test.ts create mode 100644 registry/coder/modules/portabledesktop/main.tf create mode 100644 registry/coder/modules/portabledesktop/portabledesktop.tftest.hcl create mode 100644 registry/coder/modules/portabledesktop/run.sh diff --git a/registry/coder/modules/portabledesktop/README.md b/registry/coder/modules/portabledesktop/README.md new file mode 100644 index 00000000..a5afac77 --- /dev/null +++ b/registry/coder/modules/portabledesktop/README.md @@ -0,0 +1,46 @@ +--- +display_name: Portable Desktop +description: Install the portabledesktop binary for lightweight Linux desktop sessions. +icon: ../../../../.icons/desktop.svg +verified: true +tags: [desktop, vnc, ai] +--- + +# Portable Desktop + +Install [portabledesktop](https://github.com/coder/portabledesktop) for lightweight Linux desktop sessions over VNC. The binary is stored in the agent's script data directory and is automatically available on PATH via `CODER_SCRIPT_BIN_DIR`. + +```tf +module "portabledesktop" { + source = "registry.coder.com/coder/portabledesktop/coder" + version = "0.1.0" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Custom download URL with checksum verification + +```tf +module "portabledesktop" { + source = "registry.coder.com/coder/portabledesktop/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + url = "https://example.com/portabledesktop-linux-x64" + sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +} +``` + +### Additionally copy to a system path + +Use `install_dir` to copy the binary to a system-wide directory in addition to the default script data directory: + +```tf +module "portabledesktop" { + source = "registry.coder.com/coder/portabledesktop/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + install_dir = "/usr/local/bin" +} +``` diff --git a/registry/coder/modules/portabledesktop/main.test.ts b/registry/coder/modules/portabledesktop/main.test.ts new file mode 100644 index 00000000..dd73f4e7 --- /dev/null +++ b/registry/coder/modules/portabledesktop/main.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "bun:test"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + type TerraformState, +} from "~test"; + +interface TestFixture { + state: TerraformState; + server: ReturnType; + [Symbol.asyncDispose](): Promise; +} + +interface ContainerHandle { + id: string; + [Symbol.asyncDispose](): Promise; +} + +async function setupContainer(image: string): Promise { + const id = await runContainer(image); + return { + id, + [Symbol.asyncDispose]: async () => { + await removeContainer(id); + }, + }; +} + +const ENV_PREFIX = + 'export CODER_SCRIPT_DATA_DIR=/tmp/coder-script-data && export CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin && mkdir -p "$CODER_SCRIPT_DATA_DIR" "$CODER_SCRIPT_BIN_DIR" && '; + +async function setupFakeBinaryServer( + dir: string, + extraVars?: Record, +): Promise { + const fakeBinary = "#!/bin/sh\necho portabledesktop"; + const server = Bun.serve({ + port: 0, + fetch() { + return new Response(fakeBinary); + }, + }); + + const state = await runTerraformApply(dir, { + agent_id: "foo", + url: `http://localhost:${server.port}/portabledesktop`, + ...extraVars, + }); + + return { + state, + server, + [Symbol.asyncDispose]: async () => { + server.stop(true); + }, + }; +} + +describe("portabledesktop", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("installs portabledesktop successfully", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + + // Check binary exists at CODER_SCRIPT_DATA_DIR. + const checkBinary = await execContainer(container.id, [ + "test", + "-x", + "/tmp/coder-script-data/portabledesktop", + ]); + expect(checkBinary.exitCode).toBe(0); + + // Check symlink exists at CODER_SCRIPT_BIN_DIR. + const checkSymlink = await execContainer(container.id, [ + "test", + "-L", + "/tmp/coder-script-data/bin/portabledesktop", + ]); + expect(checkSymlink.exitCode).toBe(0); + }, 30000); + + it("verifies checksum when sha256 is provided", async () => { + const fakeBinary = "#!/bin/sh\necho portabledesktop"; + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(fakeBinary); + const sha256 = hasher.digest("hex"); + + await using fixture = await setupFakeBinaryServer(import.meta.dir, { + sha256, + }); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("Checksum verified successfully"); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + }, 30000); + + it("fails when sha256 does not match", async () => { + const wrongSha256 = + "0000000000000000000000000000000000000000000000000000000000000000"; + + await using fixture = await setupFakeBinaryServer(import.meta.dir, { + sha256: wrongSha256, + }); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(1); + expect(resp.stdout).toContain("Checksum mismatch"); + }, 30000); + + it("skips checksum verification when sha256 is not set", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).not.toContain("Checksum verified"); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + }, 30000); + + it("falls back to sudo when install_dir is not writable", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir, { + install_dir: "/usr/local/bin", + }); + await using container = await setupContainer("alpine/curl"); + + await execContainer(container.id, [ + "sh", + "-c", + "apk add sudo && " + + "adduser -D testuser && " + + "echo 'testuser ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers && " + + "mkdir -p /usr/local/bin", + ]); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer( + container.id, + ["sh", "-c", ENV_PREFIX + script], + ["--user", "testuser"], + ); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("via sudo"); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + + // Verify the binary was copied to the install_dir. + const check = await execContainer(container.id, [ + "test", + "-x", + "/usr/local/bin/portabledesktop", + ]); + expect(check.exitCode).toBe(0); + }, 30000); + + it("creates install_dir if it does not exist", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir, { + install_dir: "/opt/custom/bin", + }); + await using container = await setupContainer("alpine/curl"); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + + const check = await execContainer(container.id, [ + "test", + "-x", + "/opt/custom/bin/portabledesktop", + ]); + expect(check.exitCode).toBe(0); + }, 30000); + + it("falls back to wget when curl is not available", async () => { + await using fixture = await setupFakeBinaryServer(import.meta.dir); + await using container = await setupContainer("alpine"); + + // Install wget but ensure curl is not present. + await execContainer(container.id, [ + "sh", + "-c", + "apk add wget && ! command -v curl", + ]); + + const script = findResourceInstance(fixture.state, "coder_script").script; + const resp = await execContainer(container.id, [ + "sh", + "-c", + ENV_PREFIX + script, + ]); + + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toContain("via wget"); + expect(resp.stdout).toContain("portabledesktop installed successfully"); + }, 30000); +}); diff --git a/registry/coder/modules/portabledesktop/main.tf b/registry/coder/modules/portabledesktop/main.tf new file mode 100644 index 00000000..68303c17 --- /dev/null +++ b/registry/coder/modules/portabledesktop/main.tf @@ -0,0 +1,65 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "install_dir" { + type = string + description = "Optional directory to copy the binary into (e.g. /usr/local/bin). The binary is always stored in the agent's script data directory and available on PATH via CODER_SCRIPT_BIN_DIR." + default = null +} + +variable "url" { + type = string + description = "Custom download URL. Overrides the default GitHub latest release URL when set." + default = null +} + +variable "sha256" { + type = string + description = "SHA256 checksum. When set, the downloaded binary is verified against it." + default = null +} + +locals { + default_amd64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-x64" + default_arm64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-arm64" + + using_custom_url = var.url != null + + amd64_url = local.using_custom_url ? var.url : local.default_amd64_url + arm64_url = local.using_custom_url ? var.url : local.default_arm64_url + + # Empty string signals "skip verification" to the shell script. + sha256 = var.sha256 != null ? var.sha256 : "" + install_dir = var.install_dir != null ? var.install_dir : "" +} + +resource "coder_script" "portabledesktop" { + agent_id = var.agent_id + display_name = "Portable Desktop" + icon = "/icon/desktop.svg" + script = <<-EOT + #!/bin/sh + set -eu + echo -n '${base64encode(file("${path.module}/run.sh"))}' | base64 -d > /tmp/portabledesktop-install.sh + chmod +x /tmp/portabledesktop-install.sh + ARG_AMD64_URL="$(echo -n '${base64encode(local.amd64_url)}' | base64 -d)" \ + ARG_ARM64_URL="$(echo -n '${base64encode(local.arm64_url)}' | base64 -d)" \ + ARG_SHA256="$(echo -n '${base64encode(local.sha256)}' | base64 -d)" \ + ARG_INSTALL_DIR="$(echo -n '${base64encode(local.install_dir)}' | base64 -d)" \ + /tmp/portabledesktop-install.sh + EOT + run_on_start = true +} diff --git a/registry/coder/modules/portabledesktop/portabledesktop.tftest.hcl b/registry/coder/modules/portabledesktop/portabledesktop.tftest.hcl new file mode 100644 index 00000000..20a23170 --- /dev/null +++ b/registry/coder/modules/portabledesktop/portabledesktop.tftest.hcl @@ -0,0 +1,36 @@ +run "plan_with_required_vars" { + command = plan + + variables { + agent_id = "example-agent-id" + } +} + +run "plan_with_custom_install_dir" { + command = plan + + variables { + agent_id = "example-agent-id" + install_dir = "/opt/bin" + } + + assert { + condition = resource.coder_script.portabledesktop.display_name == "Portable Desktop" + error_message = "Expected coder_script resource to have correct display name" + } +} + +run "plan_with_custom_url" { + command = plan + + variables { + agent_id = "example-agent-id" + url = "https://example.com/custom-portabledesktop" + sha256 = "abc123" + } + + assert { + condition = resource.coder_script.portabledesktop.run_on_start == true + error_message = "Expected coder_script to run on start" + } +} diff --git a/registry/coder/modules/portabledesktop/run.sh b/registry/coder/modules/portabledesktop/run.sh new file mode 100644 index 00000000..f7e62eff --- /dev/null +++ b/registry/coder/modules/portabledesktop/run.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2292 +# SC2292: We use [ ] instead of [[ ]] for POSIX sh compatibility. +set -eu + +error() { + printf "ERROR: %s\n" "$@" + exit 1 +} + +# Check if portabledesktop is already in PATH. +if command -v portabledesktop > /dev/null 2>&1; then + printf "portabledesktop is already installed and in PATH.\n" + exit 0 +fi + +# Determine the storage path. +STORAGE_DIR="${CODER_SCRIPT_DATA_DIR}" +BINARY_PATH="${STORAGE_DIR}/portabledesktop" +mkdir -p "${STORAGE_DIR}" + +# If the binary already exists and is executable, skip download. +if [ -x "${BINARY_PATH}" ]; then + printf "portabledesktop is already installed at %s, skipping download.\n" "${BINARY_PATH}" +else + # Detect architecture and select the appropriate download URL. + ARCH=$(uname -m) + case "${ARCH}" in + x86_64) + URL="${ARG_AMD64_URL}" + ;; + aarch64) + URL="${ARG_ARM64_URL}" + ;; + *) + error "Unsupported architecture: ${ARCH}" + ;; + esac + + # Select download tool. + if command -v curl > /dev/null 2>&1; then + DOWNLOAD_CMD="curl" + elif command -v wget > /dev/null 2>&1; then + DOWNLOAD_CMD="wget" + else + error "No download tool available (curl or wget required)." + fi + + # Download with retry loop (3 attempts, 1s sleep between). + TMPFILE=$(mktemp) + MAX_ATTEMPTS=3 + DOWNLOAD_SUCCESS=false + ATTEMPT=1 + + while [ "${ATTEMPT}" -le "${MAX_ATTEMPTS}" ]; do + printf "Downloading portabledesktop (attempt %s/%s) via %s...\n" "${ATTEMPT}" "${MAX_ATTEMPTS}" "${DOWNLOAD_CMD}" + + DOWNLOAD_OK=false + if [ "${DOWNLOAD_CMD}" = "curl" ]; then + curl -fsSL "${URL}" -o "${TMPFILE}" && DOWNLOAD_OK=true + else + wget -qO "${TMPFILE}" "${URL}" && DOWNLOAD_OK=true + fi + + if [ "${DOWNLOAD_OK}" = "true" ]; then + # Verify checksum when ARG_SHA256 is non-empty. + if [ -n "${ARG_SHA256}" ]; then + CHECKSUM_MATCH=false + if command -v sha256sum > /dev/null 2>&1; then + echo "${ARG_SHA256} ${TMPFILE}" | sha256sum -c - > /dev/null 2>&1 && CHECKSUM_MATCH=true + elif command -v shasum > /dev/null 2>&1; then + echo "${ARG_SHA256} ${TMPFILE}" | shasum -a 256 -c - > /dev/null 2>&1 && CHECKSUM_MATCH=true + else + rm -f "${TMPFILE}" + error "No SHA256 tool available (sha256sum or shasum required)." + fi + + if [ "${CHECKSUM_MATCH}" != "true" ]; then + printf "WARNING: Checksum mismatch (attempt %s/%s): expected %s\n" \ + "${ATTEMPT}" "${MAX_ATTEMPTS}" "${ARG_SHA256}" + rm -f "${TMPFILE}" + if [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; then + sleep 1 + fi + ATTEMPT=$((ATTEMPT + 1)) + continue + fi + printf "Checksum verified successfully.\n" + fi + + DOWNLOAD_SUCCESS=true + break + else + printf "WARNING: Download failed (attempt %s/%s).\n" "${ATTEMPT}" "${MAX_ATTEMPTS}" + if [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; then + sleep 1 + fi + fi + + ATTEMPT=$((ATTEMPT + 1)) + done + + if [ "${DOWNLOAD_SUCCESS}" != "true" ]; then + rm -f "${TMPFILE}" + error "Failed to download portabledesktop after ${MAX_ATTEMPTS} attempts." + fi + + # Make the binary executable and move to storage path. + chmod 755 "${TMPFILE}" + mv "${TMPFILE}" "${BINARY_PATH}" +fi + +# Symlink into CODER_SCRIPT_BIN_DIR for PATH access. +if [ -n "${CODER_SCRIPT_BIN_DIR}" ] && [ ! -e "${CODER_SCRIPT_BIN_DIR}/portabledesktop" ]; then + ln -s "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${CODER_SCRIPT_BIN_DIR}/portabledesktop" +fi + +# If ARG_INSTALL_DIR is set, copy the binary there with sudo fallback. +if [ -n "${ARG_INSTALL_DIR}" ]; then + if [ ! -d "${ARG_INSTALL_DIR}" ]; then + mkdir -p "${ARG_INSTALL_DIR}" 2> /dev/null || sudo mkdir -p "${ARG_INSTALL_DIR}" 2> /dev/null || true + fi + if cp "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${ARG_INSTALL_DIR}/portabledesktop" 2> /dev/null; then + printf "Copied portabledesktop to %s.\n" "${ARG_INSTALL_DIR}/portabledesktop" + elif sudo cp "${CODER_SCRIPT_DATA_DIR}/portabledesktop" "${ARG_INSTALL_DIR}/portabledesktop" 2> /dev/null; then + printf "Copied portabledesktop to %s (via sudo).\n" "${ARG_INSTALL_DIR}/portabledesktop" + else + error "Failed to copy portabledesktop to ${ARG_INSTALL_DIR}/portabledesktop." + fi +fi + +printf "portabledesktop installed successfully.\n"