Skip to content

Commit 6940774

Browse files
authored
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
1 parent 85c5181 commit 6940774

5 files changed

Lines changed: 521 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
display_name: Portable Desktop
3+
description: Install the portabledesktop binary for lightweight Linux desktop sessions.
4+
icon: ../../../../.icons/desktop.svg
5+
verified: true
6+
tags: [desktop, vnc, ai]
7+
---
8+
9+
# Portable Desktop
10+
11+
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`.
12+
13+
```tf
14+
module "portabledesktop" {
15+
source = "registry.coder.com/coder/portabledesktop/coder"
16+
version = "0.1.0"
17+
agent_id = coder_agent.example.id
18+
}
19+
```
20+
21+
## Examples
22+
23+
### Custom download URL with checksum verification
24+
25+
```tf
26+
module "portabledesktop" {
27+
source = "registry.coder.com/coder/portabledesktop/coder"
28+
version = "0.1.0"
29+
agent_id = coder_agent.example.id
30+
url = "https://example.com/portabledesktop-linux-x64"
31+
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
32+
}
33+
```
34+
35+
### Additionally copy to a system path
36+
37+
Use `install_dir` to copy the binary to a system-wide directory in addition to the default script data directory:
38+
39+
```tf
40+
module "portabledesktop" {
41+
source = "registry.coder.com/coder/portabledesktop/coder"
42+
version = "0.1.0"
43+
agent_id = coder_agent.example.id
44+
install_dir = "/usr/local/bin"
45+
}
46+
```
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
execContainer,
4+
findResourceInstance,
5+
removeContainer,
6+
runContainer,
7+
runTerraformApply,
8+
runTerraformInit,
9+
testRequiredVariables,
10+
type TerraformState,
11+
} from "~test";
12+
13+
interface TestFixture {
14+
state: TerraformState;
15+
server: ReturnType<typeof Bun.serve>;
16+
[Symbol.asyncDispose](): Promise<void>;
17+
}
18+
19+
interface ContainerHandle {
20+
id: string;
21+
[Symbol.asyncDispose](): Promise<void>;
22+
}
23+
24+
async function setupContainer(image: string): Promise<ContainerHandle> {
25+
const id = await runContainer(image);
26+
return {
27+
id,
28+
[Symbol.asyncDispose]: async () => {
29+
await removeContainer(id);
30+
},
31+
};
32+
}
33+
34+
const ENV_PREFIX =
35+
'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" && ';
36+
37+
async function setupFakeBinaryServer(
38+
dir: string,
39+
extraVars?: Record<string, string>,
40+
): Promise<TestFixture> {
41+
const fakeBinary = "#!/bin/sh\necho portabledesktop";
42+
const server = Bun.serve({
43+
port: 0,
44+
fetch() {
45+
return new Response(fakeBinary);
46+
},
47+
});
48+
49+
const state = await runTerraformApply(dir, {
50+
agent_id: "foo",
51+
url: `http://localhost:${server.port}/portabledesktop`,
52+
...extraVars,
53+
});
54+
55+
return {
56+
state,
57+
server,
58+
[Symbol.asyncDispose]: async () => {
59+
server.stop(true);
60+
},
61+
};
62+
}
63+
64+
describe("portabledesktop", async () => {
65+
await runTerraformInit(import.meta.dir);
66+
67+
testRequiredVariables(import.meta.dir, {
68+
agent_id: "foo",
69+
});
70+
71+
it("installs portabledesktop successfully", async () => {
72+
await using fixture = await setupFakeBinaryServer(import.meta.dir);
73+
await using container = await setupContainer("alpine/curl");
74+
75+
const script = findResourceInstance(fixture.state, "coder_script").script;
76+
const resp = await execContainer(container.id, [
77+
"sh",
78+
"-c",
79+
ENV_PREFIX + script,
80+
]);
81+
82+
expect(resp.exitCode).toBe(0);
83+
expect(resp.stdout).toContain("portabledesktop installed successfully");
84+
85+
// Check binary exists at CODER_SCRIPT_DATA_DIR.
86+
const checkBinary = await execContainer(container.id, [
87+
"test",
88+
"-x",
89+
"/tmp/coder-script-data/portabledesktop",
90+
]);
91+
expect(checkBinary.exitCode).toBe(0);
92+
93+
// Check symlink exists at CODER_SCRIPT_BIN_DIR.
94+
const checkSymlink = await execContainer(container.id, [
95+
"test",
96+
"-L",
97+
"/tmp/coder-script-data/bin/portabledesktop",
98+
]);
99+
expect(checkSymlink.exitCode).toBe(0);
100+
}, 30000);
101+
102+
it("verifies checksum when sha256 is provided", async () => {
103+
const fakeBinary = "#!/bin/sh\necho portabledesktop";
104+
const hasher = new Bun.CryptoHasher("sha256");
105+
hasher.update(fakeBinary);
106+
const sha256 = hasher.digest("hex");
107+
108+
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
109+
sha256,
110+
});
111+
await using container = await setupContainer("alpine/curl");
112+
113+
const script = findResourceInstance(fixture.state, "coder_script").script;
114+
const resp = await execContainer(container.id, [
115+
"sh",
116+
"-c",
117+
ENV_PREFIX + script,
118+
]);
119+
120+
expect(resp.exitCode).toBe(0);
121+
expect(resp.stdout).toContain("Checksum verified successfully");
122+
expect(resp.stdout).toContain("portabledesktop installed successfully");
123+
}, 30000);
124+
125+
it("fails when sha256 does not match", async () => {
126+
const wrongSha256 =
127+
"0000000000000000000000000000000000000000000000000000000000000000";
128+
129+
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
130+
sha256: wrongSha256,
131+
});
132+
await using container = await setupContainer("alpine/curl");
133+
134+
const script = findResourceInstance(fixture.state, "coder_script").script;
135+
const resp = await execContainer(container.id, [
136+
"sh",
137+
"-c",
138+
ENV_PREFIX + script,
139+
]);
140+
141+
expect(resp.exitCode).toBe(1);
142+
expect(resp.stdout).toContain("Checksum mismatch");
143+
}, 30000);
144+
145+
it("skips checksum verification when sha256 is not set", async () => {
146+
await using fixture = await setupFakeBinaryServer(import.meta.dir);
147+
await using container = await setupContainer("alpine/curl");
148+
149+
const script = findResourceInstance(fixture.state, "coder_script").script;
150+
const resp = await execContainer(container.id, [
151+
"sh",
152+
"-c",
153+
ENV_PREFIX + script,
154+
]);
155+
156+
expect(resp.exitCode).toBe(0);
157+
expect(resp.stdout).not.toContain("Checksum verified");
158+
expect(resp.stdout).toContain("portabledesktop installed successfully");
159+
}, 30000);
160+
161+
it("falls back to sudo when install_dir is not writable", async () => {
162+
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
163+
install_dir: "/usr/local/bin",
164+
});
165+
await using container = await setupContainer("alpine/curl");
166+
167+
await execContainer(container.id, [
168+
"sh",
169+
"-c",
170+
"apk add sudo && " +
171+
"adduser -D testuser && " +
172+
"echo 'testuser ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers && " +
173+
"mkdir -p /usr/local/bin",
174+
]);
175+
176+
const script = findResourceInstance(fixture.state, "coder_script").script;
177+
const resp = await execContainer(
178+
container.id,
179+
["sh", "-c", ENV_PREFIX + script],
180+
["--user", "testuser"],
181+
);
182+
183+
expect(resp.exitCode).toBe(0);
184+
expect(resp.stdout).toContain("via sudo");
185+
expect(resp.stdout).toContain("portabledesktop installed successfully");
186+
187+
// Verify the binary was copied to the install_dir.
188+
const check = await execContainer(container.id, [
189+
"test",
190+
"-x",
191+
"/usr/local/bin/portabledesktop",
192+
]);
193+
expect(check.exitCode).toBe(0);
194+
}, 30000);
195+
196+
it("creates install_dir if it does not exist", async () => {
197+
await using fixture = await setupFakeBinaryServer(import.meta.dir, {
198+
install_dir: "/opt/custom/bin",
199+
});
200+
await using container = await setupContainer("alpine/curl");
201+
202+
const script = findResourceInstance(fixture.state, "coder_script").script;
203+
const resp = await execContainer(container.id, [
204+
"sh",
205+
"-c",
206+
ENV_PREFIX + script,
207+
]);
208+
209+
expect(resp.exitCode).toBe(0);
210+
expect(resp.stdout).toContain("portabledesktop installed successfully");
211+
212+
const check = await execContainer(container.id, [
213+
"test",
214+
"-x",
215+
"/opt/custom/bin/portabledesktop",
216+
]);
217+
expect(check.exitCode).toBe(0);
218+
}, 30000);
219+
220+
it("falls back to wget when curl is not available", async () => {
221+
await using fixture = await setupFakeBinaryServer(import.meta.dir);
222+
await using container = await setupContainer("alpine");
223+
224+
// Install wget but ensure curl is not present.
225+
await execContainer(container.id, [
226+
"sh",
227+
"-c",
228+
"apk add wget && ! command -v curl",
229+
]);
230+
231+
const script = findResourceInstance(fixture.state, "coder_script").script;
232+
const resp = await execContainer(container.id, [
233+
"sh",
234+
"-c",
235+
ENV_PREFIX + script,
236+
]);
237+
238+
expect(resp.exitCode).toBe(0);
239+
expect(resp.stdout).toContain("via wget");
240+
expect(resp.stdout).toContain("portabledesktop installed successfully");
241+
}, 30000);
242+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
terraform {
2+
required_version = ">= 1.0"
3+
4+
required_providers {
5+
coder = {
6+
source = "coder/coder"
7+
version = ">= 2.5"
8+
}
9+
}
10+
}
11+
12+
variable "agent_id" {
13+
type = string
14+
description = "The ID of a Coder agent."
15+
}
16+
17+
variable "install_dir" {
18+
type = string
19+
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."
20+
default = null
21+
}
22+
23+
variable "url" {
24+
type = string
25+
description = "Custom download URL. Overrides the default GitHub latest release URL when set."
26+
default = null
27+
}
28+
29+
variable "sha256" {
30+
type = string
31+
description = "SHA256 checksum. When set, the downloaded binary is verified against it."
32+
default = null
33+
}
34+
35+
locals {
36+
default_amd64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-x64"
37+
default_arm64_url = "https://github.com/coder/portabledesktop/releases/latest/download/portabledesktop-linux-arm64"
38+
39+
using_custom_url = var.url != null
40+
41+
amd64_url = local.using_custom_url ? var.url : local.default_amd64_url
42+
arm64_url = local.using_custom_url ? var.url : local.default_arm64_url
43+
44+
# Empty string signals "skip verification" to the shell script.
45+
sha256 = var.sha256 != null ? var.sha256 : ""
46+
install_dir = var.install_dir != null ? var.install_dir : ""
47+
}
48+
49+
resource "coder_script" "portabledesktop" {
50+
agent_id = var.agent_id
51+
display_name = "Portable Desktop"
52+
icon = "/icon/desktop.svg"
53+
script = <<-EOT
54+
#!/bin/sh
55+
set -eu
56+
echo -n '${base64encode(file("${path.module}/run.sh"))}' | base64 -d > /tmp/portabledesktop-install.sh
57+
chmod +x /tmp/portabledesktop-install.sh
58+
ARG_AMD64_URL="$(echo -n '${base64encode(local.amd64_url)}' | base64 -d)" \
59+
ARG_ARM64_URL="$(echo -n '${base64encode(local.arm64_url)}' | base64 -d)" \
60+
ARG_SHA256="$(echo -n '${base64encode(local.sha256)}' | base64 -d)" \
61+
ARG_INSTALL_DIR="$(echo -n '${base64encode(local.install_dir)}' | base64 -d)" \
62+
/tmp/portabledesktop-install.sh
63+
EOT
64+
run_on_start = true
65+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
run "plan_with_required_vars" {
2+
command = plan
3+
4+
variables {
5+
agent_id = "example-agent-id"
6+
}
7+
}
8+
9+
run "plan_with_custom_install_dir" {
10+
command = plan
11+
12+
variables {
13+
agent_id = "example-agent-id"
14+
install_dir = "/opt/bin"
15+
}
16+
17+
assert {
18+
condition = resource.coder_script.portabledesktop.display_name == "Portable Desktop"
19+
error_message = "Expected coder_script resource to have correct display name"
20+
}
21+
}
22+
23+
run "plan_with_custom_url" {
24+
command = plan
25+
26+
variables {
27+
agent_id = "example-agent-id"
28+
url = "https://example.com/custom-portabledesktop"
29+
sha256 = "abc123"
30+
}
31+
32+
assert {
33+
condition = resource.coder_script.portabledesktop.run_on_start == true
34+
error_message = "Expected coder_script to run on start"
35+
}
36+
}

0 commit comments

Comments
 (0)