From ca5a90857512167548f0d6a74787d7fcbdb61959 Mon Sep 17 00:00:00 2001 From: Quantum-Forge-Code Date: Thu, 5 Feb 2026 03:33:30 +0000 Subject: [PATCH] feat: Add Parsec cloud gaming module for Windows workspaces - Add Parsec installation script for Windows (install-parsec.ps1) - Support for Parsec Teams deployment with team_id and team_key - External app links to Parsec web client and documentation - Terraform tests (parsec.tftest.hcl) - TypeScript tests (main.test.ts) - Custom hostname and auto_start configuration options - Add parsec.svg icon Closes #205 --- .icons/parsec.svg | 5 + registry/coder/modules/parsec/README.md | 160 ++++++++++++++++ .../coder/modules/parsec/install-parsec.ps1 | 153 +++++++++++++++ registry/coder/modules/parsec/main.test.ts | 177 ++++++++++++++++++ registry/coder/modules/parsec/main.tf | 112 +++++++++++ .../coder/modules/parsec/parsec.tftest.hcl | 97 ++++++++++ 6 files changed, 704 insertions(+) create mode 100644 .icons/parsec.svg create mode 100644 registry/coder/modules/parsec/README.md create mode 100644 registry/coder/modules/parsec/install-parsec.ps1 create mode 100644 registry/coder/modules/parsec/main.test.ts create mode 100644 registry/coder/modules/parsec/main.tf create mode 100644 registry/coder/modules/parsec/parsec.tftest.hcl diff --git a/.icons/parsec.svg b/.icons/parsec.svg new file mode 100644 index 000000000..3c88e320d --- /dev/null +++ b/.icons/parsec.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/registry/coder/modules/parsec/README.md b/registry/coder/modules/parsec/README.md new file mode 100644 index 000000000..93b9351bc --- /dev/null +++ b/registry/coder/modules/parsec/README.md @@ -0,0 +1,160 @@ +--- +display_name: Parsec +description: Install Parsec for low-latency cloud gaming and remote desktop on Windows workspaces +icon: ../../../../.icons/parsec.svg +verified: false +tags: [windows, gaming, streaming, remote-desktop] +--- + +# Parsec + +Enable [Parsec](https://parsec.app/) for low-latency cloud gaming and remote desktop access on Windows workspaces. Parsec provides high-performance streaming with support for 4K, 60fps, and low-latency input. + +```tf +module "parsec" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/parsec/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +## Features + +- **Low-latency streaming**: Sub-16ms latency for responsive gaming and productivity +- **High quality video**: Up to 4K resolution at 60fps +- **GPU acceleration**: Hardware encoding for smooth performance +- **Multi-monitor support**: Virtual monitors for cloud workspaces +- **Teams support**: Enterprise deployment with team computer keys + +## Requirements + +- Windows workspace with GPU support (recommended) +- Parsec account (free tier available) +- Parsec client installed on your local machine + +## Examples + +### Basic Installation + +Install Parsec with default settings: + +```tf +module "parsec" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/parsec/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +### With Custom Hostname + +Set a custom hostname for easier identification: + +```tf +module "parsec" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/parsec/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + host_name = "my-gaming-workspace" +} +``` + +### Parsec Teams Deployment + +For enterprise/team deployments with automated authentication: + +```tf +module "parsec" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/parsec/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + parsec_team_id = var.parsec_team_id + parsec_team_key = var.parsec_team_key +} +``` + +### AWS Windows Template + +Complete example with AWS Windows instance: + +```tf +module "parsec" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/parsec/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} + +# Recommended: Use GPU instance types like g4dn.xlarge for best performance +``` + +### GCP Windows Template + +Complete example with Google Cloud Windows instance: + +```tf +module "parsec" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/parsec/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} + +# Recommended: Use N1 with NVIDIA T4 GPU for best performance +``` + +## Connecting to Your Workspace + +1. **Install Parsec client** on your local machine from [parsec.app/downloads](https://parsec.app/downloads) +2. **Log in** to your Parsec account (same account as the workspace or Teams account) +3. **Find your workspace** in the Parsec computer list +4. **Click Connect** to start streaming + +## Configuration Options + +| Variable | Description | Default | +| ----------------- | ------------------------------------------- | -------------------- | +| `agent_id` | The ID of a Coder agent | Required | +| `display_name` | Display name for the Parsec app | `"Parsec"` | +| `slug` | Slug for the Parsec app | `"parsec"` | +| `icon` | Icon path | `"/icon/parsec.svg"` | +| `order` | App order in UI | `null` | +| `group` | App group name | `null` | +| `parsec_team_id` | Parsec Team ID for enterprise deployments | `""` | +| `parsec_team_key` | Parsec Team Computer Key for authentication | `""` | +| `host_name` | Custom hostname for the Parsec host | Workspace name | +| `auto_start` | Start Parsec service automatically | `true` | + +## GPU Recommendations + +For the best cloud gaming experience, use instances with dedicated GPUs: + +| Cloud Provider | Recommended Instance Types | +| -------------- | -------------------------- | +| AWS | g4dn.xlarge, g5.xlarge | +| GCP | n1-standard-4 + NVIDIA T4 | +| Azure | Standard_NV6 | + +## Troubleshooting + +### Parsec not starting + +- Ensure the workspace has GPU drivers installed +- Check Windows Event Viewer for Parsec service errors +- Verify network allows UDP traffic on ports 8000-8200 + +### High latency + +- Use an instance in a region close to you +- Ensure hardware encoding is enabled (requires GPU) +- Check network quality between client and workspace + +### Computer not appearing in Parsec + +- Wait 1-2 minutes after workspace starts +- Verify Parsec service is running: `Get-Service parsec` +- Check Parsec logs in `%APPDATA%\Parsec\logs` diff --git a/registry/coder/modules/parsec/install-parsec.ps1 b/registry/coder/modules/parsec/install-parsec.ps1 new file mode 100644 index 000000000..7f9d4a69c --- /dev/null +++ b/registry/coder/modules/parsec/install-parsec.ps1 @@ -0,0 +1,153 @@ +# Parsec Installation Script for Coder Workspaces +# This script installs and configures Parsec for cloud gaming and remote desktop + +$ErrorActionPreference = "Stop" + +Write-Output "=== Installing Parsec for Coder Workspace ===" + +# Configuration from Terraform variables +$ParsecTeamId = "${parsec_team_id}" +$ParsecTeamKey = "${parsec_team_key}" +$HostName = "${host_name}" +$AutoStart = [System.Convert]::ToBoolean("${auto_start}") + +# Parsec download URL and paths +$ParsecMsiUrl = "https://builds.parsec.app/package/parsec-windows.msi" +$ParsecInstallDir = "$env:ProgramFiles\Parsec" +$TempDir = "$env:TEMP\parsec-install" +$ParsecMsiPath = "$TempDir\parsec-windows.msi" + +# Create temp directory +if (-not (Test-Path $TempDir)) { + New-Item -ItemType Directory -Path $TempDir -Force | Out-Null +} + +# Check if Parsec is already installed +$ParsecInstalled = Test-Path "$ParsecInstallDir\parsecd.exe" + +if (-not $ParsecInstalled) { + Write-Output "Downloading Parsec..." + + # Download Parsec MSI + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + try { + Invoke-WebRequest -Uri $ParsecMsiUrl -OutFile $ParsecMsiPath -UseBasicParsing + } catch { + Write-Error "Failed to download Parsec: $_" + exit 1 + } + + Write-Output "Installing Parsec..." + + # Install Parsec silently + $InstallArgs = @( + "/i" + "`"$ParsecMsiPath`"" + "/qn" + "/norestart" + "ALLUSERS=1" + ) + + $InstallProcess = Start-Process -FilePath "msiexec.exe" -ArgumentList $InstallArgs -Wait -PassThru + + if ($InstallProcess.ExitCode -ne 0) { + Write-Error "Parsec installation failed with exit code: $($InstallProcess.ExitCode)" + exit 1 + } + + Write-Output "Parsec installed successfully." +} else { + Write-Output "Parsec is already installed." +} + +# Configure Parsec for headless/team deployment if credentials provided +$ParsecConfigDir = "$env:APPDATA\Parsec" +$ParsecConfigFile = "$ParsecConfigDir\config.txt" + +if (-not (Test-Path $ParsecConfigDir)) { + New-Item -ItemType Directory -Path $ParsecConfigDir -Force | Out-Null +} + +# Build configuration +$ConfigLines = @() + +# Set hostname if provided +if ($HostName -ne "") { + $ConfigLines += "host_name = $HostName" + Write-Output "Setting Parsec host name to: $HostName" +} + +# Enable hosting +$ConfigLines += "host_virtual_monitors = 1" +$ConfigLines += "host_privacy_mode = 0" + +# Write config if we have any settings +if ($ConfigLines.Count -gt 0) { + # Read existing config if present + $ExistingConfig = @() + if (Test-Path $ParsecConfigFile) { + $ExistingConfig = Get-Content $ParsecConfigFile + } + + # Merge configs (new values override existing) + $ConfigHash = @{} + foreach ($line in $ExistingConfig) { + if ($line -match "^([^=]+)=(.*)$") { + $ConfigHash[$Matches[1].Trim()] = $Matches[2].Trim() + } + } + foreach ($line in $ConfigLines) { + if ($line -match "^([^=]+)=(.*)$") { + $ConfigHash[$Matches[1].Trim()] = $Matches[2].Trim() + } + } + + # Write merged config + $FinalConfig = $ConfigHash.GetEnumerator() | ForEach-Object { "$($_.Key) = $($_.Value)" } + $FinalConfig | Out-File -FilePath $ParsecConfigFile -Encoding UTF8 + + Write-Output "Parsec configuration updated." +} + +# Configure for Parsec Teams if credentials provided +if ($ParsecTeamId -ne "" -and $ParsecTeamKey -ne "") { + Write-Output "Configuring Parsec Teams authentication..." + + # For Parsec Teams, we need to configure the team computer key + $TeamConfigFile = "$ParsecConfigDir\team_config.txt" + @" +team_id = $ParsecTeamId +team_computer_key = $ParsecTeamKey +"@ | Out-File -FilePath $TeamConfigFile -Encoding UTF8 + + Write-Output "Parsec Teams configuration saved." +} + +# Start Parsec if auto_start is enabled +if ($AutoStart) { + Write-Output "Starting Parsec..." + + $ParsecExe = "$ParsecInstallDir\parsecd.exe" + + if (Test-Path $ParsecExe) { + # Start Parsec in the background + Start-Process -FilePath $ParsecExe -WindowStyle Hidden + Write-Output "Parsec started successfully." + } else { + Write-Warning "Parsec executable not found at: $ParsecExe" + } +} + +# Cleanup +if (Test-Path $TempDir) { + Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Output "=== Parsec installation complete ===" +Write-Output "" +Write-Output "To connect to this workspace:" +Write-Output "1. Install Parsec client on your local machine: https://parsec.app/downloads" +Write-Output "2. Log in to your Parsec account" +Write-Output "3. This computer will appear in your Parsec computer list" +Write-Output "" +Write-Output "For Teams deployment, ensure you have configured team_id and team_computer_key." diff --git a/registry/coder/modules/parsec/main.test.ts b/registry/coder/modules/parsec/main.test.ts new file mode 100644 index 000000000..9e89679ac --- /dev/null +++ b/registry/coder/modules/parsec/main.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "bun:test"; +import { + type TerraformState, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +type TestVariables = Readonly<{ + agent_id: string; + display_name?: string; + slug?: string; + icon?: string; + order?: number; + group?: string; + parsec_team_id?: string; + parsec_team_key?: string; + host_name?: string; + auto_start?: boolean; +}>; + +function findParsecScript(state: TerraformState): string | null { + for (const resource of state.resources) { + const isParsecScriptResource = + resource.type === "coder_script" && resource.name === "parsec"; + + if (!isParsecScriptResource) { + continue; + } + + for (const instance of resource.instances) { + if ( + instance.attributes.display_name === "Parsec" && + typeof instance.attributes.script === "string" + ) { + return instance.attributes.script; + } + } + } + + return null; +} + +function findParsecApp( + state: TerraformState, + appName: string = "parsec", +): Record | null { + for (const resource of state.resources) { + if (resource.type === "coder_app" && resource.name === appName) { + for (const instance of resource.instances) { + return instance.attributes; + } + } + } + return null; +} + +describe("Parsec Module", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent-id", + }); + + it("Has the PowerShell script download and install Parsec", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + }); + + const script = findParsecScript(state); + expect(script).toBeString(); + expect(script).toContain( + "https://builds.parsec.app/package/parsec-windows.msi", + ); + expect(script).toContain("msiexec.exe"); + expect(script).toContain("Parsec installed successfully"); + }); + + it("Creates external Parsec app link", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + }); + + const app = findParsecApp(state, "parsec"); + expect(app).not.toBeNull(); + expect(app?.display_name).toBe("Parsec"); + expect(app?.url).toBe("https://web.parsec.app/"); + expect(app?.external).toBe(true); + }); + + it("Creates Parsec docs app link", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + }); + + const app = findParsecApp(state, "parsec-docs"); + expect(app).not.toBeNull(); + expect(app?.display_name).toBe("Parsec Docs"); + expect(app?.url).toBe("https://support.parsec.app/hc/en-us"); + expect(app?.external).toBe(true); + }); + + it("Configures custom hostname when provided", async () => { + const customHostname = "my-gaming-pc"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + host_name: customHostname, + }); + + const script = findParsecScript(state); + expect(script).toBeString(); + expect(script).toContain(`$HostName = "${customHostname}"`); + expect(script).toContain(`host_name = ${customHostname}`); + }); + + it("Configures Parsec Teams credentials when provided", async () => { + const teamId = "team-12345"; + const teamKey = "secret-key-abc"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + parsec_team_id: teamId, + parsec_team_key: teamKey, + }); + + const script = findParsecScript(state); + expect(script).toBeString(); + expect(script).toContain(`$ParsecTeamId = "${teamId}"`); + expect(script).toContain(`$ParsecTeamKey = "${teamKey}"`); + expect(script).toContain("Configuring Parsec Teams authentication"); + }); + + it("Supports custom display name and slug", async () => { + const customDisplayName = "Cloud Gaming"; + const customSlug = "cloud-gaming"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + display_name: customDisplayName, + slug: customSlug, + }); + + const app = findParsecApp(state, "parsec"); + expect(app).not.toBeNull(); + expect(app?.display_name).toBe(customDisplayName); + expect(app?.slug).toBe(customSlug); + }); + + it("Configures auto_start behavior", async () => { + // Test with auto_start enabled (default) + const stateAutoStart = await runTerraformApply( + import.meta.dir, + { + agent_id: "test-agent-id", + auto_start: true, + }, + ); + + const scriptAutoStart = findParsecScript(stateAutoStart); + expect(scriptAutoStart).toContain( + '$AutoStart = [System.Convert]::ToBoolean("true")', + ); + expect(scriptAutoStart).toContain("Starting Parsec..."); + + // Test with auto_start disabled + const stateNoAutoStart = await runTerraformApply( + import.meta.dir, + { + agent_id: "test-agent-id", + auto_start: false, + }, + ); + + const scriptNoAutoStart = findParsecScript(stateNoAutoStart); + expect(scriptNoAutoStart).toContain( + '$AutoStart = [System.Convert]::ToBoolean("false")', + ); + }); +}); diff --git a/registry/coder/modules/parsec/main.tf b/registry/coder/modules/parsec/main.tf new file mode 100644 index 000000000..f21711da6 --- /dev/null +++ b/registry/coder/modules/parsec/main.tf @@ -0,0 +1,112 @@ +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 "display_name" { + type = string + description = "The display name for the Parsec application." + default = "Parsec" +} + +variable "slug" { + type = string + description = "The slug for the Parsec application." + default = "parsec" +} + +variable "icon" { + type = string + description = "The icon for the Parsec application." + default = "/icon/parsec.svg" +} + +variable "order" { + type = number + 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)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "parsec_team_id" { + type = string + description = "Parsec Team ID for enterprise/team deployments. Leave empty for personal use." + default = "" +} + +variable "parsec_team_key" { + type = string + description = "Parsec Team Computer Key for headless authentication. Required for automated deployments." + default = "" + sensitive = true +} + +variable "host_name" { + type = string + description = "Custom hostname for the Parsec host. Defaults to workspace name." + default = "" +} + +variable "auto_start" { + type = bool + description = "Automatically start Parsec service after installation." + default = true +} + +resource "coder_script" "parsec" { + agent_id = var.agent_id + display_name = "Parsec" + icon = var.icon + + script = templatefile("${path.module}/install-parsec.ps1", { + parsec_team_id = var.parsec_team_id + parsec_team_key = var.parsec_team_key + host_name = var.host_name + auto_start = var.auto_start + }) + + run_on_start = true +} + +resource "coder_app" "parsec" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "https://web.parsec.app/" + icon = var.icon + external = true + order = var.order + group = var.group +} + +resource "coder_app" "parsec-docs" { + agent_id = var.agent_id + display_name = "Parsec Docs" + slug = "parsec-docs" + icon = "/icon/book.svg" + url = "https://support.parsec.app/hc/en-us" + external = true +} + +data "coder_workspace" "me" {} + +output "host_name" { + description = "The hostname configured for this Parsec host" + value = var.host_name != "" ? var.host_name : data.coder_workspace.me.name +} diff --git a/registry/coder/modules/parsec/parsec.tftest.hcl b/registry/coder/modules/parsec/parsec.tftest.hcl new file mode 100644 index 000000000..565bf8181 --- /dev/null +++ b/registry/coder/modules/parsec/parsec.tftest.hcl @@ -0,0 +1,97 @@ +# Test that the module initializes correctly with required variables +run "parsec_basic_test" { + command = plan + + variables { + agent_id = "test-agent-id" + } + + assert { + condition = coder_script.parsec.display_name == "Parsec" + error_message = "Parsec script display name should be 'Parsec'" + } + + assert { + condition = coder_script.parsec.run_on_start == true + error_message = "Parsec script should run on start" + } + + assert { + condition = coder_app.parsec.display_name == "Parsec" + error_message = "Parsec app display name should be 'Parsec'" + } + + assert { + condition = coder_app.parsec.external == true + error_message = "Parsec app should be external" + } + + assert { + condition = coder_app.parsec.url == "https://web.parsec.app/" + error_message = "Parsec app URL should be https://web.parsec.app/" + } +} + +# Test custom display name and slug +run "parsec_custom_name_test" { + command = plan + + variables { + agent_id = "test-agent-id" + display_name = "Cloud Gaming" + slug = "cloud-gaming" + } + + assert { + condition = coder_app.parsec.display_name == "Cloud Gaming" + error_message = "Custom display name should be applied" + } + + assert { + condition = coder_app.parsec.slug == "cloud-gaming" + error_message = "Custom slug should be applied" + } +} + +# Test that docs app is created +run "parsec_docs_app_test" { + command = plan + + variables { + agent_id = "test-agent-id" + } + + assert { + condition = coder_app.parsec-docs.display_name == "Parsec Docs" + error_message = "Parsec docs app should be created" + } + + assert { + condition = coder_app.parsec-docs.url == "https://support.parsec.app/hc/en-us" + error_message = "Parsec docs URL should point to support site" + } + + assert { + condition = coder_app.parsec-docs.external == true + error_message = "Parsec docs app should be external" + } +} + +# Test default values +run "parsec_defaults_test" { + command = plan + + variables { + agent_id = "test-agent-id" + } + + assert { + condition = coder_app.parsec.slug == "parsec" + error_message = "Default slug should be 'parsec'" + } + + assert { + condition = coder_app.parsec.icon == "/icon/parsec.svg" + error_message = "Default icon should be /icon/parsec.svg" + } +}