diff --git a/.icons/selkies.svg b/.icons/selkies.svg new file mode 100644 index 000000000..9bf3875f1 --- /dev/null +++ b/.icons/selkies.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/registry/selkies-project/.images/avatar.svg b/registry/selkies-project/.images/avatar.svg new file mode 100644 index 000000000..9bf3875f1 --- /dev/null +++ b/registry/selkies-project/.images/avatar.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/registry/selkies-project/.images/docker-desktop.png b/registry/selkies-project/.images/docker-desktop.png new file mode 100644 index 000000000..13b01d572 Binary files /dev/null and b/registry/selkies-project/.images/docker-desktop.png differ diff --git a/registry/selkies-project/.images/selkies-desktop.png b/registry/selkies-project/.images/selkies-desktop.png new file mode 100644 index 000000000..974afb10c Binary files /dev/null and b/registry/selkies-project/.images/selkies-desktop.png differ diff --git a/registry/selkies-project/README.md b/registry/selkies-project/README.md new file mode 100644 index 000000000..a644bb233 --- /dev/null +++ b/registry/selkies-project/README.md @@ -0,0 +1,12 @@ +--- +display_name: "Selkies" +bio: "Low-latency, GPU-accelerated web remote desktop streaming for self-hosting, containers, Kubernetes, and cloud and HPC clusters." +avatar: "./.images/avatar.svg" +github: "selkies-project" +website: "https://selkies.io" +status: "community" +--- + +# Selkies + +Low-latency, GPU-accelerated web remote desktop streaming for self-hosting, containers, Kubernetes, and cloud and HPC clusters, from [selkies-project/selkies](https://github.com/selkies-project/selkies). diff --git a/registry/selkies-project/modules/selkies/README.md b/registry/selkies-project/modules/selkies/README.md new file mode 100644 index 000000000..d1b4c64f5 --- /dev/null +++ b/registry/selkies-project/modules/selkies/README.md @@ -0,0 +1,88 @@ +--- +display_name: Selkies +description: A low-latency desktop streamed to the browser, with audio, gamepads, and GPU encoding +icon: ../../../../.icons/selkies.svg +verified: false +tags: [desktop, selkies, gpu, websocket] +--- + +# Selkies + +Stream the workspace's desktop to the browser with [Selkies](https://github.com/selkies-project/selkies): low latency at high frame rates, audio in both directions, gamepads, and the GPU encoding the stream where the workspace has one. Coder authenticates the app and proxies it over one WebSocket, on a subdomain or a path. + +```tf +module "selkies" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/selkies-project/selkies/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + desktop_environment = "xfce" +} +``` + +> [!IMPORTANT] +> The workspace needs a desktop installed, as the [`codercom/example-desktop`](https://hub.docker.com/r/codercom/example-desktop) image has. + +![Selkies desktop in a Coder workspace](../../.images/selkies-desktop.png) + +`desktop_environment` names a session installed in the workspace (`xfce`, `kde`, `lxqt`, `gnome`, `mate`) or gives a command to run; empty starts the workspace's default desktop. The module's variables are those of the [KasmVNC module](https://registry.coder.com/modules/coder/kasmvnc), so a template swaps one for the other or offers both. + +## Selkies in the Image + +Where the image carries Selkies and Xvfb, the module installs nothing and needs neither `sudo` nor network access, and `install_selkies = false` keeps it that way. Selkies publishes native packages, a Python wheel, and an AppImage; see its [native install guide](https://github.com/selkies-project/selkies/blob/main/docs/native.md). + +```tf +module "selkies" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/selkies-project/selkies/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + desktop_environment = "xfce" + install_selkies = false +} +``` + +## Installing at Start + +Otherwise, the module installs the release's native package, the distribution's Xvfb, and PulseAudio where the workspace has no sound server, as root or with passwordless `sudo`, on the distributions a release publishes packages for (for 2.0.0: Ubuntu 24.04 and 26.04, Debian 12 and 13, Fedora, RHEL 9 and its rebuilds, Alpine, and Arch Linux). `selkies_version` pins a release, and `release_url` points at a mirror laid out like GitHub's releases. + +```tf +module "selkies" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/selkies-project/selkies/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + desktop_environment = "xfce" + selkies_version = "2.0.0" + release_url = "https://artifacts.example.com/selkies/releases" +} +``` + +## Wayland + +`wayland = true` streams through Selkies' Wayland backend instead of an Xvfb. The desktop is then a Wayland compositor, or a desktop that starts one, nested in Selkies' own. + +```tf +module "selkies" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/selkies-project/selkies/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + desktop_environment = "labwc" + wayland = true +} +``` + +## Network Access + +The module contacts the network only when it installs: + +- `/latest`, to resolve the latest release without GitHub's rate-limited API, unless `selkies_version` is set. +- `/download//selkies--`, the package. +- The distribution's package repositories, for Xvfb, PulseAudio, and the package's dependencies. + +Once it runs, the browser reaches Selkies through Coder's proxy on the workspace's loopback addresses; no WebRTC, STUN, or TURN server is involved. + +## Troubleshooting + +The logs are in `~/.coder-modules/selkies-project/selkies/logs/`: `install.log`, `start.log`, and Selkies' own `selkies-session.log`. `coder port-forward --tcp 8080:8080` reaches the same desktop at `http://localhost:8080`, which browsers treat as a secure context for the clipboard, gamepads, and the microphone. diff --git a/registry/selkies-project/modules/selkies/main.test.ts b/registry/selkies-project/modules/selkies/main.test.ts new file mode 100644 index 000000000..321975515 --- /dev/null +++ b/registry/selkies-project/modules/selkies/main.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it, setDefaultTimeout } from "bun:test"; +import path from "node:path"; +import { + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + type TerraformState, + testRequiredVariables, + writeCoder, + writeFileContainer, +} from "~test"; + +setDefaultTimeout(120_000); + +const IMAGE = "node:22-bookworm-slim"; + +type Variables = Readonly<{ + agent_id: string; + desktop_environment?: string; + port?: number; + install_selkies?: boolean; +}>; + +interface Scripts { + install: string; + start: string; +} + +const scriptsOf = (state: TerraformState): Scripts => { + const scripts: Partial = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") { + continue; + } + for (const instance of resource.instances) { + const { display_name: name, script } = instance.attributes as Record< + string, + unknown + >; + if (typeof script !== "string") { + continue; + } + if (name === "Selkies: Install Script") { + scripts.install = script; + } else if (name === "Selkies: Start Script") { + scripts.start = script; + } + } + } + if (!scripts.install || !scripts.start) { + throw new Error("the module must create an install and a start script"); + } + return scripts as Scripts; +}; + +let containers: string[] = []; + +afterEach(async () => { + for (const id of containers) { + await removeContainer(id); + } + containers = []; +}); + +// A workspace with the coder CLI stubbed and, as an image carrying Selkies +// would have them, a launcher and an Xvfb on PATH +const workspace = async (launcher?: string): Promise => { + const id = await runContainer(IMAGE); + containers.push(id); + await writeCoder(id, "#!/bin/sh\nexit 0\n"); + if (launcher !== undefined) { + await writeFileContainer(id, "/usr/local/bin/selkies-session", launcher, { + user: "root", + }); + await writeFileContainer(id, "/usr/local/bin/Xvfb", "#!/bin/sh\n", { + user: "root", + }); + await execContainer( + id, + ["chmod", "755", "/usr/local/bin/selkies-session", "/usr/local/bin/Xvfb"], + ["--user", "root"], + ); + } + return id; +}; + +const mockLauncher = () => + Bun.file( + path.join(import.meta.dir, "testdata", "selkies-session-mock.sh"), + ).text(); + +const run = (id: string, script: string) => + execContainer(id, ["bash", "-c", script]); + +describe("selkies", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { agent_id: "foo" }); + + it("installs nothing where the image has Selkies, and starts it behind the app's port", async () => { + const scripts = scriptsOf( + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + desktop_environment: "xfce", + }), + ); + const id = await workspace(await mockLauncher()); + + const install = await run(id, scripts.install); + expect(install.exitCode).toBe(0); + expect(install.stdout).toContain( + "Selkies and its display server are installed", + ); + + const start = await run(id, scripts.start); + expect(start.exitCode).toBe(0); + expect(start.stdout).toContain("Selkies is ready on port 8080"); + const args = await readFileContainer(id, "/tmp/selkies-session.args"); + expect(args.trim().split("\n")).toEqual([ + "--port=8080", + "--enable-basic-auth=false", + "--enable-https=false", + "--session=xfce", + ]); + + const again = await run(id, scripts.start); + expect(again.exitCode).toBe(0); + expect(again.stdout).toContain("Selkies already answers on port 8080"); + }); + + it("installs nothing when install_selkies is false, and says what is missing", async () => { + const scripts = scriptsOf( + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install_selkies: false, + }), + ); + const id = await workspace(); + + const install = await run(id, scripts.install); + expect(install.exitCode).not.toBe(0); + expect(install.stdout).toContain( + "The workspace lacks selkies-session Xvfb and install_selkies is false", + ); + }); + + it("reports a Selkies that exits before it answers", async () => { + const scripts = scriptsOf( + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + port: 8081, + }), + ); + const id = await workspace( + "#!/bin/sh\necho 'Xvfb did not come up' >&2\nexit 1\n", + ); + + // The install script runs first, as Coder orders them, and lays out the module's directory + expect((await run(id, scripts.install)).exitCode).toBe(0); + const start = await run(id, scripts.start); + expect(start.exitCode).not.toBe(0); + expect(start.stdout).toContain("Selkies exited before it answered"); + expect(start.stdout).toContain("Xvfb did not come up"); + }); +}); diff --git a/registry/selkies-project/modules/selkies/main.tf b/registry/selkies-project/modules/selkies/main.tf new file mode 100644 index 000000000..81c7591c5 --- /dev/null +++ b/registry/selkies-project/modules/selkies/main.tf @@ -0,0 +1,149 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.13" + } + } +} + +variable "agent_id" { + description = "The ID of a Coder agent." + type = string +} + +variable "port" { + description = "The port Selkies listens on, on the workspace's loopback addresses." + type = number + default = 8080 + + validation { + condition = var.port >= 1 && var.port <= 65535 && floor(var.port) == var.port + error_message = "port must be an integer between 1 and 65535." + } +} + +variable "desktop_environment" { + description = "The desktop to start: a session installed in the workspace, by name (xfce, kde, lxqt, gnome, mate), or a command. Empty starts the workspace's default desktop." + type = string + default = "" +} + +variable "wayland" { + description = "Stream through Selkies' Wayland backend instead of an Xvfb." + type = bool + default = false +} + +variable "install_selkies" { + description = "Install what the workspace lacks of Selkies and Xvfb, as root or with passwordless sudo. Set to false when the image carries both." + type = bool + default = true +} + +variable "selkies_version" { + description = "The Selkies release to install, such as 2.0.0. Empty installs the latest." + type = string + default = "" + + validation { + condition = can(regex("^([0-9A-Za-z][0-9A-Za-z._-]*)?$", var.selkies_version)) + error_message = "selkies_version must be a release tag such as 2.0.0, or empty for the latest." + } +} + +variable "release_url" { + description = "Where Selkies' releases are downloaded from: GitHub's, or a mirror laid out the same way (/download//). A mirror without a /latest redirect needs selkies_version." + type = string + default = "https://github.com/selkies-project/selkies/releases" + + validation { + condition = can(regex("^https?://[^\\s'\"]+[^/\\s'\"]$", var.release_url)) + error_message = "release_url must be an http(s) URL without quotes or a trailing slash." + } +} + +variable "order" { + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + type = number + default = null +} + +variable "group" { + description = "The name of a group that this app belongs to." + type = string + default = null +} + +variable "subdomain" { + description = "Is subdomain sharing enabled in your cluster?" + type = bool + default = true +} + +variable "share" { + description = "Who can open the app: the workspace's owner, any authenticated user, or anyone." + type = string + default = "owner" + + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +locals { + icon = "/icon/selkies.svg" + module_directory = "$HOME/.coder-modules/selkies-project/selkies" + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_selkies) + ARG_WAYLAND = tostring(var.wayland) + ARG_VERSION = var.selkies_version + ARG_RELEASE_URL = var.release_url + }) + + start_script = templatefile("${path.module}/scripts/start.sh.tftpl", { + ARG_PORT = tostring(var.port) + ARG_WAYLAND = tostring(var.wayland) + ARG_DESKTOP_B64 = base64encode(var.desktop_environment) + }) +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.2" + + agent_id = var.agent_id + module_directory = local.module_directory + display_name_prefix = "Selkies" + icon = local.icon + install_script = local.install_script + start_script = local.start_script +} + +# Selkies derives its path prefix from the URL it is loaded from, so a path app works as well as a subdomain. +resource "coder_app" "selkies" { + agent_id = var.agent_id + slug = "selkies" + display_name = "Selkies" + url = "http://localhost:${var.port}" + icon = local.icon + subdomain = var.subdomain + share = var.share + order = var.order + group = var.group + + healthcheck { + url = "http://localhost:${var.port}/api/health" + interval = 5 + threshold = 6 + } +} + +output "scripts" { + description = "Ordered list of coder exp sync names for the scripts this module runs: the install script, then the start script." + value = module.coder_utils.scripts +} diff --git a/registry/selkies-project/modules/selkies/main.tftest.hcl b/registry/selkies-project/modules/selkies/main.tftest.hcl new file mode 100644 index 000000000..4745486f4 --- /dev/null +++ b/registry/selkies-project/modules/selkies/main.tftest.hcl @@ -0,0 +1,197 @@ +mock_provider "coder" {} + +run "defaults" { + command = plan + + variables { + agent_id = "agent" + } + + assert { + condition = coder_app.selkies.url == "http://localhost:8080" + error_message = "The app must reach Selkies on the default port." + } + + assert { + condition = coder_app.selkies.slug == "selkies" && coder_app.selkies.display_name == "Selkies" && coder_app.selkies.icon == "/icon/selkies.svg" + error_message = "The app must be named and branded Selkies." + } + + assert { + condition = coder_app.selkies.subdomain && coder_app.selkies.share == "owner" + error_message = "The app must default to a subdomain its owner alone opens, as the KasmVNC module's does." + } + + assert { + condition = local.module_directory == "$HOME/.coder-modules/selkies-project/selkies" + error_message = "The module must keep its data under the standard per-module root." + } + + assert { + condition = strcontains(local.start_script, "--enable-basic-auth=false --enable-https=false") && !strcontains(local.start_script, "--public") + error_message = "Selkies must listen on the loopback addresses alone, leaving the login and TLS to Coder." + } + + assert { + condition = strcontains(local.start_script, "ARG_DESKTOP=$(echo -n '' | base64 -d)") + error_message = "An empty desktop_environment must start the workspace's default desktop." + } + + assert { + condition = strcontains(local.install_script, "ARG_INSTALL='true'") && strcontains(local.install_script, "ARG_VERSION=''") + error_message = "The module must install the latest release where the workspace lacks Selkies." + } + + assert { + condition = strcontains(local.install_script, "ARG_RELEASE_URL='https://github.com/selkies-project/selkies/releases'") && !strcontains(local.install_script, "api.github.com") + error_message = "The latest release must be resolved from GitHub's releases redirect, not its rate-limited API." + } +} + +run "health_check_and_scripts" { + command = apply + + variables { + agent_id = "agent" + } + + assert { + condition = one(coder_app.selkies.healthcheck).url == "http://localhost:8080/api/health" + error_message = "The health check must use Selkies' /api/health endpoint." + } + + assert { + condition = length(output.scripts) == 2 + error_message = "The module must run an install script and then a start script." + } +} + +run "kasmvnc_module_variables" { + command = plan + + variables { + agent_id = "agent" + desktop_environment = "xfce" + port = 6800 + subdomain = false + share = "authenticated" + order = 3 + group = "Desktops" + } + + assert { + condition = coder_app.selkies.url == "http://localhost:6800" && !coder_app.selkies.subdomain && coder_app.selkies.share == "authenticated" + error_message = "The app must follow the port, subdomain, and share variables." + } + + assert { + condition = coder_app.selkies.order == 3 && coder_app.selkies.group == "Desktops" + error_message = "The app must keep its order and group." + } + + assert { + condition = strcontains(local.start_script, base64encode("xfce")) && strcontains(local.start_script, "ARG_PORT='6800'") + error_message = "The start script must start the named desktop on the configured port." + } +} + +run "desktop_command" { + command = plan + + variables { + agent_id = "agent" + desktop_environment = "startxfce4 --replace 'now'" + } + + assert { + condition = strcontains(local.start_script, base64encode("startxfce4 --replace 'now'")) + error_message = "A desktop command must reach the start script intact, quotes included." + } +} + +run "wayland" { + command = plan + + variables { + agent_id = "agent" + wayland = true + } + + assert { + condition = strcontains(local.start_script, "ARG_WAYLAND='true'") && strcontains(local.install_script, "ARG_WAYLAND='true'") + error_message = "The Wayland backend must reach both scripts, so neither starts nor installs an Xvfb." + } +} + +run "image_carries_selkies" { + command = plan + + variables { + agent_id = "agent" + install_selkies = false + } + + assert { + condition = strcontains(local.install_script, "ARG_INSTALL='false'") + error_message = "install_selkies = false must keep the install script from installing." + } +} + +run "pinned_release_from_a_mirror" { + command = plan + + variables { + agent_id = "agent" + selkies_version = "2.0.0" + release_url = "https://artifacts.example.com/selkies/releases" + } + + assert { + condition = strcontains(local.install_script, "ARG_VERSION='2.0.0'") && strcontains(local.install_script, "ARG_RELEASE_URL='https://artifacts.example.com/selkies/releases'") + error_message = "The install script must download the pinned release from the mirror." + } +} + +run "rejects_an_invalid_share" { + command = plan + + variables { + agent_id = "agent" + share = "everyone" + } + + expect_failures = [var.share] +} + +run "rejects_a_version_that_is_not_a_tag" { + command = plan + + variables { + agent_id = "agent" + selkies_version = "2.0.0'; true" + } + + expect_failures = [var.selkies_version] +} + +run "rejects_a_release_url_with_a_trailing_slash" { + command = plan + + variables { + agent_id = "agent" + release_url = "https://github.com/selkies-project/selkies/releases/" + } + + expect_failures = [var.release_url] +} + +run "rejects_a_port_out_of_range" { + command = plan + + variables { + agent_id = "agent" + port = 70000 + } + + expect_failures = [var.port] +} diff --git a/registry/selkies-project/modules/selkies/scripts/install.sh.tftpl b/registry/selkies-project/modules/selkies/scripts/install.sh.tftpl new file mode 100644 index 000000000..4cf2acf36 --- /dev/null +++ b/registry/selkies-project/modules/selkies/scripts/install.sh.tftpl @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Install what the workspace lacks of Selkies and its display server: the +# release's native package and the distribution's Xvfb, with PulseAudio where +# no sound server is installed, as root or with passwordless sudo. A workspace +# that has Selkies and its display server installs nothing. +if [ -r /etc/os-release ]; then + # shellcheck source=/dev/null + . /etc/os-release +fi + +set -euo pipefail + +ARG_INSTALL='${ARG_INSTALL}' +ARG_WAYLAND='${ARG_WAYLAND}' +ARG_VERSION='${ARG_VERSION}' +ARG_RELEASE_URL='${ARG_RELEASE_URL}' + +missing=() +command -v selkies-session > /dev/null || missing+=(selkies-session) +[ "$ARG_WAYLAND" = true ] || command -v Xvfb > /dev/null || missing+=(Xvfb) +if [ $${#missing[@]} -eq 0 ]; then + echo "Selkies and its display server are installed" + exit 0 +fi +if [ "$ARG_INSTALL" != true ]; then + echo "The workspace lacks $${missing[*]} and install_selkies is false: install them in the image" >&2 + exit 1 +fi + +sudo=() +if [ "$(id -u)" -ne 0 ]; then + if ! sudo -n true 2> /dev/null; then + echo "Installing $${missing[*]} needs root or sudo without a password: install them in the image instead" >&2 + exit 1 + fi + sudo=(sudo) +fi + +if command -v apt-get > /dev/null; then + platform="$${ID:-}$([ "$${ID:-}" = ubuntu ] && echo "$${VERSION_ID:-}" || echo "$${VERSION_CODENAME:-}")-$(dpkg --print-architecture).deb" + apt=("$${sudo[@]}" env DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300) + "$${apt[@]}" update -qq + install_file=("$${apt[@]}" install -y) + install_repo=("$${apt[@]}" install -y --no-install-recommends) + xvfb=xvfb +elif command -v dnf > /dev/null; then + platform="$([ "$${ID:-}" = fedora ] && echo fc || echo "el$${VERSION_ID%%.*}")-$(uname -m).rpm" + install_file=("$${sudo[@]}" dnf install -y) + install_repo=("$${sudo[@]}" dnf install -y) + xvfb=xorg-x11-server-Xvfb +elif command -v apk > /dev/null; then + platform="$(uname -m).apk" + install_file=("$${sudo[@]}" apk add --allow-untrusted) + install_repo=("$${sudo[@]}" apk add) + xvfb=xvfb +elif command -v pacman > /dev/null; then + platform="$(uname -m).pkg.tar.zst" + install_file=("$${sudo[@]}" pacman -U --noconfirm) + install_repo=("$${sudo[@]}" pacman -S --noconfirm --needed) + xvfb=xorg-server-xvfb +else + echo "No package manager Selkies publishes packages for (apt, dnf, apk, pacman): install Selkies in the image instead" >&2 + exit 1 +fi + +if ! command -v selkies-session > /dev/null; then + command -v curl > /dev/null || "$${install_repo[@]}" curl + version=$ARG_VERSION + if [ -z "$version" ]; then + # The latest release's tag, from the redirect GitHub answers without its rate-limited API + latest=$(curl -fsSLI -o /dev/null -w '%%{url_effective}' "$ARG_RELEASE_URL/latest") || latest= + version=$${latest##*/} + if [ -z "$version" ] || [ "$version" = latest ]; then + echo "Could not resolve the latest release at $ARG_RELEASE_URL: set selkies_version" >&2 + exit 1 + fi + fi + file="selkies-$version-$platform" + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + # The package manager reads the file as an unprivileged user of its own + chmod 755 "$work" + echo "Installing Selkies $version ($file)" + if ! curl -fsSL -o "$work/$file" "$ARG_RELEASE_URL/download/$version/$file"; then + echo "Selkies $version publishes no $file for $${PRETTY_NAME:-this workspace}: install Selkies in the image instead" >&2 + exit 1 + fi + "$${install_file[@]}" "$work/$file" +fi + +if [ "$ARG_WAYLAND" != true ] && ! command -v Xvfb > /dev/null; then + echo "Installing Xvfb" + "$${install_repo[@]}" "$xvfb" +fi +if ! command -v pulseaudio > /dev/null && ! command -v pipewire-pulse > /dev/null; then + echo "Installing PulseAudio" + "$${install_repo[@]}" pulseaudio +fi +echo "Selkies and its display server are installed" diff --git a/registry/selkies-project/modules/selkies/scripts/start.sh.tftpl b/registry/selkies-project/modules/selkies/scripts/start.sh.tftpl new file mode 100644 index 000000000..f020bcf44 --- /dev/null +++ b/registry/selkies-project/modules/selkies/scripts/start.sh.tftpl @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Start Selkies with the workspace's desktop behind the app's port, unless it +# already answers. Coder authenticates the app and terminates TLS, so Selkies +# listens on the loopback addresses without a login or TLS of its own. +set -euo pipefail + +ARG_PORT='${ARG_PORT}' +ARG_WAYLAND='${ARG_WAYLAND}' +ARG_DESKTOP=$(echo -n '${ARG_DESKTOP_B64}' | base64 -d) +LOG_PATH="$HOME/.coder-modules/selkies-project/selkies/logs/selkies-session.log" + +# A GET of /api/health over bash's own TCP, so the check needs no curl +healthy() { + ( + exec 3<> "/dev/tcp/127.0.0.1/$ARG_PORT" + printf 'GET /api/health HTTP/1.0\r\nHost: localhost\r\n\r\n' >&3 + read -r -t 5 _ status _ <&3 + [ "$status" = 200 ] + ) 2> /dev/null +} + +if healthy; then + echo "Selkies already answers on port $ARG_PORT" + exit 0 +fi + +mkdir -p "$(dirname "$LOG_PATH")" +args=(--port="$ARG_PORT" --enable-basic-auth=false --enable-https=false) +[ -z "$ARG_DESKTOP" ] || args+=(--session="$ARG_DESKTOP") +SELKIES_WAYLAND="$ARG_WAYLAND" nohup selkies-session "$${args[@]}" >> "$LOG_PATH" 2>&1 & +pid=$! +echo "Starting Selkies on port $ARG_PORT; its log is $LOG_PATH" +for _ in $(seq 1 60); do + if healthy; then + echo "Selkies is ready on port $ARG_PORT" + exit 0 + fi + if ! kill -0 "$pid" 2> /dev/null; then + echo "Selkies exited before it answered; the end of its log:" >&2 + tail -n 20 "$LOG_PATH" >&2 + exit 1 + fi + sleep 1 +done +echo "Selkies did not answer on port $ARG_PORT within 60 seconds; see $LOG_PATH" >&2 +exit 1 diff --git a/registry/selkies-project/modules/selkies/testdata/selkies-session-mock.sh b/registry/selkies-project/modules/selkies/testdata/selkies-session-mock.sh new file mode 100755 index 000000000..174cee8ac --- /dev/null +++ b/registry/selkies-project/modules/selkies/testdata/selkies-session-mock.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Stands in for selkies-session: records how it was started, and answers +# /api/health on the port it was given, as Selkies does +printf '%s\n' "$@" > /tmp/selkies-session.args +printf 'SELKIES_WAYLAND=%s\n' "${SELKIES_WAYLAND:-}" > /tmp/selkies-session.env +port=8080 +for arg in "$@"; do + case "$arg" in --port=*) port=${arg#--port=} ;; esac +done +exec node -e ' + require("node:http") + .createServer((req, res) => { + res.writeHead(req.url === "/api/health" ? 200 : 404); + res.end(); + }) + .listen(Number(process.argv[1]), "127.0.0.1"); +' "$port" diff --git a/registry/selkies-project/templates/docker-desktop/README.md b/registry/selkies-project/templates/docker-desktop/README.md new file mode 100644 index 000000000..5b7782a94 --- /dev/null +++ b/registry/selkies-project/templates/docker-desktop/README.md @@ -0,0 +1,30 @@ +--- +display_name: Selkies Desktop on Docker +description: A Linux desktop in a Docker container, streamed to the browser by Selkies with audio, gamepads, and GPU encoding +icon: ../../../../.icons/selkies.svg +verified: false +tags: [docker, container, desktop, selkies, gpu] +--- + +# Selkies Desktop on Docker + +Provision Docker containers as [Coder workspaces](https://coder.com/docs/workspaces) that run [Selkies' desktop image](https://docs.selkies.io/components/desktop-image): an LXQt desktop with Firefox and Google Chrome, streamed to the browser by the [Selkies module](https://registry.coder.com/modules/selkies-project/selkies) with low latency, audio in both directions, and gamepads, and encoded on an NVIDIA GPU where the workspace has one. + +![Selkies desktop in a Coder workspace on an NVIDIA GPU](../../.images/docker-desktop.png) + +## Prerequisites + +Coder needs access to a Docker socket, as for the [Docker Containers](https://registry.coder.com/templates/coder/docker) template. + +A workspace on an NVIDIA GPU needs the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) v1.20.1 or higher on the Docker host, with its `nvidia` runtime registered with Docker (`sudo nvidia-ctk runtime configure --runtime=docker`). The desktop then renders and encodes on the GPU, on X11 and on Wayland alike. Without one, it renders and encodes in software, even on a host whose default runtime is NVIDIA's. + +Intel and AMD GPUs take the DRM render node and its group instead: add `devices { host_path = "/dev/dri" }` and `group_add = [""]` to the container, as Selkies' [Getting Started](https://docs.selkies.io/start) does with `docker run`. + +## Architecture + +This template provisions the following resources: + +- Docker container from `ghcr.io/selkies-project/selkies/desktop:latest-ubuntu26.04` (ephemeral) +- Docker volume (persistent on `/home/ubuntu`) + +The Coder agent runs in place of the image's own init, and the Selkies module starts the image's default desktop and Selkies behind Coder's proxy, so the image's own login, TLS, and TURN server are not used. When the workspace restarts, anything outside the home directory is reset to the image. diff --git a/registry/selkies-project/templates/docker-desktop/main.tf b/registry/selkies-project/templates/docker-desktop/main.tf new file mode 100644 index 000000000..0532c5bd7 --- /dev/null +++ b/registry/selkies-project/templates/docker-desktop/main.tf @@ -0,0 +1,171 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + docker = { + source = "kreuzwerker/docker" + } + } +} + +variable "docker_socket" { + default = "" + description = "(Optional) Docker socket URI" + type = string +} + +provider "docker" { + # Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default + host = var.docker_socket != "" ? var.docker_socket : null +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "gpu" { + name = "gpu" + display_name = "GPU" + description = "The GPU the desktop renders and encodes the stream on. NVIDIA needs the NVIDIA Container Toolkit on the Docker host." + default = "none" + mutable = true + option { + name = "None" + value = "none" + } + option { + name = "NVIDIA" + value = "nvidia" + } +} + +data "coder_parameter" "display_backend" { + name = "display_backend" + display_name = "Display backend" + description = "The display server the desktop runs on." + default = "x11" + mutable = true + option { + name = "X11" + value = "x11" + } + option { + name = "Wayland" + value = "wayland" + } +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } +} + +# See https://registry.coder.com/modules/selkies-project/selkies +module "selkies" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/selkies-project/selkies/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + install_selkies = false + wayland = data.coder_parameter.display_backend.value == "wayland" +} + +resource "docker_volume" "home_volume" { + name = "coder-${data.coder_workspace.me.id}-home" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + # This field becomes outdated if the workspace is renamed but can + # be useful for debugging or cleaning out dangling volumes. + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + # Selkies' desktop image: LXQt, Firefox, and Chrome, with Selkies and Xvfb installed + image = "ghcr.io/selkies-project/selkies/desktop:latest-ubuntu26.04" + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + # Hostname makes the shell more user friendly: ubuntu@my-workspace:~$ + hostname = data.coder_workspace.me.name + # The agent runs in place of the image's own init, and the module starts the desktop and Selkies + entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] + env = [ + "CODER_AGENT_TOKEN=${coder_agent.main.token}", + # The image asks for every NVIDIA GPU, which a host whose default runtime is NVIDIA's would otherwise pass in + "NVIDIA_VISIBLE_DEVICES=${data.coder_parameter.gpu.value == "nvidia" ? "all" : "void"}", + ] + gpus = data.coder_parameter.gpu.value == "nvidia" ? "all" : null + runtime = data.coder_parameter.gpu.value == "nvidia" ? "nvidia" : null + # Browsers crash in Docker's default 64 MB of shared memory + shm_size = 2048 + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/home/ubuntu" + volume_name = docker_volume.home_volume.name + read_only = false + } + + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +}