From e905d7a3d53725e6fb5d12d6b8b35a240ac10cdb Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 16 Apr 2025 15:43:17 +0000 Subject: [PATCH] chore: copy over main modules --- {.sample => examples}/modules/README.md | 0 {.sample => examples}/modules/main.tf | 0 {.sample => examples}/modules/run.sh | 0 .../modules/amazon-dcv-windows/README.md | 48 ++ .../amazon-dcv-windows/install-dcv.ps1 | 170 ++++ .../coder/modules/amazon-dcv-windows/main.tf | 85 ++ .../coder/modules/apache-airflow/README.md | 24 + registry/coder/modules/apache-airflow/main.tf | 65 ++ registry/coder/modules/apache-airflow/run.sh | 19 + registry/coder/modules/aws-region/README.md | 85 ++ .../coder/modules/aws-region/main.test.ts | 33 + registry/coder/modules/aws-region/main.tf | 199 +++++ registry/coder/modules/azure-region/README.md | 84 ++ .../coder/modules/azure-region/main.test.ts | 33 + registry/coder/modules/azure-region/main.tf | 333 ++++++++ registry/coder/modules/claude-code/README.md | 114 +++ registry/coder/modules/claude-code/main.tf | 170 ++++ registry/coder/modules/code-server/README.md | 115 +++ .../coder/modules/code-server/main.test.ts | 38 + registry/coder/modules/code-server/main.tf | 175 ++++ registry/coder/modules/code-server/run.sh | 128 +++ registry/coder/modules/coder-login/README.md | 23 + .../coder/modules/coder-login/main.test.ts | 10 + registry/coder/modules/coder-login/main.tf | 31 + registry/coder/modules/coder-login/run.sh | 15 + registry/coder/modules/cursor/README.md | 37 + registry/coder/modules/cursor/main.test.ts | 88 +++ registry/coder/modules/cursor/main.tf | 62 ++ registry/coder/modules/dotfiles/README.md | 84 ++ registry/coder/modules/dotfiles/main.test.ts | 40 + registry/coder/modules/dotfiles/main.tf | 91 +++ registry/coder/modules/dotfiles/run.sh | 26 + registry/coder/modules/filebrowser/README.md | 62 ++ registry/coder/modules/filebrowser/main.tf | 123 +++ registry/coder/modules/filebrowser/run.sh | 37 + registry/coder/modules/fly-region/README.md | 70 ++ .../coder/modules/fly-region/main.test.ts | 32 + registry/coder/modules/fly-region/main.tf | 287 +++++++ registry/coder/modules/gcp-region/README.md | 81 ++ .../coder/modules/gcp-region/main.test.ts | 52 ++ registry/coder/modules/gcp-region/main.tf | 748 ++++++++++++++++++ registry/coder/modules/git-clone/README.md | 182 +++++ registry/coder/modules/git-clone/main.test.ts | 247 ++++++ registry/coder/modules/git-clone/main.tf | 121 +++ registry/coder/modules/git-clone/run.sh | 47 ++ .../modules/git-commit-signing/README.md | 29 + .../coder/modules/git-commit-signing/main.tf | 25 + .../coder/modules/git-commit-signing/run.sh | 42 + registry/coder/modules/git-config/README.md | 52 ++ .../coder/modules/git-config/main.test.ts | 127 +++ registry/coder/modules/git-config/main.tf | 84 ++ .../github-upload-public-key/README.md | 55 ++ .../github-upload-public-key/main.test.ts | 132 ++++ .../modules/github-upload-public-key/main.tf | 43 + .../modules/github-upload-public-key/run.sh | 110 +++ registry/coder/modules/goose/README.md | 130 +++ registry/coder/modules/goose/main.tf | 207 +++++ .../coder/modules/hcp-vault-secrets/README.md | 80 ++ .../coder/modules/hcp-vault-secrets/main.tf | 73 ++ .../coder/modules/jetbrains-gateway/README.md | 133 ++++ .../modules/jetbrains-gateway/main.test.ts | 43 + .../coder/modules/jetbrains-gateway/main.tf | 341 ++++++++ .../coder/modules/jfrog-oauth/.npmrc.tftpl | 5 + registry/coder/modules/jfrog-oauth/README.md | 107 +++ .../coder/modules/jfrog-oauth/main.test.ts | 129 +++ registry/coder/modules/jfrog-oauth/main.tf | 173 ++++ .../coder/modules/jfrog-oauth/pip.conf.tftpl | 6 + registry/coder/modules/jfrog-oauth/run.sh | 131 +++ .../coder/modules/jfrog-token/.npmrc.tftpl | 5 + registry/coder/modules/jfrog-token/README.md | 125 +++ .../coder/modules/jfrog-token/main.test.ts | 165 ++++ registry/coder/modules/jfrog-token/main.tf | 219 +++++ .../coder/modules/jfrog-token/pip.conf.tftpl | 6 + registry/coder/modules/jfrog-token/run.sh | 130 +++ .../coder/modules/jupyter-notebook/README.md | 23 + .../coder/modules/jupyter-notebook/main.tf | 65 ++ .../coder/modules/jupyter-notebook/run.sh | 25 + registry/coder/modules/jupyterlab/README.md | 23 + .../coder/modules/jupyterlab/main.test.ts | 107 +++ registry/coder/modules/jupyterlab/main.tf | 75 ++ registry/coder/modules/jupyterlab/run.sh | 57 ++ registry/coder/modules/kasmvnc/README.md | 24 + registry/coder/modules/kasmvnc/main.test.ts | 37 + registry/coder/modules/kasmvnc/main.tf | 63 ++ registry/coder/modules/kasmvnc/run.sh | 235 ++++++ registry/coder/modules/personalize/README.md | 21 + .../coder/modules/personalize/main.test.ts | 29 + registry/coder/modules/personalize/main.tf | 39 + registry/coder/modules/personalize/run.sh | 27 + registry/coder/modules/slackme/README.md | 85 ++ registry/coder/modules/slackme/main.test.ts | 166 ++++ registry/coder/modules/slackme/main.tf | 46 ++ registry/coder/modules/slackme/slackme.sh | 88 +++ registry/coder/modules/vault-github/README.md | 83 ++ .../coder/modules/vault-github/main.test.ts | 11 + registry/coder/modules/vault-github/main.tf | 68 ++ registry/coder/modules/vault-github/run.sh | 119 +++ registry/coder/modules/vault-jwt/README.md | 81 ++ registry/coder/modules/vault-jwt/main.test.ts | 12 + registry/coder/modules/vault-jwt/main.tf | 64 ++ registry/coder/modules/vault-jwt/run.sh | 112 +++ registry/coder/modules/vault-token/README.md | 83 ++ .../coder/modules/vault-token/main.test.ts | 12 + registry/coder/modules/vault-token/main.tf | 62 ++ registry/coder/modules/vault-token/run.sh | 103 +++ .../coder/modules/vscode-desktop/README.md | 37 + .../coder/modules/vscode-desktop/main.test.ts | 89 +++ registry/coder/modules/vscode-desktop/main.tf | 62 ++ registry/coder/modules/vscode-web/README.md | 86 ++ .../coder/modules/vscode-web/main.test.ts | 42 + registry/coder/modules/vscode-web/main.tf | 198 +++++ registry/coder/modules/vscode-web/run.sh | 111 +++ registry/coder/modules/windows-rdp/README.md | 57 ++ .../modules/windows-rdp/devolutions-patch.js | 409 ++++++++++ .../coder/modules/windows-rdp/main.test.ts | 134 ++++ registry/coder/modules/windows-rdp/main.tf | 86 ++ .../powershell-installation-script.tftpl | 85 ++ .../video-thumbnails/video-thumbnail.png | Bin 0 -> 100943 bytes registry/thezoker/README.md | 0 registry/thezoker/modules/nodejs/README.md | 61 ++ registry/thezoker/modules/nodejs/main.test.ts | 12 + registry/thezoker/modules/nodejs/main.tf | 52 ++ registry/thezoker/modules/nodejs/run.sh | 51 ++ .../modules/exoscale-instance-type/README.md | 117 +++ .../exoscale-instance-type/main.test.ts | 43 + .../modules/exoscale-instance-type/main.tf | 286 +++++++ .../whizus/modules/exoscale-zone/README.md | 100 +++ .../whizus/modules/exoscale-zone/main.test.ts | 33 + registry/whizus/modules/exoscale-zone/main.tf | 116 +++ 129 files changed, 11733 insertions(+) rename {.sample => examples}/modules/README.md (100%) rename {.sample => examples}/modules/main.tf (100%) rename {.sample => examples}/modules/run.sh (100%) create mode 100644 registry/coder/modules/amazon-dcv-windows/README.md create mode 100644 registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 create mode 100644 registry/coder/modules/amazon-dcv-windows/main.tf create mode 100644 registry/coder/modules/apache-airflow/README.md create mode 100644 registry/coder/modules/apache-airflow/main.tf create mode 100644 registry/coder/modules/apache-airflow/run.sh create mode 100644 registry/coder/modules/aws-region/README.md create mode 100644 registry/coder/modules/aws-region/main.test.ts create mode 100644 registry/coder/modules/aws-region/main.tf create mode 100644 registry/coder/modules/azure-region/README.md create mode 100644 registry/coder/modules/azure-region/main.test.ts create mode 100644 registry/coder/modules/azure-region/main.tf create mode 100644 registry/coder/modules/claude-code/README.md create mode 100644 registry/coder/modules/claude-code/main.tf create mode 100644 registry/coder/modules/code-server/README.md create mode 100644 registry/coder/modules/code-server/main.test.ts create mode 100644 registry/coder/modules/code-server/main.tf create mode 100644 registry/coder/modules/code-server/run.sh create mode 100644 registry/coder/modules/coder-login/README.md create mode 100644 registry/coder/modules/coder-login/main.test.ts create mode 100644 registry/coder/modules/coder-login/main.tf create mode 100644 registry/coder/modules/coder-login/run.sh create mode 100644 registry/coder/modules/cursor/README.md create mode 100644 registry/coder/modules/cursor/main.test.ts create mode 100644 registry/coder/modules/cursor/main.tf create mode 100644 registry/coder/modules/dotfiles/README.md create mode 100644 registry/coder/modules/dotfiles/main.test.ts create mode 100644 registry/coder/modules/dotfiles/main.tf create mode 100644 registry/coder/modules/dotfiles/run.sh create mode 100644 registry/coder/modules/filebrowser/README.md create mode 100644 registry/coder/modules/filebrowser/main.tf create mode 100644 registry/coder/modules/filebrowser/run.sh create mode 100644 registry/coder/modules/fly-region/README.md create mode 100644 registry/coder/modules/fly-region/main.test.ts create mode 100644 registry/coder/modules/fly-region/main.tf create mode 100644 registry/coder/modules/gcp-region/README.md create mode 100644 registry/coder/modules/gcp-region/main.test.ts create mode 100644 registry/coder/modules/gcp-region/main.tf create mode 100644 registry/coder/modules/git-clone/README.md create mode 100644 registry/coder/modules/git-clone/main.test.ts create mode 100644 registry/coder/modules/git-clone/main.tf create mode 100644 registry/coder/modules/git-clone/run.sh create mode 100644 registry/coder/modules/git-commit-signing/README.md create mode 100644 registry/coder/modules/git-commit-signing/main.tf create mode 100644 registry/coder/modules/git-commit-signing/run.sh create mode 100644 registry/coder/modules/git-config/README.md create mode 100644 registry/coder/modules/git-config/main.test.ts create mode 100644 registry/coder/modules/git-config/main.tf create mode 100644 registry/coder/modules/github-upload-public-key/README.md create mode 100644 registry/coder/modules/github-upload-public-key/main.test.ts create mode 100644 registry/coder/modules/github-upload-public-key/main.tf create mode 100644 registry/coder/modules/github-upload-public-key/run.sh create mode 100644 registry/coder/modules/goose/README.md create mode 100644 registry/coder/modules/goose/main.tf create mode 100644 registry/coder/modules/hcp-vault-secrets/README.md create mode 100644 registry/coder/modules/hcp-vault-secrets/main.tf create mode 100644 registry/coder/modules/jetbrains-gateway/README.md create mode 100644 registry/coder/modules/jetbrains-gateway/main.test.ts create mode 100644 registry/coder/modules/jetbrains-gateway/main.tf create mode 100644 registry/coder/modules/jfrog-oauth/.npmrc.tftpl create mode 100644 registry/coder/modules/jfrog-oauth/README.md create mode 100644 registry/coder/modules/jfrog-oauth/main.test.ts create mode 100644 registry/coder/modules/jfrog-oauth/main.tf create mode 100644 registry/coder/modules/jfrog-oauth/pip.conf.tftpl create mode 100644 registry/coder/modules/jfrog-oauth/run.sh create mode 100644 registry/coder/modules/jfrog-token/.npmrc.tftpl create mode 100644 registry/coder/modules/jfrog-token/README.md create mode 100644 registry/coder/modules/jfrog-token/main.test.ts create mode 100644 registry/coder/modules/jfrog-token/main.tf create mode 100644 registry/coder/modules/jfrog-token/pip.conf.tftpl create mode 100644 registry/coder/modules/jfrog-token/run.sh create mode 100644 registry/coder/modules/jupyter-notebook/README.md create mode 100644 registry/coder/modules/jupyter-notebook/main.tf create mode 100644 registry/coder/modules/jupyter-notebook/run.sh create mode 100644 registry/coder/modules/jupyterlab/README.md create mode 100644 registry/coder/modules/jupyterlab/main.test.ts create mode 100644 registry/coder/modules/jupyterlab/main.tf create mode 100644 registry/coder/modules/jupyterlab/run.sh create mode 100644 registry/coder/modules/kasmvnc/README.md create mode 100644 registry/coder/modules/kasmvnc/main.test.ts create mode 100644 registry/coder/modules/kasmvnc/main.tf create mode 100644 registry/coder/modules/kasmvnc/run.sh create mode 100644 registry/coder/modules/personalize/README.md create mode 100644 registry/coder/modules/personalize/main.test.ts create mode 100644 registry/coder/modules/personalize/main.tf create mode 100644 registry/coder/modules/personalize/run.sh create mode 100644 registry/coder/modules/slackme/README.md create mode 100644 registry/coder/modules/slackme/main.test.ts create mode 100644 registry/coder/modules/slackme/main.tf create mode 100644 registry/coder/modules/slackme/slackme.sh create mode 100644 registry/coder/modules/vault-github/README.md create mode 100644 registry/coder/modules/vault-github/main.test.ts create mode 100644 registry/coder/modules/vault-github/main.tf create mode 100644 registry/coder/modules/vault-github/run.sh create mode 100644 registry/coder/modules/vault-jwt/README.md create mode 100644 registry/coder/modules/vault-jwt/main.test.ts create mode 100644 registry/coder/modules/vault-jwt/main.tf create mode 100644 registry/coder/modules/vault-jwt/run.sh create mode 100644 registry/coder/modules/vault-token/README.md create mode 100644 registry/coder/modules/vault-token/main.test.ts create mode 100644 registry/coder/modules/vault-token/main.tf create mode 100644 registry/coder/modules/vault-token/run.sh create mode 100644 registry/coder/modules/vscode-desktop/README.md create mode 100644 registry/coder/modules/vscode-desktop/main.test.ts create mode 100644 registry/coder/modules/vscode-desktop/main.tf create mode 100644 registry/coder/modules/vscode-web/README.md create mode 100644 registry/coder/modules/vscode-web/main.test.ts create mode 100644 registry/coder/modules/vscode-web/main.tf create mode 100644 registry/coder/modules/vscode-web/run.sh create mode 100644 registry/coder/modules/windows-rdp/README.md create mode 100644 registry/coder/modules/windows-rdp/devolutions-patch.js create mode 100644 registry/coder/modules/windows-rdp/main.test.ts create mode 100644 registry/coder/modules/windows-rdp/main.tf create mode 100644 registry/coder/modules/windows-rdp/powershell-installation-script.tftpl create mode 100644 registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png create mode 100644 registry/thezoker/README.md create mode 100644 registry/thezoker/modules/nodejs/README.md create mode 100644 registry/thezoker/modules/nodejs/main.test.ts create mode 100644 registry/thezoker/modules/nodejs/main.tf create mode 100644 registry/thezoker/modules/nodejs/run.sh create mode 100644 registry/whizus/modules/exoscale-instance-type/README.md create mode 100644 registry/whizus/modules/exoscale-instance-type/main.test.ts create mode 100644 registry/whizus/modules/exoscale-instance-type/main.tf create mode 100644 registry/whizus/modules/exoscale-zone/README.md create mode 100644 registry/whizus/modules/exoscale-zone/main.test.ts create mode 100644 registry/whizus/modules/exoscale-zone/main.tf diff --git a/.sample/modules/README.md b/examples/modules/README.md similarity index 100% rename from .sample/modules/README.md rename to examples/modules/README.md diff --git a/.sample/modules/main.tf b/examples/modules/main.tf similarity index 100% rename from .sample/modules/main.tf rename to examples/modules/main.tf diff --git a/.sample/modules/run.sh b/examples/modules/run.sh similarity index 100% rename from .sample/modules/run.sh rename to examples/modules/run.sh diff --git a/registry/coder/modules/amazon-dcv-windows/README.md b/registry/coder/modules/amazon-dcv-windows/README.md new file mode 100644 index 00000000..669576df --- /dev/null +++ b/registry/coder/modules/amazon-dcv-windows/README.md @@ -0,0 +1,48 @@ +--- +display_name: Amazon DCV Windows +description: Amazon DCV Server and Web Client for Windows +icon: ../.icons/dcv.svg +maintainer_github: coder +verified: true +tags: [windows, amazon, dcv, web, desktop] +--- + +# Amazon DCV Windows + +Amazon DCV is high performance remote display protocol that provides a secure way to deliver remote desktop and application streaming from any cloud or data center to any device, over varying network conditions. + +![Amazon DCV on a Windows workspace](../.images/amazon-dcv-windows.png) + +Enable DCV Server and Web Client on Windows workspaces. + +```tf +module "dcv" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/amazon-dcv-windows/coder" + version = "1.0.24" + agent_id = resource.coder_agent.main.id +} + +resource "coder_metadata" "dcv" { + count = data.coder_workspace.me.start_count + resource_id = aws_instance.dev.id # id of the instance resource + + item { + key = "DCV client instructions" + value = "Run `coder port-forward ${data.coder_workspace.me.name} -p ${module.dcv[count.index].port}` and connect to **localhost:${module.dcv[count.index].port}${module.dcv[count.index].web_url_path}**" + } + item { + key = "username" + value = module.dcv[count.index].username + } + item { + key = "password" + value = module.dcv[count.index].password + sensitive = true + } +} +``` + +## License + +Amazon DCV is free to use on AWS EC2 instances but requires a license for other cloud providers. Please see the instructions [here](https://docs.aws.amazon.com/dcv/latest/adminguide/setting-up-license.html#setting-up-license-ec2) for more information. diff --git a/registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 b/registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 new file mode 100644 index 00000000..2b1c9f4b --- /dev/null +++ b/registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 @@ -0,0 +1,170 @@ +# Terraform variables +$adminPassword = "${admin_password}" +$port = "${port}" +$webURLPath = "${web_url_path}" + +function Set-LocalAdminUser { + Write-Output "[INFO] Starting Set-LocalAdminUser function" + $securePassword = ConvertTo-SecureString $adminPassword -AsPlainText -Force + Write-Output "[DEBUG] Secure password created" + Get-LocalUser -Name Administrator | Set-LocalUser -Password $securePassword + Write-Output "[INFO] Administrator password set" + Get-LocalUser -Name Administrator | Enable-LocalUser + Write-Output "[INFO] User Administrator enabled successfully" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +function Get-VirtualDisplayDriverRequired { + Write-Output "[INFO] Starting Get-VirtualDisplayDriverRequired function" + $token = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token-ttl-seconds' = '21600'} -Method PUT -Uri http://169.254.169.254/latest/api/token + Write-Output "[DEBUG] Token acquired: $token" + $instanceType = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token' = $token} -Method GET -Uri http://169.254.169.254/latest/meta-data/instance-type + Write-Output "[DEBUG] Instance type: $instanceType" + $OSVersion = ((Get-ItemProperty -Path "Microsoft.PowerShell.Core\Registry::\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name ProductName).ProductName) -replace "[^0-9]", '' + Write-Output "[DEBUG] OS version: $OSVersion" + + # Force boolean result + $result = (($OSVersion -ne "2019") -and ($OSVersion -ne "2022") -and ($OSVersion -ne "2025")) -and (($instanceType[0] -ne 'g') -and ($instanceType[0] -ne 'p')) + Write-Output "[INFO] VirtualDisplayDriverRequired result: $result" + Read-Host "[DEBUG] Press Enter to proceed to the next step" + return [bool]$result +} + +function Download-DCV { + param ( + [bool]$VirtualDisplayDriverRequired + ) + Write-Output "[INFO] Starting Download-DCV function" + + $downloads = @( + @{ + Name = "DCV Display Driver" + Required = $VirtualDisplayDriverRequired + Path = "C:\Windows\Temp\DCVDisplayDriver.msi" + Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-virtual-display-x64-Release.msi" + }, + @{ + Name = "DCV Server" + Required = $true + Path = "C:\Windows\Temp\DCVServer.msi" + Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-server-x64-Release.msi" + } + ) + + foreach ($download in $downloads) { + if ($download.Required -and -not (Test-Path $download.Path)) { + try { + Write-Output "[INFO] Downloading $($download.Name)" + + # Display progress manually (no events) + $progressActivity = "Downloading $($download.Name)" + $progressStatus = "Starting download..." + Write-Progress -Activity $progressActivity -Status $progressStatus -PercentComplete 0 + + # Synchronously download the file + $webClient = New-Object System.Net.WebClient + $webClient.DownloadFile($download.Uri, $download.Path) + + # Update progress + Write-Progress -Activity $progressActivity -Status "Completed" -PercentComplete 100 + + Write-Output "[INFO] $($download.Name) downloaded successfully." + } catch { + Write-Output "[ERROR] Failed to download $($download.Name): $_" + throw + } + } else { + Write-Output "[INFO] $($download.Name) already exists. Skipping download." + } + } + + Write-Output "[INFO] All downloads completed" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +function Install-DCV { + param ( + [bool]$VirtualDisplayDriverRequired + ) + Write-Output "[INFO] Starting Install-DCV function" + + if (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue)) { + if ($VirtualDisplayDriverRequired) { + Write-Output "[INFO] Installing DCV Display Driver" + Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVDisplayDriver.msi /quiet /norestart" -Wait + } else { + Write-Output "[INFO] DCV Display Driver installation skipped (not required)." + } + Write-Output "[INFO] Installing DCV Server" + Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVServer.msi ADDLOCAL=ALL /quiet /norestart /l*v C:\Windows\Temp\dcv_install_msi.log" -Wait + } else { + Write-Output "[INFO] DCV Server already installed, skipping installation." + } + + # Wait for the service to appear with a timeout + $timeout = 10 # seconds + $elapsed = 0 + while (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) { + Start-Sleep -Seconds 1 + $elapsed++ + } + + if ($elapsed -ge $timeout) { + Write-Output "[WARNING] Timeout waiting for dcvserver service. A restart is required to complete installation." + Restart-SystemForDCV + } else { + Write-Output "[INFO] dcvserver service detected successfully." + } +} + +function Restart-SystemForDCV { + Write-Output "[INFO] The system will restart in 10 seconds to finalize DCV installation." + Start-Sleep -Seconds 10 + + # Initiate restart + Restart-Computer -Force + + # Exit the script after initiating restart + Write-Output "[INFO] Please wait for the system to restart..." + + Exit 1 +} + + +function Configure-DCV { + Write-Output "[INFO] Starting Configure-DCV function" + $dcvPath = "Microsoft.PowerShell.Core\Registry::\HKEY_USERS\S-1-5-18\Software\GSettings\com\nicesoftware\dcv" + + # Create the required paths + @("$dcvPath\connectivity", "$dcvPath\session-management", "$dcvPath\session-management\automatic-console-session", "$dcvPath\display") | ForEach-Object { + if (-not (Test-Path $_)) { + New-Item -Path $_ -Force | Out-Null + } + } + + # Set registry keys + New-ItemProperty -Path "$dcvPath\session-management" -Name create-session -PropertyType DWORD -Value 1 -Force + New-ItemProperty -Path "$dcvPath\session-management\automatic-console-session" -Name owner -Value Administrator -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name quic-port -PropertyType DWORD -Value $port -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name web-port -PropertyType DWORD -Value $port -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name web-url-path -PropertyType String -Value $webURLPath -Force + + # Attempt to restart service + if (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) { + Restart-Service -Name "dcvserver" + } else { + Write-Output "[WARNING] dcvserver service not found. Ensure the system was restarted properly." + } + + Write-Output "[INFO] DCV configuration completed" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +# Main Script Execution +Write-Output "[INFO] Starting script" +$VirtualDisplayDriverRequired = [bool](Get-VirtualDisplayDriverRequired) +Set-LocalAdminUser +Download-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired +Install-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired +Configure-DCV +Write-Output "[INFO] Script completed" diff --git a/registry/coder/modules/amazon-dcv-windows/main.tf b/registry/coder/modules/amazon-dcv-windows/main.tf new file mode 100644 index 00000000..90058af3 --- /dev/null +++ b/registry/coder/modules/amazon-dcv-windows/main.tf @@ -0,0 +1,85 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "admin_password" { + type = string + default = "coderDCV!" + sensitive = true +} + +variable "port" { + type = number + description = "The port number for the DCV server." + default = 8443 +} + +variable "subdomain" { + type = bool + description = "Whether to use a subdomain for the DCV server." + default = true +} + +variable "slug" { + type = string + description = "The slug of the web-dcv coder_app resource." + default = "web-dcv" +} + +resource "coder_app" "web-dcv" { + agent_id = var.agent_id + slug = var.slug + display_name = "Web DCV" + url = "https://localhost:${var.port}${local.web_url_path}?username=${local.admin_username}&password=${var.admin_password}" + icon = "/icon/dcv.svg" + subdomain = var.subdomain +} + +resource "coder_script" "install-dcv" { + agent_id = var.agent_id + display_name = "Install DCV" + icon = "/icon/dcv.svg" + run_on_start = true + script = templatefile("${path.module}/install-dcv.ps1", { + admin_password : var.admin_password, + port : var.port, + web_url_path : local.web_url_path + }) +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + web_url_path = var.subdomain ? "/" : format("/@%s/%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) + admin_username = "Administrator" +} + +output "web_url_path" { + value = local.web_url_path +} + +output "username" { + value = local.admin_username +} + +output "password" { + value = var.admin_password + sensitive = true +} + +output "port" { + value = var.port +} diff --git a/registry/coder/modules/apache-airflow/README.md b/registry/coder/modules/apache-airflow/README.md new file mode 100644 index 00000000..72361a0b --- /dev/null +++ b/registry/coder/modules/apache-airflow/README.md @@ -0,0 +1,24 @@ +--- +display_name: airflow +description: A module that adds Apache Airflow in your Coder template +icon: ../.icons/airflow.svg +maintainer_github: coder +partner_github: nataindata +verified: true +tags: [airflow, idea, web, helper] +--- + +# airflow + +A module that adds Apache Airflow in your Coder template. + +```tf +module "airflow" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/apache-airflow/coder" + version = "1.0.13" + agent_id = coder_agent.main.id +} +``` + +![Airflow](../.images/airflow.png) diff --git a/registry/coder/modules/apache-airflow/main.tf b/registry/coder/modules/apache-airflow/main.tf new file mode 100644 index 00000000..91b6682a --- /dev/null +++ b/registry/coder/modules/apache-airflow/main.tf @@ -0,0 +1,65 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "log_path" { + type = string + description = "The path to log airflow to." + default = "/tmp/airflow.log" +} + +variable "port" { + type = number + description = "The port to run airflow on." + default = 8080 +} + +variable "share" { + 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'." + } +} + +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 +} + +resource "coder_script" "airflow" { + agent_id = var.agent_id + display_name = "airflow" + icon = "/icon/apache-guacamole.svg" + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port + }) + run_on_start = true +} + +resource "coder_app" "airflow" { + agent_id = var.agent_id + slug = "airflow" + display_name = "airflow" + url = "http://localhost:${var.port}" + icon = "/icon/apache-guacamole.svg" + subdomain = true + share = var.share + order = var.order +} diff --git a/registry/coder/modules/apache-airflow/run.sh b/registry/coder/modules/apache-airflow/run.sh new file mode 100644 index 00000000..d8812603 --- /dev/null +++ b/registry/coder/modules/apache-airflow/run.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh + +BOLD='\033[0;1m' + +PATH=$PATH:~/.local/bin +pip install --upgrade apache-airflow + +filename=~/airflow/airflow.db +if ! [ -f $filename ] || ! [ -s $filename ]; then + airflow db init +fi + +export AIRFLOW__CORE__LOAD_EXAMPLES=false + +airflow webserver > ${LOG_PATH} 2>&1 & + +airflow scheduler >> /tmp/airflow_scheduler.log 2>&1 & + +airflow users create -u admin -p admin -r Admin -e admin@admin.com -f Coder -l User diff --git a/registry/coder/modules/aws-region/README.md b/registry/coder/modules/aws-region/README.md new file mode 100644 index 00000000..c190ffd8 --- /dev/null +++ b/registry/coder/modules/aws-region/README.md @@ -0,0 +1,85 @@ +--- +display_name: AWS Region +description: A parameter with human region names and icons +icon: ../.icons/aws.svg +maintainer_github: coder +verified: true +tags: [helper, parameter, regions, aws] +--- + +# AWS Region + +A parameter with all AWS regions. This allows developers to select +the region closest to them. + +Customize the preselected parameter value: + +```tf +module "aws-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/aws-region/coder" + version = "1.0.12" + default = "us-east-1" +} + +provider "aws" { + region = module.aws_region.value +} +``` + +![AWS Regions](../.images/aws-regions.png) + +## Examples + +### Customize regions + +Change the display name and icon for a region using the corresponding maps: + +```tf +module "aws-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/aws-region/coder" + version = "1.0.12" + default = "ap-south-1" + + custom_names = { + "ap-south-1" : "Awesome Mumbai!" + } + + custom_icons = { + "ap-south-1" : "/emojis/1f33a.png" + } +} + +provider "aws" { + region = module.aws_region.value +} +``` + +![AWS Custom](../.images/aws-custom.png) + +### Exclude regions + +Hide the Asia Pacific regions Seoul and Osaka: + +```tf +module "aws-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/aws-region/coder" + version = "1.0.12" + exclude = ["ap-northeast-2", "ap-northeast-3"] +} + +provider "aws" { + region = module.aws_region.value +} +``` + +![AWS Exclude](../.images/aws-exclude.png) + +## Related templates + +For a complete AWS EC2 template, see the following examples in the [Coder Registry](https://registry.coder.com/). + +- [AWS EC2 (Linux)](https://registry.coder.com/templates/aws-linux) +- [AWS EC2 (Windows)](https://registry.coder.com/templates/aws-windows) diff --git a/registry/coder/modules/aws-region/main.test.ts b/registry/coder/modules/aws-region/main.test.ts new file mode 100644 index 00000000..06f8e56e --- /dev/null +++ b/registry/coder/modules/aws-region/main.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("aws-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "us-west-2", + }); + expect(state.outputs.value.value).toBe("us-west-2"); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/coder/modules/aws-region/main.tf b/registry/coder/modules/aws-region/main.tf new file mode 100644 index 00000000..12a01fe7 --- /dev/null +++ b/registry/coder/modules/aws-region/main.tf @@ -0,0 +1,199 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "display_name" { + default = "AWS Region" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "The region to deploy workspace infrastructure." + description = "The description of the parameter." + type = string +} + +variable "default" { + default = "" + description = "The default region to use if no region is specified." + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "exclude" { + default = [] + description = "A list of region IDs to exclude." + type = list(string) +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + # This is a static list because the regions don't change _that_ + # frequently and including the `aws_regions` data source requires + # the provider, which requires a region. + regions = { + "af-south-1" = { + name = "Africa (Cape Town)" + icon = "/emojis/1f1ff-1f1e6.png" + } + "ap-east-1" = { + name = "Asia Pacific (Hong Kong)" + icon = "/emojis/1f1ed-1f1f0.png" + } + "ap-northeast-1" = { + name = "Asia Pacific (Tokyo)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "ap-northeast-2" = { + name = "Asia Pacific (Seoul)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "ap-northeast-3" = { + name = "Asia Pacific (Osaka)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "ap-south-1" = { + name = "Asia Pacific (Mumbai)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "ap-south-2" = { + name = "Asia Pacific (Hyderabad)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "ap-southeast-1" = { + name = "Asia Pacific (Singapore)" + icon = "/emojis/1f1f8-1f1ec.png" + } + "ap-southeast-2" = { + name = "Asia Pacific (Sydney)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "ap-southeast-3" = { + name = "Asia Pacific (Jakarta)" + icon = "/emojis/1f1ee-1f1e9.png" + } + "ap-southeast-4" = { + name = "Asia Pacific (Melbourne)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "ca-central-1" = { + name = "Canada (Central)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "ca-west-1" = { + name = "Canada West (Calgary)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "eu-central-1" = { + name = "EU (Frankfurt)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-central-2" = { + name = "Europe (Zurich)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-north-1" = { + name = "EU (Stockholm)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-south-1" = { + name = "Europe (Milan)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-south-2" = { + name = "Europe (Spain)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-west-1" = { + name = "EU (Ireland)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-west-2" = { + name = "EU (London)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-west-3" = { + name = "EU (Paris)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "il-central-1" = { + name = "Israel (Tel Aviv)" + icon = "/emojis/1f1ee-1f1f1.png" + } + "me-south-1" = { + name = "Middle East (Bahrain)" + icon = "/emojis/1f1e7-1f1ed.png" + } + "sa-east-1" = { + name = "South America (São Paulo)" + icon = "/emojis/1f1e7-1f1f7.png" + } + "us-east-1" = { + name = "US East (N. Virginia)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east-2" = { + name = "US East (Ohio)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west-1" = { + name = "US West (N. California)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west-2" = { + name = "US West (Oregon)" + icon = "/emojis/1f1fa-1f1f8.png" + } + } +} + +data "coder_parameter" "region" { + name = "aws_region" + display_name = var.display_name + description = var.description + default = var.default == "" ? null : var.default + order = var.coder_parameter_order + mutable = var.mutable + dynamic "option" { + for_each = { for k, v in local.regions : k => v if !(contains(var.exclude, k)) } + content { + name = try(var.custom_names[option.key], option.value.name) + icon = try(var.custom_icons[option.key], option.value.icon) + value = option.key + } + } +} + +output "value" { + value = data.coder_parameter.region.value +} \ No newline at end of file diff --git a/registry/coder/modules/azure-region/README.md b/registry/coder/modules/azure-region/README.md new file mode 100644 index 00000000..2ac9597e --- /dev/null +++ b/registry/coder/modules/azure-region/README.md @@ -0,0 +1,84 @@ +--- +display_name: Azure Region +description: A parameter with human region names and icons +icon: ../.icons/azure.svg +maintainer_github: coder +verified: true +tags: [helper, parameter, azure, regions] +--- + +# Azure Region + +This module adds a parameter with all Azure regions, allowing developers to select the region closest to them. + +```tf +module "azure_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/azure-region/coder" + version = "1.0.12" + default = "eastus" +} + +resource "azurem_resource_group" "example" { + location = module.azure_region.value +} +``` + +![Azure Region Default](../.images/azure-default.png) + +## Examples + +### Customize existing regions + +Change the display name and icon for a region using the corresponding maps: + +```tf +module "azure-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/azure-region/coder" + version = "1.0.12" + custom_names = { + "australia" : "Go Australia!" + } + custom_icons = { + "australia" : "/icons/smiley.svg" + } +} + +resource "azurerm_resource_group" "example" { + location = module.azure_region.value +} +``` + +![Azure Region Custom](../.images/azure-custom.png) + +### Exclude Regions + +Hide all regions in Australia except australiacentral: + +```tf +module "azure-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/azure-region/coder" + version = "1.0.12" + exclude = [ + "australia", + "australiacentral2", + "australiaeast", + "australiasoutheast" + ] +} + +resource "azurerm_resource_group" "example" { + location = module.azure_region.value +} +``` + +![Azure Exclude](../.images/azure-exclude.png) + +## Related templates + +For a complete Azure template, see the following examples in the [Coder Registry](https://registry.coder.com/). + +- [Azure VM (Linux)](https://registry.coder.com/templates/azure-linux) +- [Azure VM (Windows)](https://registry.coder.com/templates/azure-windows) diff --git a/registry/coder/modules/azure-region/main.test.ts b/registry/coder/modules/azure-region/main.test.ts new file mode 100644 index 00000000..8adbb48b --- /dev/null +++ b/registry/coder/modules/azure-region/main.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("azure-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "westus", + }); + expect(state.outputs.value.value).toBe("westus"); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/coder/modules/azure-region/main.tf b/registry/coder/modules/azure-region/main.tf new file mode 100644 index 00000000..3d1c2f13 --- /dev/null +++ b/registry/coder/modules/azure-region/main.tf @@ -0,0 +1,333 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.11" + } + } +} + +variable "display_name" { + default = "Azure Region" + description = "The display name of the Coder parameter." + type = string +} + +variable "description" { + default = "The region where your workspace will live." + description = "Description of the Coder parameter." +} + +variable "default" { + default = "" + description = "The default region to use if no region is specified." + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "exclude" { + default = [] + description = "A list of region IDs to exclude." + type = list(string) +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + # Note: Options are limited to 64 regions, some redundant regions have been removed. + all_regions = { + "australia" = { + name = "Australia" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australiacentral" = { + name = "Australia Central" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australiacentral2" = { + name = "Australia Central 2" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australiaeast" = { + name = "Australia (New South Wales)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australiasoutheast" = { + name = "Australia Southeast" + icon = "/emojis/1f1e6-1f1fa.png" + } + "brazil" = { + name = "Brazil" + icon = "/emojis/1f1e7-1f1f7.png" + } + "brazilsouth" = { + name = "Brazil (Sao Paulo)" + icon = "/emojis/1f1e7-1f1f7.png" + } + "brazilsoutheast" = { + name = "Brazil Southeast" + icon = "/emojis/1f1e7-1f1f7.png" + } + "brazilus" = { + name = "Brazil US" + icon = "/emojis/1f1e7-1f1f7.png" + } + "canada" = { + name = "Canada" + icon = "/emojis/1f1e8-1f1e6.png" + } + "canadacentral" = { + name = "Canada (Toronto)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "canadaeast" = { + name = "Canada East" + icon = "/emojis/1f1e8-1f1e6.png" + } + "centralindia" = { + name = "India (Pune)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "centralus" = { + name = "US (Iowa)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "eastasia" = { + name = "East Asia (Hong Kong)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "eastus" = { + name = "US (Virginia)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "eastus2" = { + name = "US (Virginia) 2" + icon = "/emojis/1f1fa-1f1f8.png" + } + "europe" = { + name = "Europe" + icon = "/emojis/1f30d.png" + } + "france" = { + name = "France" + icon = "/emojis/1f1eb-1f1f7.png" + } + "francecentral" = { + name = "France (Paris)" + icon = "/emojis/1f1eb-1f1f7.png" + } + "francesouth" = { + name = "France South" + icon = "/emojis/1f1eb-1f1f7.png" + } + "germany" = { + name = "Germany" + icon = "/emojis/1f1e9-1f1ea.png" + } + "germanynorth" = { + name = "Germany North" + icon = "/emojis/1f1e9-1f1ea.png" + } + "germanywestcentral" = { + name = "Germany (Frankfurt)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "india" = { + name = "India" + icon = "/emojis/1f1ee-1f1f3.png" + } + "japan" = { + name = "Japan" + icon = "/emojis/1f1ef-1f1f5.png" + } + "japaneast" = { + name = "Japan (Tokyo)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "japanwest" = { + name = "Japan West" + icon = "/emojis/1f1ef-1f1f5.png" + } + "jioindiacentral" = { + name = "Jio India Central" + icon = "/emojis/1f1ee-1f1f3.png" + } + "jioindiawest" = { + name = "Jio India West" + icon = "/emojis/1f1ee-1f1f3.png" + } + "koreacentral" = { + name = "Korea (Seoul)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "koreasouth" = { + name = "Korea South" + icon = "/emojis/1f1f0-1f1f7.png" + } + "northcentralus" = { + name = "North Central US" + icon = "/emojis/1f1fa-1f1f8.png" + } + "northeurope" = { + name = "Europe (Ireland)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "norway" = { + name = "Norway" + icon = "/emojis/1f1f3-1f1f4.png" + } + "norwayeast" = { + name = "Norway (Oslo)" + icon = "/emojis/1f1f3-1f1f4.png" + } + "norwaywest" = { + name = "Norway West" + icon = "/emojis/1f1f3-1f1f4.png" + } + "qatarcentral" = { + name = "Qatar (Doha)" + icon = "/emojis/1f1f6-1f1e6.png" + } + "singapore" = { + name = "Singapore" + icon = "/emojis/1f1f8-1f1ec.png" + } + "southafrica" = { + name = "South Africa" + icon = "/emojis/1f1ff-1f1e6.png" + } + "southafricanorth" = { + name = "South Africa (Johannesburg)" + icon = "/emojis/1f1ff-1f1e6.png" + } + "southafricawest" = { + name = "South Africa West" + icon = "/emojis/1f1ff-1f1e6.png" + } + "southcentralus" = { + name = "US (Texas)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "southeastasia" = { + name = "Southeast Asia (Singapore)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "southindia" = { + name = "South India" + icon = "/emojis/1f1ee-1f1f3.png" + } + "swedencentral" = { + name = "Sweden (Gävle)" + icon = "/emojis/1f1f8-1f1ea.png" + } + "switzerland" = { + name = "Switzerland" + icon = "/emojis/1f1e8-1f1ed.png" + } + "switzerlandnorth" = { + name = "Switzerland (Zurich)" + icon = "/emojis/1f1e8-1f1ed.png" + } + "switzerlandwest" = { + name = "Switzerland West" + icon = "/emojis/1f1e8-1f1ed.png" + } + "uae" = { + name = "United Arab Emirates" + icon = "/emojis/1f1e6-1f1ea.png" + } + "uaecentral" = { + name = "UAE Central" + icon = "/emojis/1f1e6-1f1ea.png" + } + "uaenorth" = { + name = "UAE (Dubai)" + icon = "/emojis/1f1e6-1f1ea.png" + } + "uk" = { + name = "United Kingdom" + icon = "/emojis/1f1ec-1f1e7.png" + } + "uksouth" = { + name = "UK (London)" + icon = "/emojis/1f1ec-1f1e7.png" + } + "ukwest" = { + name = "UK West" + icon = "/emojis/1f1ec-1f1e7.png" + } + "unitedstates" = { + name = "United States" + icon = "/emojis/1f1fa-1f1f8.png" + } + "westcentralus" = { + name = "West Central US" + icon = "/emojis/1f1fa-1f1f8.png" + } + "westeurope" = { + name = "Europe (Netherlands)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "westindia" = { + name = "West India" + icon = "/emojis/1f1ee-1f1f3.png" + } + "westus" = { + name = "West US" + icon = "/emojis/1f1fa-1f1f8.png" + } + "westus2" = { + name = "US (Washington)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "westus3" = { + name = "US (Arizona)" + icon = "/emojis/1f1fa-1f1f8.png" + } + } +} + +data "coder_parameter" "region" { + name = "azure_region" + display_name = var.display_name + description = var.description + default = var.default == "" ? null : var.default + order = var.coder_parameter_order + mutable = var.mutable + icon = "/icon/azure.png" + dynamic "option" { + for_each = { for k, v in local.all_regions : k => v if !(contains(var.exclude, k)) } + content { + name = try(var.custom_names[option.key], option.value.name) + icon = try(var.custom_icons[option.key], option.value.icon) + value = option.key + } + } +} + +output "value" { + value = data.coder_parameter.region.value +} diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md new file mode 100644 index 00000000..d851d7cf --- /dev/null +++ b/registry/coder/modules/claude-code/README.md @@ -0,0 +1,114 @@ +--- +display_name: Claude Code +description: Run Claude Code in your workspace +icon: ../.icons/claude.svg +maintainer_github: coder +verified: true +tags: [agent, claude-code] +--- + +# Claude Code + +Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. + +```tf +module "claude-code" { + source = "registry.coder.com/modules/claude-code/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_claude_code = true + claude_code_version = "latest" +} +``` + +### Prerequisites + +- Node.js and npm must be installed in your workspace to install Claude Code +- `screen` must be installed in your workspace to run Claude Code in the background +- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template + +The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. + +## Examples + +### Run in the background and report tasks (Experimental) + +> This functionality is in early access as of Coder v2.21 and is still evolving. +> For now, we recommend testing it in a demo or staging environment, +> rather than deploying to production +> +> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents) +> +> Join our [Discord channel](https://discord.gg/coder) or +> [contact us](https://coder.com/contact) to get help or share feedback. + +Your workspace must have `screen` installed to use this. + +```tf +variable "anthropic_api_key" { + type = string + description = "The Anthropic API key" + sensitive = true +} + +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder-login/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} + +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Write a prompt for Claude Code" + mutable = true +} + +# Set the prompt and system prompt for Claude Code via environment variables +resource "coder_agent" "main" { + # ... + env = { + CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter + CODER_MCP_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value + CODER_MCP_APP_STATUS_SLUG = "claude-code" + CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT + You are a helpful assistant that can help with code. + EOT + } +} + +module "claude-code" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/claude-code/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_claude_code = true + claude_code_version = "0.2.57" + + # Enable experimental features + experiment_use_screen = true + experiment_report_tasks = true +} +``` + +## Run standalone + +Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it directly without using screen or any task reporting to the Coder UI. + +```tf +module "claude-code" { + source = "registry.coder.com/modules/claude-code/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_claude_code = true + claude_code_version = "latest" + + # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL + icon = "https://registry.npmmirror.com/@lobehub/icons-static-png/1.24.0/files/dark/claude-color.png" +} +``` diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf new file mode 100644 index 00000000..349af17f --- /dev/null +++ b/registry/coder/modules/claude-code/main.tf @@ -0,0 +1,170 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +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 "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/claude.svg" +} + +variable "folder" { + type = string + description = "The folder to run Claude Code in." + default = "/home/coder" +} + +variable "install_claude_code" { + type = bool + description = "Whether to install Claude Code." + default = true +} + +variable "claude_code_version" { + type = string + description = "The version of Claude Code to install." + default = "latest" +} + +variable "experiment_use_screen" { + type = bool + description = "Whether to use screen for running Claude Code in the background." + default = false +} + +variable "experiment_report_tasks" { + type = bool + description = "Whether to enable task reporting." + default = false +} + +# Install and Initialize Claude Code +resource "coder_script" "claude_code" { + agent_id = var.agent_id + display_name = "Claude Code" + icon = var.icon + script = <<-EOT + #!/bin/bash + set -e + + # Function to check if a command exists + command_exists() { + command -v "$1" >/dev/null 2>&1 + } + + # Install Claude Code if enabled + if [ "${var.install_claude_code}" = "true" ]; then + if ! command_exists npm; then + echo "Error: npm is not installed. Please install Node.js and npm first." + exit 1 + fi + echo "Installing Claude Code..." + npm install -g @anthropic-ai/claude-code@${var.claude_code_version} + fi + + if [ "${var.experiment_report_tasks}" = "true" ]; then + echo "Configuring Claude Code to report tasks via Coder MCP..." + coder exp mcp configure claude-code ${var.folder} + fi + + # Run with screen if enabled + if [ "${var.experiment_use_screen}" = "true" ]; then + echo "Running Claude Code in the background..." + + # Check if screen is installed + if ! command_exists screen; then + echo "Error: screen is not installed. Please install screen manually." + exit 1 + fi + + touch "$HOME/.claude-code.log" + + # Ensure the screenrc exists + if [ ! -f "$HOME/.screenrc" ]; then + echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log" + echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" + fi + + if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then + echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" + echo "multiuser on" >> "$HOME/.screenrc" + fi + + if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then + echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" + echo "acladd $(whoami)" >> "$HOME/.screenrc" + fi + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + screen -U -dmS claude-code bash -c ' + cd ${var.folder} + claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log" + exec bash + ' + # Extremely hacky way to send the prompt to the screen session + # This will be fixed in the future, but `claude` was not sending MCP + # tasks when an initial prompt is provided. + screen -S claude-code -X stuff "$CODER_MCP_CLAUDE_TASK_PROMPT" + sleep 5 + screen -S claude-code -X stuff "^M" + else + # Check if claude is installed before running + if ! command_exists claude; then + echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually." + exit 1 + fi + fi + EOT + run_on_start = true +} + +resource "coder_app" "claude_code" { + slug = "claude-code" + display_name = "Claude Code" + agent_id = var.agent_id + command = <<-EOT + #!/bin/bash + set -e + + if [ "${var.experiment_use_screen}" = "true" ]; then + if screen -list | grep -q "claude-code"; then + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + echo "Attaching to existing Claude Code session." | tee -a "$HOME/.claude-code.log" + screen -xRR claude-code + else + echo "Starting a new Claude Code session." | tee -a "$HOME/.claude-code.log" + screen -S claude-code bash -c 'export LANG=en_US.UTF-8; export LC_ALL=en_US.UTF-8; claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash' + fi + else + cd ${var.folder} + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + claude + fi + EOT + icon = var.icon +} diff --git a/registry/coder/modules/code-server/README.md b/registry/coder/modules/code-server/README.md new file mode 100644 index 00000000..e7098113 --- /dev/null +++ b/registry/coder/modules/code-server/README.md @@ -0,0 +1,115 @@ +--- +display_name: code-server +description: VS Code in the browser +icon: ../.icons/code.svg +maintainer_github: coder +verified: true +tags: [helper, ide, web] +--- + +# code-server + +Automatically install [code-server](https://github.com/coder/code-server) in a workspace, create an app to access it via the dashboard, install extensions, and pre-configure editor settings. + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id +} +``` + +![Screenshot 1](https://github.com/coder/code-server/raw/main/docs/assets/screenshot-1.png?raw=true) + +## Examples + +### Pin Versions + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + install_version = "4.8.3" +} +``` + +### Pre-install Extensions + +Install the Dracula theme from [OpenVSX](https://open-vsx.org/): + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + extensions = [ + "dracula-theme.theme-dracula" + ] +} +``` + +Enter the `.` into the extensions array and code-server will automatically install on start. + +### Pre-configure Settings + +Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file: + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + extensions = ["dracula-theme.theme-dracula"] + settings = { + "workbench.colorTheme" = "Dracula" + } +} +``` + +### Install multiple extensions + +Just run code-server in the background, don't fetch it from GitHub: + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] +} +``` + +### Offline and Use Cached Modes + +By default the module looks for code-server at `/tmp/code-server` but this can be changed with `install_prefix`. + +Run an existing copy of code-server if found, otherwise download from GitHub: + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + use_cached = true + extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] +} +``` + +Just run code-server in the background, don't fetch it from GitHub: + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + offline = true +} +``` diff --git a/registry/coder/modules/code-server/main.test.ts b/registry/coder/modules/code-server/main.test.ts new file mode 100644 index 00000000..1d6da5e5 --- /dev/null +++ b/registry/coder/modules/code-server/main.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("code-server", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("use_cached and offline can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + use_cached: "true", + offline: "true", + }); + }; + expect(t).toThrow("Offline and Use Cached can not be used together"); + }); + + it("offline and extensions can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + offline: "true", + extensions: '["1", "2"]', + }); + }; + expect(t).toThrow("Offline mode does not allow extensions to be installed"); + }); + + // More tests depend on shebang refactors +}); diff --git a/registry/coder/modules/code-server/main.tf b/registry/coder/modules/code-server/main.tf new file mode 100644 index 00000000..c80e5378 --- /dev/null +++ b/registry/coder/modules/code-server/main.tf @@ -0,0 +1,175 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "extensions" { + type = list(string) + description = "A list of extensions to install." + default = [] +} + +variable "port" { + type = number + description = "The port to run code-server on." + default = 13337 +} + +variable "display_name" { + type = string + description = "The display name for the code-server application." + default = "code-server" +} + +variable "slug" { + type = string + description = "The slug for the code-server application." + default = "code-server" +} + +variable "settings" { + type = any + description = "A map of settings to apply to code-server." + default = {} +} + +variable "folder" { + type = string + description = "The folder to open in code-server." + default = "" +} + +variable "install_prefix" { + type = string + description = "The prefix to install code-server to." + default = "/tmp/code-server" +} + +variable "log_path" { + type = string + description = "The path to log code-server to." + default = "/tmp/code-server.log" +} + +variable "install_version" { + type = string + description = "The version of code-server to install." + default = "" +} + +variable "share" { + 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'." + } +} + +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 "offline" { + type = bool + description = "Just run code-server in the background, don't fetch it from GitHub" + default = false +} + +variable "use_cached" { + type = bool + description = "Uses cached copy code-server in the background, otherwise fetched it from GitHub" + default = false +} + +variable "use_cached_extensions" { + type = bool + description = "Uses cached copy of extensions, otherwise do a forced upgrade" + default = false +} + +variable "extensions_dir" { + type = string + description = "Override the directory to store extensions in." + default = "" +} + +variable "auto_install_extensions" { + type = bool + description = "Automatically install recommended extensions when code-server starts." + default = false +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = false +} + +resource "coder_script" "code-server" { + agent_id = var.agent_id + display_name = "code-server" + icon = "/icon/code.svg" + script = templatefile("${path.module}/run.sh", { + VERSION : var.install_version, + EXTENSIONS : join(",", var.extensions), + APP_NAME : var.display_name, + PORT : var.port, + LOG_PATH : var.log_path, + INSTALL_PREFIX : var.install_prefix, + // This is necessary otherwise the quotes are stripped! + SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), + OFFLINE : var.offline, + USE_CACHED : var.use_cached, + USE_CACHED_EXTENSIONS : var.use_cached_extensions, + EXTENSIONS_DIR : var.extensions_dir, + FOLDER : var.folder, + AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, + }) + run_on_start = true + + lifecycle { + precondition { + condition = !var.offline || length(var.extensions) == 0 + error_message = "Offline mode does not allow extensions to be installed" + } + + precondition { + condition = !var.offline || !var.use_cached + error_message = "Offline and Use Cached can not be used together" + } + } +} + +resource "coder_app" "code-server" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : ""}" + icon = "/icon/code.svg" + subdomain = var.subdomain + share = var.share + order = var.order + + healthcheck { + url = "http://localhost:${var.port}/healthz" + interval = 5 + threshold = 6 + } +} diff --git a/registry/coder/modules/code-server/run.sh b/registry/coder/modules/code-server/run.sh new file mode 100644 index 00000000..99b30c0e --- /dev/null +++ b/registry/coder/modules/code-server/run.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +EXTENSIONS=("${EXTENSIONS}") +BOLD='\033[0;1m' +CODE='\033[36;40;1m' +RESET='\033[0m' +CODE_SERVER="${INSTALL_PREFIX}/bin/code-server" + +# Set extension directory +EXTENSION_ARG="" +if [ -n "${EXTENSIONS_DIR}" ]; then + EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" + mkdir -p "${EXTENSIONS_DIR}" +fi + +function run_code_server() { + echo "👷 Running code-server in the background..." + echo "Check logs at ${LOG_PATH}!" + $CODE_SERVER "$EXTENSION_ARG" --auth none --port "${PORT}" --app-name "${APP_NAME}" > "${LOG_PATH}" 2>&1 & +} + +# Check if the settings file exists... +if [ ! -f ~/.local/share/code-server/User/settings.json ]; then + echo "⚙️ Creating settings file..." + mkdir -p ~/.local/share/code-server/User + echo "${SETTINGS}" > ~/.local/share/code-server/User/settings.json +fi + +# Check if code-server is already installed for offline +if [ "${OFFLINE}" = true ]; then + if [ -f "$CODE_SERVER" ]; then + echo "🥳 Found a copy of code-server" + run_code_server + exit 0 + fi + # Offline mode always expects a copy of code-server to be present + echo "Failed to find a copy of code-server" + exit 1 +fi + +# If there is no cached install OR we don't want to use a cached install +if [ ! -f "$CODE_SERVER" ] || [ "${USE_CACHED}" != true ]; then + printf "$${BOLD}Installing code-server!\n" + + # Clean up from other install (in case install prefix changed). + if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/code-server" ]; then + rm "$CODER_SCRIPT_BIN_DIR/code-server" + fi + + ARGS=( + "--method=standalone" + "--prefix=${INSTALL_PREFIX}" + ) + if [ -n "${VERSION}" ]; then + ARGS+=("--version=${VERSION}") + fi + + output=$(curl -fsSL https://code-server.dev/install.sh | sh -s -- "$${ARGS[@]}") + if [ $? -ne 0 ]; then + echo "Failed to install code-server: $output" + exit 1 + fi + printf "🥳 code-server has been installed in ${INSTALL_PREFIX}\n\n" +fi + +# Make the code-server available in PATH. +if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/code-server" ]; then + ln -s "$CODE_SERVER" "$CODER_SCRIPT_BIN_DIR/code-server" +fi + +# Get the list of installed extensions... +LIST_EXTENSIONS=$($CODE_SERVER --list-extensions $EXTENSION_ARG) +readarray -t EXTENSIONS_ARRAY <<< "$LIST_EXTENSIONS" +function extension_installed() { + if [ "${USE_CACHED_EXTENSIONS}" != true ]; then + return 1 + fi + for _extension in "$${EXTENSIONS_ARRAY[@]}"; do + if [ "$_extension" == "$1" ]; then + echo "Extension $1 was already installed." + return 0 + fi + done + return 1 +} + +# Install each extension... +IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" +for extension in "$${EXTENSIONLIST[@]}"; do + if [ -z "$extension" ]; then + continue + fi + if extension_installed "$extension"; then + continue + fi + printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n" + output=$($CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension") + if [ $? -ne 0 ]; then + echo "Failed to install extension: $extension: $output" + exit 1 + 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." + exit 0 + fi + + 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" + # Use sed to remove single-line comments before parsing with jq + extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]') + for extension in $extensions; do + if extension_installed "$extension"; then + continue + fi + $CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension" + done + fi +fi + +run_code_server diff --git a/registry/coder/modules/coder-login/README.md b/registry/coder/modules/coder-login/README.md new file mode 100644 index 00000000..589266bf --- /dev/null +++ b/registry/coder/modules/coder-login/README.md @@ -0,0 +1,23 @@ +--- +display_name: Coder Login +description: Automatically logs the user into Coder on their workspace +icon: ../.icons/coder-white.svg +maintainer_github: coder +verified: true +tags: [helper] +--- + +# Coder Login + +Automatically logs the user into Coder when creating their workspace. + +```tf +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder-login/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} +``` + +![Coder Login Logs](../.images/coder-login.png) diff --git a/registry/coder/modules/coder-login/main.test.ts b/registry/coder/modules/coder-login/main.test.ts new file mode 100644 index 00000000..aca43216 --- /dev/null +++ b/registry/coder/modules/coder-login/main.test.ts @@ -0,0 +1,10 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "../test"; + +describe("coder-login", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); +}); diff --git a/registry/coder/modules/coder-login/main.tf b/registry/coder/modules/coder-login/main.tf new file mode 100644 index 00000000..0db33a8d --- /dev/null +++ b/registry/coder/modules/coder-login/main.tf @@ -0,0 +1,31 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_script" "coder-login" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + CODER_USER_TOKEN : data.coder_workspace_owner.me.session_token, + CODER_DEPLOYMENT_URL : data.coder_workspace.me.access_url + }) + display_name = "Coder Login" + icon = "/icon/coder.svg" + run_on_start = true + start_blocks_login = true +} + diff --git a/registry/coder/modules/coder-login/run.sh b/registry/coder/modules/coder-login/run.sh new file mode 100644 index 00000000..c91eb1e8 --- /dev/null +++ b/registry/coder/modules/coder-login/run.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh + +# Automatically authenticate the user if they are not +# logged in to another deployment + +BOLD='\033[0;1m' + +printf "$${BOLD}Logging into Coder...\n\n$${RESET}" + +if ! coder list > /dev/null 2>&1; then + set +x + coder login --token="${CODER_USER_TOKEN}" --url="${CODER_DEPLOYMENT_URL}" +else + echo "You are already authenticated with coder." +fi diff --git a/registry/coder/modules/cursor/README.md b/registry/coder/modules/cursor/README.md new file mode 100644 index 00000000..d9a2e17f --- /dev/null +++ b/registry/coder/modules/cursor/README.md @@ -0,0 +1,37 @@ +--- +display_name: Cursor IDE +description: Add a one-click button to launch Cursor IDE +icon: ../.icons/cursor.svg +maintainer_github: coder +verified: true +tags: [ide, cursor, helper] +--- + +# Cursor IDE + +Add a button to open any workspace with a single click in Cursor IDE. + +Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder). + +```tf +module "cursor" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/cursor/coder" + version = "1.0.19" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "cursor" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/cursor/coder" + version = "1.0.19" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` diff --git a/registry/coder/modules/cursor/main.test.ts b/registry/coder/modules/cursor/main.test.ts new file mode 100644 index 00000000..3c164698 --- /dev/null +++ b/registry/coder/modules/cursor/main.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("cursor", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "cursor", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("adds folder", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder and open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + open_recent: "true", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder but not open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + openRecent: "false", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + open_recent: "true", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("expect order to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "cursor", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); +}); diff --git a/registry/coder/modules/cursor/main.tf b/registry/coder/modules/cursor/main.tf new file mode 100644 index 00000000..f350f942 --- /dev/null +++ b/registry/coder/modules/cursor/main.tf @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "folder" { + type = string + description = "The folder to open in Cursor IDE." + default = "" +} + +variable "open_recent" { + type = bool + description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." + default = false +} + +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 +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "cursor" { + agent_id = var.agent_id + external = true + icon = "/icon/cursor.svg" + slug = "cursor" + display_name = "Cursor Desktop" + order = var.order + url = join("", [ + "cursor://coder.coder-remote/open", + "?owner=", + data.coder_workspace_owner.me.name, + "&workspace=", + data.coder_workspace.me.name, + var.folder != "" ? join("", ["&folder=", var.folder]) : "", + var.open_recent ? "&openRecent" : "", + "&url=", + data.coder_workspace.me.access_url, + "&token=$SESSION_TOKEN", + ]) +} + +output "cursor_url" { + value = coder_app.cursor.url + description = "Cursor IDE Desktop URL." +} diff --git a/registry/coder/modules/dotfiles/README.md b/registry/coder/modules/dotfiles/README.md new file mode 100644 index 00000000..4a911f87 --- /dev/null +++ b/registry/coder/modules/dotfiles/README.md @@ -0,0 +1,84 @@ +--- +display_name: Dotfiles +description: Allow developers to optionally bring their own dotfiles repository to customize their shell and IDE settings! +icon: ../.icons/dotfiles.svg +maintainer_github: coder +verified: true +tags: [helper] +--- + +# Dotfiles + +Allow developers to optionally bring their own [dotfiles repository](https://dotfiles.github.io). + +This will prompt the user for their dotfiles repository URL on template creation using a `coder_parameter`. + +Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/latest/dotfiles) command. + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Apply dotfiles as the current user + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id +} +``` + +### Apply dotfiles as another user (only works if sudo is passwordless) + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id + user = "root" +} +``` + +### Apply the same dotfiles as the current user and root (the root dotfiles can only be applied if sudo is passwordless) + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id +} + +module "dotfiles-root" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id + user = "root" + dotfiles_uri = module.dotfiles.dotfiles_uri +} +``` + +## Setting a default dotfiles repository + +You can set a default dotfiles repository for all users by setting the `default_dotfiles_uri` variable: + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id + default_dotfiles_uri = "https://github.com/coder/dotfiles" +} +``` diff --git a/registry/coder/modules/dotfiles/main.test.ts b/registry/coder/modules/dotfiles/main.test.ts new file mode 100644 index 00000000..60267195 --- /dev/null +++ b/registry/coder/modules/dotfiles/main.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("dotfiles", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.dotfiles_uri.value).toBe(""); + }); + + it("set a default dotfiles_uri", async () => { + const default_dotfiles_uri = "foo"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + default_dotfiles_uri, + }); + expect(state.outputs.dotfiles_uri.value).toBe(default_dotfiles_uri); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(2); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/coder/modules/dotfiles/main.tf b/registry/coder/modules/dotfiles/main.tf new file mode 100644 index 00000000..9bc3735e --- /dev/null +++ b/registry/coder/modules/dotfiles/main.tf @@ -0,0 +1,91 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "default_dotfiles_uri" { + type = string + description = "The default dotfiles URI if the workspace user does not provide one" + default = "" +} + +variable "dotfiles_uri" { + type = string + description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)" + + default = null +} + +variable "user" { + type = string + description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)" + default = null +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +variable "manual_update" { + type = bool + description = "If true, this adds a button to workspace page to refresh dotfiles on demand." + default = false +} + +data "coder_parameter" "dotfiles_uri" { + count = var.dotfiles_uri == null ? 1 : 0 + type = "string" + name = "dotfiles_uri" + display_name = "Dotfiles URL" + order = var.coder_parameter_order + default = var.default_dotfiles_uri + description = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace" + mutable = true + icon = "/icon/dotfiles.svg" +} + +locals { + dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value + user = var.user != null ? var.user : "" +} + +resource "coder_script" "dotfiles" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + DOTFILES_URI : local.dotfiles_uri, + DOTFILES_USER : local.user + }) + display_name = "Dotfiles" + icon = "/icon/dotfiles.svg" + run_on_start = true +} + +resource "coder_app" "dotfiles" { + count = var.manual_update ? 1 : 0 + agent_id = var.agent_id + display_name = "Refresh Dotfiles" + slug = "dotfiles" + icon = "/icon/dotfiles.svg" + command = templatefile("${path.module}/run.sh", { + DOTFILES_URI : local.dotfiles_uri, + DOTFILES_USER : local.user + }) +} + +output "dotfiles_uri" { + description = "Dotfiles URI" + value = local.dotfiles_uri +} diff --git a/registry/coder/modules/dotfiles/run.sh b/registry/coder/modules/dotfiles/run.sh new file mode 100644 index 00000000..e0599418 --- /dev/null +++ b/registry/coder/modules/dotfiles/run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DOTFILES_URI="${DOTFILES_URI}" +DOTFILES_USER="${DOTFILES_USER}" + +if [ -n "$${DOTFILES_URI// }" ]; then + if [ -z "$DOTFILES_USER" ]; then + DOTFILES_USER="$USER" + fi + + echo "✨ Applying dotfiles for user $DOTFILES_USER" + + if [ "$DOTFILES_USER" = "$USER" ]; then + coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log + else + # The `eval echo ~"$DOTFILES_USER"` part is used to dynamically get the home directory of the user, see https://superuser.com/a/484280 + # eval echo ~coder -> "/home/coder" + # eval echo ~root -> "/root" + + CODER_BIN=$(which coder) + DOTFILES_USER_HOME=$(eval echo ~"$DOTFILES_USER") + sudo -u "$DOTFILES_USER" sh -c "'$CODER_BIN' dotfiles '$DOTFILES_URI' -y 2>&1 | tee '$DOTFILES_USER_HOME'/.dotfiles.log" + fi +fi diff --git a/registry/coder/modules/filebrowser/README.md b/registry/coder/modules/filebrowser/README.md new file mode 100644 index 00000000..3a0e56bd --- /dev/null +++ b/registry/coder/modules/filebrowser/README.md @@ -0,0 +1,62 @@ +--- +display_name: File Browser +description: A file browser for your workspace +icon: ../.icons/filebrowser.svg +maintainer_github: coder +verified: true +tags: [helper, filebrowser] +--- + +# File Browser + +A file browser for your workspace. + +```tf +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" + agent_id = coder_agent.example.id +} +``` + +![Filebrowsing Example](../.images/filebrowser.png) + +## Examples + +### Serve a specific directory + +```tf +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` + +### Specify location of `filebrowser.db` + +```tf +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + database_path = ".config/filebrowser.db" +} +``` + +### Serve from the same domain (no subdomain) + +```tf +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + agent_name = "main" + subdomain = false +} +``` diff --git a/registry/coder/modules/filebrowser/main.tf b/registry/coder/modules/filebrowser/main.tf new file mode 100644 index 00000000..ba83844b --- /dev/null +++ b/registry/coder/modules/filebrowser/main.tf @@ -0,0 +1,123 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "agent_name" { + type = string + description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)" + default = null +} + +variable "database_path" { + type = string + description = "The path to the filebrowser database." + default = "filebrowser.db" + validation { + # Ensures path leads to */filebrowser.db + condition = can(regex(".*filebrowser\\.db$", var.database_path)) + error_message = "The database_path must end with 'filebrowser.db'." + } +} + +variable "log_path" { + type = string + description = "The path to log filebrowser to." + default = "/tmp/filebrowser.log" +} + +variable "port" { + type = number + description = "The port to run filebrowser on." + default = 13339 +} + +variable "folder" { + type = string + description = "--root value for filebrowser." + default = "~" +} + +variable "share" { + 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'." + } +} + +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 "slug" { + type = string + description = "The slug of the coder_app resource." + default = "filebrowser" +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = true +} + +resource "coder_script" "filebrowser" { + agent_id = var.agent_id + display_name = "File Browser" + icon = "/icon/filebrowser.svg" + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port, + FOLDER : var.folder, + LOG_PATH : var.log_path, + DB_PATH : var.database_path, + SUBDOMAIN : var.subdomain, + SERVER_BASE_PATH : local.server_base_path + }) + run_on_start = true +} + +resource "coder_app" "filebrowser" { + agent_id = var.agent_id + slug = var.slug + display_name = "File Browser" + url = local.url + icon = "/icon/filebrowser.svg" + subdomain = var.subdomain + share = var.share + order = var.order + + healthcheck { + url = local.healthcheck_url + interval = 5 + threshold = 6 + } +} + +locals { + server_base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug) + url = "http://localhost:${var.port}${local.server_base_path}" + healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/health" +} \ No newline at end of file diff --git a/registry/coder/modules/filebrowser/run.sh b/registry/coder/modules/filebrowser/run.sh new file mode 100644 index 00000000..84810e4e --- /dev/null +++ b/registry/coder/modules/filebrowser/run.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +BOLD='\033[[0;1m' + +printf "$${BOLD}Installing filebrowser \n\n" + +# Check if filebrowser is installed +if ! command -v filebrowser &> /dev/null; then + curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash +fi + +printf "🥳 Installation complete! \n\n" + +printf "🛠️ Configuring filebrowser \n\n" + +ROOT_DIR=${FOLDER} +ROOT_DIR=$${ROOT_DIR/\~/$HOME} + +echo "DB_PATH: ${DB_PATH}" + +export FB_DATABASE="${DB_PATH}" + +# Check if filebrowser db exists +if [[ ! -f "${DB_PATH}" ]]; then + filebrowser config init 2>&1 | tee -a ${LOG_PATH} + filebrowser users add admin "" --perm.admin=true --viewMode=mosaic 2>&1 | tee -a ${LOG_PATH} +fi + +filebrowser config set --baseurl=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH} + +printf "👷 Starting filebrowser in background... \n\n" + +printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n" + +filebrowser >> ${LOG_PATH} 2>&1 & + +printf "📝 Logs at ${LOG_PATH} \n\n" diff --git a/registry/coder/modules/fly-region/README.md b/registry/coder/modules/fly-region/README.md new file mode 100644 index 00000000..30bcb136 --- /dev/null +++ b/registry/coder/modules/fly-region/README.md @@ -0,0 +1,70 @@ +--- +display_name: Fly.io Region +description: A parameter with human region names and icons +icon: ../.icons/fly.svg +maintainer_github: coder +verified: true +tags: [helper, parameter, fly.io, regions] +--- + +# Fly.io Region + +This module adds Fly.io regions to your Coder template. Regions can be whitelisted using the `regions` argument and given custom names and custom icons with their respective map arguments (`custom_names`, `custom_icons`). + +We can use the simplest format here, only adding a default selection as the `atl` region. + +```tf +module "fly-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/fly-region/coder" + version = "1.0.2" + default = "atl" +} +``` + +![Fly.io Default](../.images/flyio-basic.png) + +## Examples + +### Using region whitelist + +The regions argument can be used to display only the desired regions in the Coder parameter. + +```tf +module "fly-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/fly-region/coder" + version = "1.0.2" + default = "ams" + regions = ["ams", "arn", "atl"] +} +``` + +![Fly.io Filtered Regions](../.images/flyio-filtered.png) + +### Using custom icons and names + +Set custom icons and names with their respective maps. + +```tf +module "fly-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/fly-region/coder" + version = "1.0.2" + default = "ams" + + custom_icons = { + "ams" = "/emojis/1f90e.png" + } + + custom_names = { + "ams" = "We love the Netherlands!" + } +} +``` + +![Fly.io custom icon and name](../.images/flyio-custom.png) + +## Associated template + +Also see the Coder template registry for a [Fly.io template](https://registry.coder.com/templates/fly-docker-image) that provisions workspaces as Fly.io machines. diff --git a/registry/coder/modules/fly-region/main.test.ts b/registry/coder/modules/fly-region/main.test.ts new file mode 100644 index 00000000..7e72586f --- /dev/null +++ b/registry/coder/modules/fly-region/main.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("fly-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "atl", + }); + expect(state.outputs.value.value).toBe("atl"); + }); + + it("region filter", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "atl", + regions: '["arn", "ams", "bos"]', + }); + expect(state.outputs.value.value).toBe(""); + }); +}); diff --git a/registry/coder/modules/fly-region/main.tf b/registry/coder/modules/fly-region/main.tf new file mode 100644 index 00000000..ff6a9e3c --- /dev/null +++ b/registry/coder/modules/fly-region/main.tf @@ -0,0 +1,287 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "display_name" { + default = "Fly.io Region" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "The region to deploy workspace infrastructure." + description = "The description of the parameter." + type = string +} + +variable "default" { + default = null + description = "The default region to use if no region is specified." + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "regions" { + default = [] + description = "List of regions to include for region selection." + type = list(string) +} + +locals { + regions = { + "ams" = { + name = "Amsterdam, Netherlands" + gateway = true + paid_only = false + icon = "/emojis/1f1f3-1f1f1.png" + } + "arn" = { + name = "Stockholm, Sweden" + gateway = false + paid_only = false + icon = "/emojis/1f1f8-1f1ea.png" + } + "atl" = { + name = "Atlanta, Georgia (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "bog" = { + name = "Bogotá, Colombia" + gateway = false + paid_only = false + icon = "/emojis/1f1e8-1f1f4.png" + } + "bom" = { + name = "Mumbai, India" + gateway = true + paid_only = true + icon = "/emojis/1f1ee-1f1f3.png" + } + "bos" = { + name = "Boston, Massachusetts (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "cdg" = { + name = "Paris, France" + gateway = true + paid_only = false + icon = "/emojis/1f1eb-1f1f7.png" + } + "den" = { + name = "Denver, Colorado (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "dfw" = { + name = "Dallas, Texas (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "ewr" = { + name = "Secaucus, NJ (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "eze" = { + name = "Ezeiza, Argentina" + gateway = false + paid_only = false + icon = "/emojis/1f1e6-1f1f7.png" + } + "fra" = { + name = "Frankfurt, Germany" + gateway = true + paid_only = true + icon = "/emojis/1f1e9-1f1ea.png" + } + "gdl" = { + name = "Guadalajara, Mexico" + gateway = false + paid_only = false + icon = "/emojis/1f1f2-1f1fd.png" + } + "gig" = { + name = "Rio de Janeiro, Brazil" + gateway = false + paid_only = false + icon = "/emojis/1f1e7-1f1f7.png" + } + "gru" = { + name = "Sao Paulo, Brazil" + gateway = false + paid_only = false + icon = "/emojis/1f1e7-1f1f7.png" + } + "hkg" = { + name = "Hong Kong, Hong Kong" + gateway = true + paid_only = false + icon = "/emojis/1f1ed-1f1f0.png" + } + "iad" = { + name = "Ashburn, Virginia (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "jnb" = { + name = "Johannesburg, South Africa" + gateway = false + paid_only = false + icon = "/emojis/1f1ff-1f1e6.png" + } + "lax" = { + name = "Los Angeles, California (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "lhr" = { + name = "London, United Kingdom" + gateway = true + paid_only = false + icon = "/emojis/1f1ec-1f1e7.png" + } + "mad" = { + name = "Madrid, Spain" + gateway = false + paid_only = false + icon = "/emojis/1f1ea-1f1f8.png" + } + "mia" = { + name = "Miami, Florida (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "nrt" = { + name = "Tokyo, Japan" + gateway = true + paid_only = false + icon = "/emojis/1f1ef-1f1f5.png" + } + "ord" = { + name = "Chicago, Illinois (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "otp" = { + name = "Bucharest, Romania" + gateway = false + paid_only = false + icon = "/emojis/1f1f7-1f1f4.png" + } + "phx" = { + name = "Phoenix, Arizona (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "qro" = { + name = "Querétaro, Mexico" + gateway = false + paid_only = false + icon = "/emojis/1f1f2-1f1fd.png" + } + "scl" = { + name = "Santiago, Chile" + gateway = true + paid_only = false + icon = "/emojis/1f1e8-1f1f1.png" + } + "sea" = { + name = "Seattle, Washington (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "sin" = { + name = "Singapore, Singapore" + gateway = true + paid_only = false + icon = "/emojis/1f1f8-1f1ec.png" + } + "sjc" = { + name = "San Jose, California (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "syd" = { + name = "Sydney, Australia" + gateway = true + paid_only = false + icon = "/emojis/1f1e6-1f1fa.png" + } + "waw" = { + name = "Warsaw, Poland" + gateway = false + paid_only = false + icon = "/emojis/1f1f5-1f1f1.png" + } + "yul" = { + name = "Montreal, Canada" + gateway = false + paid_only = false + icon = "/emojis/1f1e8-1f1e6.png" + } + "yyz" = { + name = "Toronto, Canada" + gateway = true + paid_only = false + icon = "/emojis/1f1e8-1f1e6.png" + } + } +} + +data "coder_parameter" "fly_region" { + name = "flyio_region" + display_name = var.display_name + description = var.description + default = (var.default != null && var.default != "") && ((var.default != null ? contains(var.regions, var.default) : false) || length(var.regions) == 0) ? var.default : null + mutable = var.mutable + dynamic "option" { + for_each = { for k, v in local.regions : k => v if anytrue([for d in var.regions : k == d]) || length(var.regions) == 0 } + content { + name = try(var.custom_names[option.key], option.value.name) + icon = try(var.custom_icons[option.key], option.value.icon) + value = option.key + } + } +} + +output "value" { + value = data.coder_parameter.fly_region.value +} \ No newline at end of file diff --git a/registry/coder/modules/gcp-region/README.md b/registry/coder/modules/gcp-region/README.md new file mode 100644 index 00000000..a74807f3 --- /dev/null +++ b/registry/coder/modules/gcp-region/README.md @@ -0,0 +1,81 @@ +--- +display_name: GCP Region +description: Add Google Cloud Platform regions to your Coder template. +icon: ../.icons/gcp.svg +maintainer_github: coder +verified: true +tags: [gcp, regions, parameter, helper] +--- + +# Google Cloud Platform Regions + +This module adds Google Cloud Platform regions to your Coder template. + +```tf +module "gcp_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/gcp-region/coder" + version = "1.0.12" + regions = ["us", "europe"] +} + +resource "google_compute_instance" "example" { + zone = module.gcp_region.value +} +``` + +![GCP Regions](../.images/gcp-regions.png) + +## Examples + +### Add only GPU zones in the US West 1 region + +Note: setting `gpu_only = true` and using a default region without GPU support, the default will be set to `null`. + +```tf +module "gcp_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/gcp-region/coder" + version = "1.0.12" + default = ["us-west1-a"] + regions = ["us-west1"] + gpu_only = false +} + +resource "google_compute_instance" "example" { + zone = module.gcp_region.value +} +``` + +### Add all zones in the Europe West region + +```tf +module "gcp_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/gcp-region/coder" + version = "1.0.12" + regions = ["europe-west"] + single_zone_per_region = false +} + +resource "google_compute_instance" "example" { + zone = module.gcp_region.value +} +``` + +### Add a single zone from each region in US and Europe that has GPUs + +```tf +module "gcp_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/gcp-region/coder" + version = "1.0.12" + regions = ["us", "europe"] + gpu_only = true + single_zone_per_region = true +} + +resource "google_compute_instance" "example" { + zone = module.gcp_region.value +} +``` diff --git a/registry/coder/modules/gcp-region/main.test.ts b/registry/coder/modules/gcp-region/main.test.ts new file mode 100644 index 00000000..bf01c2bc --- /dev/null +++ b/registry/coder/modules/gcp-region/main.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("gcp-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + regions: '["asia"]', + default: "asia-east1-a", + }); + expect(state.outputs.value.value).toBe("asia-east1-a"); + }); + + it("gpu only invalid default", async () => { + const state = await runTerraformApply(import.meta.dir, { + regions: '["us-west2"]', + default: "us-west2-a", + gpu_only: "true", + }); + expect(state.outputs.value.value).toBe(""); + }); + + it("gpu only valid default", async () => { + const state = await runTerraformApply(import.meta.dir, { + regions: '["us-west2"]', + default: "us-west2-b", + gpu_only: "true", + }); + expect(state.outputs.value.value).toBe("us-west2-b"); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/coder/modules/gcp-region/main.tf b/registry/coder/modules/gcp-region/main.tf new file mode 100644 index 00000000..0a759248 --- /dev/null +++ b/registry/coder/modules/gcp-region/main.tf @@ -0,0 +1,748 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.11" + } + } +} + +variable "display_name" { + default = "GCP Region" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "The region to deploy workspace infrastructure." + description = "The description of the parameter." + type = string +} + +variable "default" { + default = null + description = "Default zone" + type = string +} + +variable "regions" { + description = "List of GCP regions to include." + type = list(string) + default = ["us-central1"] +} + +variable "gpu_only" { + description = "Whether to only include zones with GPUs." + type = bool + default = false +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "single_zone_per_region" { + default = true + description = "Whether to only include a single zone per region." + type = bool +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + zones = { + # US Central + "us-central1-a" = { + gpu = true + name = "Council Bluffs, Iowa, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-central1-b" = { + gpu = true + name = "Council Bluffs, Iowa, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-central1-c" = { + gpu = true + name = "Council Bluffs, Iowa, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-central1-f" = { + gpu = true + name = "Council Bluffs, Iowa, USA (f)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + # US East + "us-east1-b" = { + gpu = true + name = "Moncks Corner, S. Carolina, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east1-c" = { + gpu = true + name = "Moncks Corner, S. Carolina, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east1-d" = { + gpu = true + name = "Moncks Corner, S. Carolina, USA (d)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-east4-a" = { + gpu = true + name = "Ashburn, Virginia, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east4-b" = { + gpu = true + name = "Ashburn, Virginia, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east4-c" = { + gpu = true + name = "Ashburn, Virginia, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-east5-a" = { + gpu = false + name = "Columbus, Ohio, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east5-b" = { + gpu = true + name = "Columbus, Ohio, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east5-c" = { + gpu = false + name = "Columbus, Ohio, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + # Us West + "us-west1-a" = { + gpu = true + name = "The Dalles, Oregon, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west1-b" = { + gpu = true + name = "The Dalles, Oregon, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west1-c" = { + gpu = false + name = "The Dalles, Oregon, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-west2-a" = { + gpu = false + name = "Los Angeles, California, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west2-b" = { + gpu = true + name = "Los Angeles, California, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west2-c" = { + gpu = true + name = "Los Angeles, California, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-west3-a" = { + gpu = true + name = "Salt Lake City, Utah, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west3-b" = { + gpu = true + name = "Salt Lake City, Utah, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west3-c" = { + gpu = true + name = "Salt Lake City, Utah, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-west4-a" = { + gpu = true + name = "Las Vegas, Nevada, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west4-b" = { + gpu = true + name = "Las Vegas, Nevada, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west4-c" = { + gpu = true + name = "Las Vegas, Nevada, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + # US South + "us-south1-a" = { + gpu = false + name = "Dallas, Texas, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-south1-b" = { + gpu = false + name = "Dallas, Texas, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-south1-c" = { + gpu = false + name = "Dallas, Texas, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + # Canada + "northamerica-northeast1-a" = { + gpu = true + name = "Montréal, Québec, Canada (a)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "northamerica-northeast1-b" = { + gpu = true + name = "Montréal, Québec, Canada (b)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "northamerica-northeast1-c" = { + gpu = true + name = "Montréal, Québec, Canada (c)" + icon = "/emojis/1f1e8-1f1e6.png" + } + + "northamerica-northeast2-a" = { + gpu = false + name = "Toronto, Ontario, Canada (a)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "northamerica-northeast2-b" = { + gpu = false + name = "Toronto, Ontario, Canada (b)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "northamerica-northeast2-c" = { + gpu = false + name = "Toronto, Ontario, Canada (c)" + icon = "/emojis/1f1e8-1f1e6.png" + } + + # South America East (Brazil, Chile) + "southamerica-east1-a" = { + gpu = true + name = "Osasco, São Paulo, Brazil (a)" + icon = "/emojis/1f1e7-1f1f7.png" + } + "southamerica-east1-b" = { + gpu = false + name = "Osasco, São Paulo, Brazil (b)" + icon = "/emojis/1f1e7-1f1f7.png" + } + "southamerica-east1-c" = { + gpu = true + name = "Osasco, São Paulo, Brazil (c)" + icon = "/emojis/1f1e7-1f1f7.png" + } + + "southamerica-west1-a" = { + gpu = false + name = "Santiago, Chile (a)" + icon = "/emojis/1f1e8-1f1f1.png" + } + "southamerica-west1-b" = { + gpu = false + name = "Santiago, Chile (b)" + icon = "/emojis/1f1e8-1f1f1.png" + } + "southamerica-west1-c" = { + gpu = false + name = "Santiago, Chile (c)" + icon = "/emojis/1f1e8-1f1f1.png" + } + + # Europe North (Finland) + "europe-north1-a" = { + gpu = false + name = "Hamina, Finland (a)" + icon = "/emojis/1f1e7-1f1ee.png" + } + "europe-north1-b" = { + gpu = false + name = "Hamina, Finland (b)" + icon = "/emojis/1f1e7-1f1ee.png" + } + "europe-north1-c" = { + gpu = false + name = "Hamina, Finland (c)" + icon = "/emojis/1f1e7-1f1ee.png" + } + + # Europe Central (Poland) + "europe-central2-a" = { + gpu = false + name = "Warsaw, Poland (a)" + icon = "/emojis/1f1f5-1f1f1.png" + } + "europe-central2-b" = { + gpu = true + name = "Warsaw, Poland (b)" + icon = "/emojis/1f1f5-1f1f1.png" + } + "europe-central2-c" = { + gpu = true + name = "Warsaw, Poland (c)" + icon = "/emojis/1f1f5-1f1f1.png" + } + + # Europe Southwest (Spain) + "europe-southwest1-a" = { + gpu = false + name = "Madrid, Spain (a)" + icon = "/emojis/1f1ea-1f1f8.png" + } + "europe-southwest1-b" = { + gpu = false + name = "Madrid, Spain (b)" + icon = "/emojis/1f1ea-1f1f8.png" + } + "europe-southwest1-c" = { + gpu = false + name = "Madrid, Spain (c)" + icon = "/emojis/1f1ea-1f1f8.png" + } + + # Europe West + "europe-west1-b" = { + gpu = true + name = "St. Ghislain, Belgium (b)" + icon = "/emojis/1f1e7-1f1ea.png" + } + "europe-west1-c" = { + gpu = true + name = "St. Ghislain, Belgium (c)" + icon = "/emojis/1f1e7-1f1ea.png" + } + "europe-west1-d" = { + gpu = true + name = "St. Ghislain, Belgium (d)" + icon = "/emojis/1f1e7-1f1ea.png" + } + + "europe-west2-a" = { + gpu = true + name = "London, England (a)" + icon = "/emojis/1f1ec-1f1e7.png" + } + "europe-west2-b" = { + gpu = true + name = "London, England (b)" + icon = "/emojis/1f1ec-1f1e7.png" + } + "europe-west2-c" = { + gpu = false + name = "London, England (c)" + icon = "/emojis/1f1ec-1f1e7.png" + } + + "europe-west3-b" = { + gpu = false + name = "Frankfurt, Germany (b)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "europe-west3-c" = { + gpu = true + name = "Frankfurt, Germany (c)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "europe-west3-d" = { + gpu = false + name = "Frankfurt, Germany (d)" + icon = "/emojis/1f1e9-1f1ea.png" + } + + "europe-west4-a" = { + gpu = true + name = "Eemshaven, Netherlands (a)" + icon = "/emojis/1f1f3-1f1f1.png" + } + "europe-west4-b" = { + gpu = true + name = "Eemshaven, Netherlands (b)" + icon = "/emojis/1f1f3-1f1f1.png" + } + "europe-west4-c" = { + gpu = true + name = "Eemshaven, Netherlands (c)" + icon = "/emojis/1f1f3-1f1f1.png" + } + + "europe-west6-a" = { + gpu = false + name = "Zurich, Switzerland (a)" + icon = "/emojis/1f1e8-1f1ed.png" + } + "europe-west6-b" = { + gpu = false + name = "Zurich, Switzerland (b)" + icon = "/emojis/1f1e8-1f1ed.png" + } + "europe-west6-c" = { + gpu = false + name = "Zurich, Switzerland (c)" + icon = "/emojis/1f1e8-1f1ed.png" + } + + "europe-west8-a" = { + gpu = false + name = "Milan, Italy (a)" + icon = "/emojis/1f1ee-1f1f9.png" + } + "europe-west8-b" = { + gpu = false + name = "Milan, Italy (b)" + icon = "/emojis/1f1ee-1f1f9.png" + } + "europe-west8-c" = { + gpu = false + name = "Milan, Italy (c)" + icon = "/emojis/1f1ee-1f1f9.png" + } + + "europe-west9-a" = { + gpu = false + name = "Paris, France (a)" + icon = "/emojis/1f1eb-1f1f7.png" + } + "europe-west9-b" = { + gpu = false + name = "Paris, France (b)" + icon = "/emojis/1f1eb-1f1f7.png" + } + "europe-west9-c" = { + gpu = false + name = "Paris, France (c)" + icon = "/emojis/1f1eb-1f1f7.png" + } + + "europe-west10-a" = { + gpu = false + name = "Berlin, Germany (a)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "europe-west10-b" = { + gpu = false + name = "Berlin, Germany (b)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "europe-west10-c" = { + gpu = false + name = "Berlin, Germany (c)" + icon = "/emojis/1f1e9-1f1ea.png" + } + + "europe-west12-a" = { + gpu = false + name = "Turin, Italy (a)" + icon = "/emojis/1f1ee-1f1f9.png" + } + "europe-west12-b" = { + gpu = false + name = "Turin, Italy (b)" + icon = "/emojis/1f1ee-1f1f9.png" + } + "europe-west12-c" = { + gpu = false + name = "Turin, Italy (c)" + icon = "/emojis/1f1ee-1f1f9.png" + } + + # Middleeast Central (Qatar, Saudi Arabia) + "me-central1-a" = { + gpu = false + name = "Doha, Qatar (a)" + icon = "/emojis/1f1f6-1f1e6.png" + } + "me-central1-b" = { + gpu = false + name = "Doha, Qatar (b)" + icon = "/emojis/1f1f6-1f1e6.png" + } + "me-central1-c" = { + gpu = false + name = "Doha, Qatar (c)" + icon = "/emojis/1f1f6-1f1e6.png" + } + + "me-central2-a" = { + gpu = false + name = "Dammam, Saudi Arabia (a)" + icon = "/emojis/1f1f8-1f1e6.png" + } + "me-central2-b" = { + gpu = false + name = "Dammam, Saudi Arabia (b)" + icon = "/emojis/1f1f8-1f1e6.png" + } + "me-central2-c" = { + gpu = false + name = "Dammam, Saudi Arabia (c)" + icon = "/emojis/1f1f8-1f1e6.png" + } + + # Middleeast West (Israel) + "me-west1-a" = { + gpu = false + name = "Tel Aviv, Israel (a)" + icon = "/emojis/1f1ee-1f1f1.png" + } + "me-west1-b" = { + gpu = true + name = "Tel Aviv, Israel (b)" + icon = "/emojis/1f1ee-1f1f1.png" + } + "me-west1-c" = { + gpu = true + name = "Tel Aviv, Israel (c)" + icon = "/emojis/1f1ee-1f1f1.png" + } + + # Asia East (Taiwan, Hong Kong) + "asia-east1-a" = { + gpu = true + name = "Changhua County, Taiwan (a)" + icon = "/emojis/1f1f9-1f1fc.png" + } + "asia-east1-b" = { + gpu = true + name = "Changhua County, Taiwan (b)" + icon = "/emojis/1f1f9-1f1fc.png" + } + "asia-east1-c" = { + gpu = true + name = "Changhua County, Taiwan (c)" + icon = "/emojis/1f1f9-1f1fc.png" + } + + "asia-east2-a" = { + gpu = true + name = "Hong Kong (a)" + icon = "/emojis/1f1ed-1f1f0.png" + } + "asia-east2-b" = { + gpu = false + name = "Hong Kong (b)" + icon = "/emojis/1f1ed-1f1f0.png" + } + "asia-east2-c" = { + gpu = true + name = "Hong Kong (c)" + icon = "/emojis/1f1ed-1f1f0.png" + } + + # Asia Northeast (Japan, South Korea) + "asia-northeast1-a" = { + gpu = true + name = "Tokyo, Japan (a)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast1-b" = { + gpu = false + name = "Tokyo, Japan (b)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast1-c" = { + gpu = true + name = "Tokyo, Japan (c)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast2-a" = { + gpu = false + name = "Osaka, Japan (a)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast2-b" = { + gpu = false + name = "Osaka, Japan (b)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast2-c" = { + gpu = false + name = "Osaka, Japan (c)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast3-a" = { + gpu = true + name = "Seoul, South Korea (a)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "asia-northeast3-b" = { + gpu = true + name = "Seoul, South Korea (b)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "asia-northeast3-c" = { + gpu = true + name = "Seoul, South Korea (c)" + icon = "/emojis/1f1f0-1f1f7.png" + } + + # Asia South (India) + "asia-south1-a" = { + gpu = true + name = "Mumbai, India (a)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south1-b" = { + gpu = true + name = "Mumbai, India (b)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south1-c" = { + gpu = false + name = "Mumbai, India (c)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south2-a" = { + gpu = false + name = "Delhi, India (a)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south2-b" = { + gpu = false + name = "Delhi, India (b)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south2-c" = { + gpu = false + name = "Delhi, India (c)" + icon = "/emojis/1f1ee-1f1f3.png" + } + + # Asia Southeast (Singapore, Indonesia) + "asia-southeast1-a" = { + gpu = true + name = "Jurong West, Singapore (a)" + icon = "/emojis/1f1f8-1f1ec.png" + } + "asia-southeast1-b" = { + gpu = true + name = "Jurong West, Singapore (b)" + icon = "/emojis/1f1f8-1f1ec.png" + } + "asia-southeast1-c" = { + gpu = true + name = "Jurong West, Singapore (c)" + icon = "/emojis/1f1f8-1f1ec.png" + } + "asia-southeast2-a" = { + gpu = true + name = "Jakarta, Indonesia (a)" + icon = "/emojis/1f1ee-1f1e9.png" + } + "asia-southeast2-b" = { + gpu = true + name = "Jakarta, Indonesia (b)" + icon = "/emojis/1f1ee-1f1e9.png" + } + "asia-southeast2-c" = { + gpu = true + name = "Jakarta, Indonesia (c)" + icon = "/emojis/1f1ee-1f1e9.png" + } + + # Australia (Sydney, Melbourne) + "australia-southeast1-a" = { + gpu = true + name = "Sydney, Australia (a)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast1-b" = { + gpu = true + name = "Sydney, Australia (b)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast1-c" = { + gpu = true + name = "Sydney, Australia (c)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast2-a" = { + gpu = false + name = "Melbourne, Australia (a)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast2-b" = { + gpu = false + name = "Melbourne, Australia (b)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast2-c" = { + gpu = false + name = "Melbourne, Australia (c)" + icon = "/emojis/1f1e6-1f1fa.png" + } + } +} + +data "coder_parameter" "region" { + name = "gcp_region" + display_name = var.display_name + description = var.description + icon = "/icon/gcp.png" + mutable = var.mutable + default = var.default != null && var.default != "" && (!var.gpu_only || try(local.zones[var.default].gpu, false)) ? var.default : null + order = var.coder_parameter_order + dynamic "option" { + for_each = { + for k, v in local.zones : k => v + if anytrue([for d in var.regions : startswith(k, d)]) && (!var.gpu_only || v.gpu) && (!var.single_zone_per_region || endswith(k, "-a")) + } + content { + icon = try(var.custom_icons[option.key], option.value.icon) + # if single_zone_per_region is true, remove the zone letter from the name + name = try(var.custom_names[option.key], var.single_zone_per_region ? substr(option.value.name, 0, length(option.value.name) - 4) : option.value.name) + description = option.key + value = option.key + } + } +} + +output "value" { + description = "GCP zone identifier." + value = data.coder_parameter.region.value +} + +output "region" { + description = "GCP region identifier." + value = substr(data.coder_parameter.region.value, 0, length(data.coder_parameter.region.value) - 2) +} diff --git a/registry/coder/modules/git-clone/README.md b/registry/coder/modules/git-clone/README.md new file mode 100644 index 00000000..0647f7f9 --- /dev/null +++ b/registry/coder/modules/git-clone/README.md @@ -0,0 +1,182 @@ +--- +display_name: Git Clone +description: Clone a Git repository by URL and skip if it exists. +icon: ../.icons/git.svg +maintainer_github: coder +verified: true +tags: [git, helper] +--- + +# Git Clone + +This module allows you to automatically clone a repository by URL and skip if it exists in the base directory provided. + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" +} +``` + +## Examples + +### Custom Path + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" + base_dir = "~/projects/coder" +} +``` + +### Git Authentication + +To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-providers), add the provider by ID to your template: + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" +} + +data "coder_git_auth" "github" { + id = "github" +} +``` + +## GitHub clone with branch name + +To GitHub clone with a specific branch like `feat/example` + +```tf +# Prompt the user for the git repo URL +data "coder_parameter" "git_repo" { + name = "git_repo" + display_name = "Git repository" + default = "https://github.com/coder/coder/tree/feat/example" +} + +# Clone the repository for branch `feat/example` +module "git_clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = data.coder_parameter.git_repo.value +} + +# Create a code-server instance for the cloned repository +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + order = 1 + folder = "/home/${local.username}/${module.git_clone[count.index].folder_name}" +} + +# Create a Coder app for the website +resource "coder_app" "website" { + count = data.coder_workspace.me.start_count + agent_id = coder_agent.example.id + order = 2 + slug = "website" + external = true + display_name = module.git_clone[count.index].folder_name + url = module.git_clone[count.index].web_url + icon = module.git_clone[count.index].git_provider != "" ? "/icon/${module.git_clone[count.index].git_provider}.svg" : "/icon/git.svg" +} +``` + +Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `github.example.com` + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.example.com/coder/coder/tree/feat/example" + git_providers = { + "https://github.example.com/" = { + provider = "github" + } + } +} +``` + +## GitLab clone with branch name + +To GitLab clone with a specific branch like `feat/example` + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://gitlab.com/coder/coder/-/tree/feat/example" +} +``` + +Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com` + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" + git_providers = { + "https://gitlab.example.com/" = { + provider = "gitlab" + } + } +} +``` + +## Git clone with branch_name set + +Alternatively, you can set the `branch_name` attribute to clone a specific branch. + +For example, to clone the `feat/example` branch: + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" + branch_name = "feat/example" +} +``` + +## Git clone with different destination folder + +By default, the repository will be cloned into a folder matching the repository name. You can use the `folder_name` attribute to change the name of the destination folder to something else. + +For example, this will clone into the `~/projects/coder/coder-dev` folder: + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" + folder_name = "coder-dev" + base_dir = "~/projects/coder" +} +``` diff --git a/registry/coder/modules/git-clone/main.test.ts b/registry/coder/modules/git-clone/main.test.ts new file mode 100644 index 00000000..9fbd2022 --- /dev/null +++ b/registry/coder/modules/git-clone/main.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("git-clone", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + url: "foo", + }); + + it("fails without git", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "some-url", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(1); + expect(output.stdout).toEqual(["Git is not installed!"]); + }); + + it("runs with git", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "fake-url", + }); + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.exitCode).toBe(128); + expect(output.stdout).toEqual([ + "Creating directory ~/fake-url...", + "Cloning fake-url to ~/fake-url...", + ]); + }); + + it("repo_dir should match repo name for https", async () => { + const url = "https://github.com/coder/coder.git"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/coder"); + expect(state.outputs.folder_name.value).toEqual("coder"); + expect(state.outputs.clone_url.value).toEqual(url); + expect(state.outputs.web_url.value).toEqual(url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("repo_dir should match repo name for https without .git", async () => { + const url = "https://github.com/coder/coder"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/coder"); + expect(state.outputs.clone_url.value).toEqual(url); + expect(state.outputs.web_url.value).toEqual(url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("repo_dir should match repo name for ssh", async () => { + const url = "git@github.com:coder/coder.git"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/coder"); + expect(state.outputs.git_provider.value).toEqual(""); + expect(state.outputs.clone_url.value).toEqual(url); + const https_url = "https://github.com/coder/coder.git"; + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("repo_dir should match base_dir/folder_name", async () => { + const url = "git@github.com:coder/coder.git"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + folder_name: "foo", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/foo"); + expect(state.outputs.folder_name.value).toEqual("foo"); + expect(state.outputs.clone_url.value).toEqual(url); + const https_url = "https://github.com/coder/coder.git"; + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("branch_name should not include query string", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch?ref_type=heads", + }); + expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log"); + expect(state.outputs.folder_name.value).toEqual("repo-tests.log"); + const https_url = "https://gitlab.com/mike.brew/repo-tests.log"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/branch"); + }); + + it("branch_name should not include fragments", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch#name", + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log"); + const https_url = "https://gitlab.com/mike.brew/repo-tests.log"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/branch"); + }); + + it("gitlab url with branch should match", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch", + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log"); + expect(state.outputs.git_provider.value).toEqual("gitlab"); + const https_url = "https://gitlab.com/mike.brew/repo-tests.log"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/branch"); + }); + + it("github url with branch should match", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch", + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log"); + expect(state.outputs.git_provider.value).toEqual("github"); + const https_url = "https://github.com/michaelbrewer/repo-tests.log"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/branch"); + }); + + it("self-host git url with branch should match", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url: "https://git.example.com/example/project/-/tree/feat/example", + git_providers: ` + { + "https://git.example.com/" = { + provider = "gitlab" + } + }`, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/project"); + expect(state.outputs.git_provider.value).toEqual("gitlab"); + const https_url = "https://git.example.com/example/project"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/example"); + }); + + it("handle unsupported git provider configuration", async () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "foo", + git_providers: ` + { + "https://git.example.com/" = { + provider = "bitbucket" + } + }`, + }); + }; + expect(t).toThrow('Allowed values for provider are "github" or "gitlab".'); + }); + + it("handle unknown git provider url", async () => { + const url = "https://git.unknown.com/coder/coder"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/coder"); + expect(state.outputs.clone_url.value).toEqual(url); + expect(state.outputs.web_url.value).toEqual(url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("runs with github clone with switch to feat/branch", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch", + }); + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "Creating directory ~/repo-tests.log...", + "Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...", + ]); + }); + + it("runs with gitlab clone with switch to feat/branch", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch", + }); + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "Creating directory ~/repo-tests.log...", + "Cloning https://gitlab.com/mike.brew/repo-tests.log to ~/repo-tests.log on branch feat/branch...", + ]); + }); + + it("runs with github clone with branch_name set to feat/branch", async () => { + const url = "https://github.com/michaelbrewer/repo-tests.log"; + const branch_name = "feat/branch"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url, + branch_name, + }); + expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log"); + expect(state.outputs.clone_url.value).toEqual(url); + expect(state.outputs.web_url.value).toEqual(url); + expect(state.outputs.branch_name.value).toEqual(branch_name); + + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "Creating directory ~/repo-tests.log...", + "Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...", + ]); + }); +}); diff --git a/registry/coder/modules/git-clone/main.tf b/registry/coder/modules/git-clone/main.tf new file mode 100644 index 00000000..0295444d --- /dev/null +++ b/registry/coder/modules/git-clone/main.tf @@ -0,0 +1,121 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "url" { + description = "The URL of the Git repository." + type = string +} + +variable "base_dir" { + default = "" + description = "The base directory to clone the repository. Defaults to \"$HOME\"." + type = string +} + +variable "agent_id" { + description = "The ID of a Coder agent." + type = string +} + +variable "git_providers" { + type = map(object({ + provider = string + })) + description = "A mapping of URLs to their git provider." + default = { + "https://github.com/" = { + provider = "github" + }, + "https://gitlab.com/" = { + provider = "gitlab" + }, + } + validation { + error_message = "Allowed values for provider are \"github\" or \"gitlab\"." + condition = alltrue([for provider in var.git_providers : contains(["github", "gitlab"], provider.provider)]) + } +} + +variable "branch_name" { + description = "The branch name to clone. If not provided, the default branch will be cloned." + type = string + default = "" +} + +variable "folder_name" { + description = "The destination folder to clone the repository into." + type = string + default = "" +} + +locals { + # Remove query parameters and fragments from the URL + url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "") + + # Find the git provider based on the URL and determine the tree path + provider_key = try(one([for key in keys(var.git_providers) : key if startswith(local.url, key)]), null) + provider = try(lookup(var.git_providers, local.provider_key).provider, "") + tree_path = local.provider == "gitlab" ? "/-/tree/" : local.provider == "github" ? "/tree/" : "" + + # Remove tree and branch name from the URL + clone_url = var.branch_name == "" && local.tree_path != "" ? replace(local.url, "/${local.tree_path}.*/", "") : local.url + # Extract the branch name from the URL + branch_name = var.branch_name == "" && local.tree_path != "" ? replace(replace(local.url, local.clone_url, ""), "/.*${local.tree_path}/", "") : var.branch_name + # Extract the folder name from the URL + folder_name = var.folder_name == "" ? replace(basename(local.clone_url), ".git", "") : var.folder_name + # Construct the path to clone the repository + clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name]) + # Construct the web URL + web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url +} + +output "repo_dir" { + value = local.clone_path + description = "Full path of cloned repo directory" +} + +output "git_provider" { + value = local.provider + description = "The git provider of the repository" +} + +output "folder_name" { + value = local.folder_name + description = "The name of the folder that will be created" +} + +output "clone_url" { + value = local.clone_url + description = "The exact Git repository URL that will be cloned" +} + +output "web_url" { + value = local.web_url + description = "Git https repository URL (may be invalid for unsupported providers)" +} + +output "branch_name" { + value = local.branch_name + description = "Git branch name (may be empty)" +} + +resource "coder_script" "git_clone" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + CLONE_PATH = local.clone_path, + REPO_URL : local.clone_url, + BRANCH_NAME : local.branch_name, + }) + display_name = "Git Clone" + icon = "/icon/git.svg" + run_on_start = true + start_blocks_login = true +} diff --git a/registry/coder/modules/git-clone/run.sh b/registry/coder/modules/git-clone/run.sh new file mode 100644 index 00000000..bd807177 --- /dev/null +++ b/registry/coder/modules/git-clone/run.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +REPO_URL="${REPO_URL}" +CLONE_PATH="${CLONE_PATH}" +BRANCH_NAME="${BRANCH_NAME}" +# Expand home if it's specified! +CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}" + +# Check if the variable is empty... +if [ -z "$REPO_URL" ]; then + echo "No repository specified!" + exit 1 +fi + +# Check if the variable is empty... +if [ -z "$CLONE_PATH" ]; then + echo "No clone path specified!" + exit 1 +fi + +# Check if `git` is installed... +if ! command -v git > /dev/null; then + echo "Git is not installed!" + exit 1 +fi + +# Check if the directory for the cloning exists +# and if not, create it +if [ ! -d "$CLONE_PATH" ]; then + echo "Creating directory $CLONE_PATH..." + mkdir -p "$CLONE_PATH" +fi + +# Check if the directory is empty +# and if it is, clone the repo, otherwise skip cloning +if [ -z "$(ls -A "$CLONE_PATH")" ]; then + if [ -z "$BRANCH_NAME" ]; then + echo "Cloning $REPO_URL to $CLONE_PATH..." + git clone "$REPO_URL" "$CLONE_PATH" + else + echo "Cloning $REPO_URL to $CLONE_PATH on branch $BRANCH_NAME..." + git clone "$REPO_URL" -b "$BRANCH_NAME" "$CLONE_PATH" + fi +else + echo "$CLONE_PATH already exists and isn't empty, skipping clone!" + exit 0 +fi diff --git a/registry/coder/modules/git-commit-signing/README.md b/registry/coder/modules/git-commit-signing/README.md new file mode 100644 index 00000000..1f71cbb3 --- /dev/null +++ b/registry/coder/modules/git-commit-signing/README.md @@ -0,0 +1,29 @@ +--- +display_name: Git commit signing +description: Configures Git to sign commits using your Coder SSH key +icon: ../.icons/git.svg +maintainer_github: coder +verified: true +tags: [helper, git] +--- + +# git-commit-signing + +> [!IMPORTANT] +> This module will only work with Git versions >=2.34, prior versions [do not support signing commits via SSH keys](https://lore.kernel.org/git/xmqq8rxpgwki.fsf@gitster.g/). + +This module downloads your SSH key from Coder and uses it to sign commits with Git. +It requires `curl` and `jq` to be installed inside your workspace. + +Please observe that using the SSH key that's part of your Coder account for commit signing, means that in the event of a breach of your Coder account, or a malicious admin, someone could perform commit signing pretending to be you. + +This module has a chance of conflicting with the user's dotfiles / the personalize module if one of those has configuration directives that overwrite this module's / each other's git configuration. + +```tf +module "git-commit-signing" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-commit-signing/coder" + version = "1.0.11" + agent_id = coder_agent.example.id +} +``` diff --git a/registry/coder/modules/git-commit-signing/main.tf b/registry/coder/modules/git-commit-signing/main.tf new file mode 100644 index 00000000..7c8cd3be --- /dev/null +++ b/registry/coder/modules/git-commit-signing/main.tf @@ -0,0 +1,25 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +resource "coder_script" "git-commit-signing" { + display_name = "Git commit signing" + icon = "/icon/git.svg" + + script = file("${path.module}/run.sh") + run_on_start = true + + agent_id = var.agent_id +} diff --git a/registry/coder/modules/git-commit-signing/run.sh b/registry/coder/modules/git-commit-signing/run.sh new file mode 100644 index 00000000..c0e0faa3 --- /dev/null +++ b/registry/coder/modules/git-commit-signing/run.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env sh + +if ! command -v git > /dev/null; then + echo "git is not installed" + exit 1 +fi + +if ! command -v curl > /dev/null; then + echo "curl is not installed" + exit 1 +fi + +if ! command -v jq > /dev/null; then + echo "jq is not installed" + exit 1 +fi + +mkdir -p ~/.ssh/git-commit-signing + +echo "Downloading SSH key" + +ssh_key=$(curl --request GET \ + --url "${CODER_AGENT_URL}api/v2/workspaceagents/me/gitsshkey" \ + --header "Coder-Session-Token: ${CODER_AGENT_TOKEN}" \ + --silent --show-error) + +jq --raw-output ".public_key" > ~/.ssh/git-commit-signing/coder.pub << EOF +$ssh_key +EOF + +jq --raw-output ".private_key" > ~/.ssh/git-commit-signing/coder << EOF +$ssh_key +EOF + +chmod -R 600 ~/.ssh/git-commit-signing/coder +chmod -R 644 ~/.ssh/git-commit-signing/coder.pub + +echo "Configuring git to use the SSH key" + +git config --global gpg.format ssh +git config --global commit.gpgsign true +git config --global user.signingkey ~/.ssh/git-commit-signing/coder diff --git a/registry/coder/modules/git-config/README.md b/registry/coder/modules/git-config/README.md new file mode 100644 index 00000000..5ba0806b --- /dev/null +++ b/registry/coder/modules/git-config/README.md @@ -0,0 +1,52 @@ +--- +display_name: Git Config +description: Stores Git configuration from Coder credentials +icon: ../.icons/git.svg +maintainer_github: coder +verified: true +tags: [helper, git] +--- + +# git-config + +Runs a script that updates git credentials in the workspace to match the user's Coder credentials, optionally allowing to the developer to override the defaults. + +```tf +module "git-config" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-config/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} +``` + +TODO: Add screenshot + +## Examples + +### Allow users to override both username and email + +```tf +module "git-config" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-config/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + allow_email_change = true +} +``` + +TODO: Add screenshot + +## Disallowing users from overriding both username and email + +```tf +module "git-config" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-config/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + allow_username_change = false + allow_email_change = false +} +``` diff --git a/registry/coder/modules/git-config/main.test.ts b/registry/coder/modules/git-config/main.test.ts new file mode 100644 index 00000000..e702c6e6 --- /dev/null +++ b/registry/coder/modules/git-config/main.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("git-config", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("can run apply allow_username_change and allow_email_change disabled", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_username_change: "false", + allow_email_change: "false", + }); + + const resources = state.resources; + expect(resources).toHaveLength(6); + expect(resources).toMatchObject([ + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + }); + + it("can run apply allow_email_change enabled", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_email_change: "true", + }); + + const resources = state.resources; + expect(resources).toHaveLength(8); + expect(resources).toMatchObject([ + { type: "coder_parameter", name: "user_email" }, + { type: "coder_parameter", name: "username" }, + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + }); + + it("can run apply allow_email_change enabled", async () => { + const state = await runTerraformApply( + import.meta.dir, + { + agent_id: "foo", + allow_username_change: "false", + allow_email_change: "false", + }, + { CODER_WORKSPACE_OWNER_EMAIL: "foo@email.com" }, + ); + + const resources = state.resources; + expect(resources).toHaveLength(6); + expect(resources).toMatchObject([ + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + }); + + it("set custom order for coder_parameter for both fields", async () => { + const order = 20; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_username_change: "true", + allow_email_change: "true", + coder_parameter_order: order.toString(), + }); + const resources = state.resources; + expect(resources).toHaveLength(8); + expect(resources).toMatchObject([ + { type: "coder_parameter", name: "user_email" }, + { type: "coder_parameter", name: "username" }, + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + // user_email order is the same as the order + expect(resources[0].instances[0].attributes.order).toBe(order); + // username order is incremented by 1 + // @ts-ignore: Object is possibly 'null'. + expect(resources[1].instances[0]?.attributes.order).toBe(order + 1); + }); + + it("set custom order for coder_parameter for just username", async () => { + const order = 30; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_email_change: "false", + allow_username_change: "true", + coder_parameter_order: order.toString(), + }); + const resources = state.resources; + expect(resources).toHaveLength(7); + expect(resources).toMatchObject([ + { type: "coder_parameter", name: "username" }, + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + // user_email was not created + // username order is incremented by 1 + expect(resources[0].instances[0].attributes.order).toBe(order + 1); + }); +}); diff --git a/registry/coder/modules/git-config/main.tf b/registry/coder/modules/git-config/main.tf new file mode 100644 index 00000000..e8fea8fd --- /dev/null +++ b/registry/coder/modules/git-config/main.tf @@ -0,0 +1,84 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "allow_username_change" { + type = bool + description = "Allow developers to change their git username." + default = true +} + +variable "allow_email_change" { + type = bool + description = "Allow developers to change their git email." + default = false +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "user_email" { + count = var.allow_email_change ? 1 : 0 + name = "user_email" + type = "string" + default = "" + order = var.coder_parameter_order != null ? var.coder_parameter_order + 0 : null + description = "Git user.email to be used for commits. Leave empty to default to Coder user's email." + display_name = "Git config user.email" + mutable = true +} + +data "coder_parameter" "username" { + count = var.allow_username_change ? 1 : 0 + name = "username" + type = "string" + default = "" + order = var.coder_parameter_order != null ? var.coder_parameter_order + 1 : null + description = "Git user.name to be used for commits. Leave empty to default to Coder user's Full Name." + display_name = "Full Name for Git config" + mutable = true +} + +resource "coder_env" "git_author_name" { + agent_id = var.agent_id + name = "GIT_AUTHOR_NAME" + value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) +} + +resource "coder_env" "git_commmiter_name" { + agent_id = var.agent_id + name = "GIT_COMMITTER_NAME" + value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) +} + +resource "coder_env" "git_author_email" { + agent_id = var.agent_id + name = "GIT_AUTHOR_EMAIL" + value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace_owner.me.email) + count = data.coder_workspace_owner.me.email != "" ? 1 : 0 +} + +resource "coder_env" "git_commmiter_email" { + agent_id = var.agent_id + name = "GIT_COMMITTER_EMAIL" + value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace_owner.me.email) + count = data.coder_workspace_owner.me.email != "" ? 1 : 0 +} diff --git a/registry/coder/modules/github-upload-public-key/README.md b/registry/coder/modules/github-upload-public-key/README.md new file mode 100644 index 00000000..192db7eb --- /dev/null +++ b/registry/coder/modules/github-upload-public-key/README.md @@ -0,0 +1,55 @@ +--- +display_name: Github Upload Public Key +description: Automates uploading Coder public key to Github so users don't have to. +icon: ../.icons/github.svg +maintainer_github: coder +verified: true +tags: [helper, git] +--- + +# github-upload-public-key + +Templates that utilize Github External Auth can automatically ensure that the Coder public key is uploaded to Github so that users can clone repositories without needing to upload the public key themselves. + +```tf +module "github-upload-public-key" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/github-upload-public-key/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} +``` + +# Requirements + +This module requires `curl` and `jq` to be installed inside your workspace. + +Github External Auth must be enabled in the workspace for this module to work. The Github app that is configured for external auth must have both read and write permissions to "Git SSH keys" in order to upload the public key. Additionally, a Coder admin must also have the `admin:public_key` scope added to the external auth configuration of the Coder deployment. For example: + +``` +CODER_EXTERNAL_AUTH_0_ID="USER_DEFINED_ID" +CODER_EXTERNAL_AUTH_0_TYPE=github +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key" +``` + +Note that the default scopes if not provided are `repo,workflow`. If the module is failing to complete after updating the external auth configuration, instruct users of the module to "Unlink" and "Link" their Github account in the External Auth user settings page to get the new scopes. + +# Example + +Using a coder github external auth with a non-default id: (default is `github`) + +```tf +data "coder_external_auth" "github" { + id = "myauthid" +} + +module "github-upload-public-key" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/github-upload-public-key/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + external_auth_id = data.coder_external_auth.github.id +} +``` diff --git a/registry/coder/modules/github-upload-public-key/main.test.ts b/registry/coder/modules/github-upload-public-key/main.test.ts new file mode 100644 index 00000000..6ce16d82 --- /dev/null +++ b/registry/coder/modules/github-upload-public-key/main.test.ts @@ -0,0 +1,132 @@ +import { type Server, serve } from "bun"; +import { describe, expect, it } from "bun:test"; +import { + createJSONResponse, + execContainer, + findResourceInstance, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + writeCoder, +} from "../test"; + +describe("github-upload-public-key", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("creates new key if one does not exist", async () => { + const { instance, id, server } = await setupContainer(); + await writeCoder(id, "echo foo"); + + const url = server.url.toString().slice(0, -1); + const exec = await execContainer(id, [ + "env", + `CODER_ACCESS_URL=${url}`, + `GITHUB_API_URL=${url}`, + "CODER_OWNER_SESSION_TOKEN=foo", + "CODER_EXTERNAL_AUTH_ID=github", + "bash", + "-c", + instance.script, + ]); + expect(exec.stdout).toContain( + "Your Coder public key has been added to GitHub!", + ); + expect(exec.exitCode).toBe(0); + // we need to increase timeout to pull the container + }, 15000); + + it("does nothing if one already exists", async () => { + const { instance, id, server } = await setupContainer(); + // use keyword to make server return a existing key + await writeCoder(id, "echo findkey"); + + const url = server.url.toString().slice(0, -1); + const exec = await execContainer(id, [ + "env", + `CODER_ACCESS_URL=${url}`, + `GITHUB_API_URL=${url}`, + "CODER_OWNER_SESSION_TOKEN=foo", + "CODER_EXTERNAL_AUTH_ID=github", + "bash", + "-c", + instance.script, + ]); + expect(exec.stdout).toContain( + "Your Coder public key is already on GitHub!", + ); + expect(exec.exitCode).toBe(0); + }); +}); + +const setupContainer = async ( + image = "lorello/alpine-bash", + vars: Record = {}, +) => { + const server = await setupServer(); + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ...vars, + }); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + return { id, instance, server }; +}; + +const setupServer = async (): Promise => { + let url: URL; + const fakeSlackHost = serve({ + fetch: (req) => { + url = new URL(req.url); + if (url.pathname === "/api/v2/users/me/gitsshkey") { + return createJSONResponse({ + public_key: "exists", + }); + } + + if (url.pathname === "/user/keys") { + if (req.method === "POST") { + return createJSONResponse( + { + key: "created", + }, + 201, + ); + } + + // case: key already exists + if (req.headers.get("Authorization") === "Bearer findkey") { + return createJSONResponse([ + { + key: "foo", + }, + { + key: "exists", + }, + ]); + } + + // case: key does not exist + return createJSONResponse([ + { + key: "foo", + }, + ]); + } + + return createJSONResponse( + { + error: "not_found", + }, + 404, + ); + }, + port: 0, + }); + + return fakeSlackHost; +}; diff --git a/registry/coder/modules/github-upload-public-key/main.tf b/registry/coder/modules/github-upload-public-key/main.tf new file mode 100644 index 00000000..b527400e --- /dev/null +++ b/registry/coder/modules/github-upload-public-key/main.tf @@ -0,0 +1,43 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "external_auth_id" { + type = string + description = "The ID of the GitHub external auth." + default = "github" +} + +variable "github_api_url" { + type = string + description = "The URL of the GitHub instance." + default = "https://api.github.com" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_script" "github_upload_public_key" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + CODER_OWNER_SESSION_TOKEN : data.coder_workspace_owner.me.session_token, + CODER_ACCESS_URL : data.coder_workspace.me.access_url, + CODER_EXTERNAL_AUTH_ID : var.external_auth_id, + GITHUB_API_URL : var.github_api_url, + }) + display_name = "Github Upload Public Key" + icon = "/icon/github.svg" + run_on_start = true +} \ No newline at end of file diff --git a/registry/coder/modules/github-upload-public-key/run.sh b/registry/coder/modules/github-upload-public-key/run.sh new file mode 100644 index 00000000..a382a40a --- /dev/null +++ b/registry/coder/modules/github-upload-public-key/run.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +if [ -z "$CODER_ACCESS_URL" ]; then + if [ -z "${CODER_ACCESS_URL}" ]; then + echo "CODER_ACCESS_URL is empty!" + exit 1 + fi + CODER_ACCESS_URL=${CODER_ACCESS_URL} +fi + +if [ -z "$CODER_OWNER_SESSION_TOKEN" ]; then + if [ -z "${CODER_OWNER_SESSION_TOKEN}" ]; then + echo "CODER_OWNER_SESSION_TOKEN is empty!" + exit 1 + fi + CODER_OWNER_SESSION_TOKEN=${CODER_OWNER_SESSION_TOKEN} +fi + +if [ -z "$CODER_EXTERNAL_AUTH_ID" ]; then + if [ -z "${CODER_EXTERNAL_AUTH_ID}" ]; then + echo "CODER_EXTERNAL_AUTH_ID is empty!" + exit 1 + fi + CODER_EXTERNAL_AUTH_ID=${CODER_EXTERNAL_AUTH_ID} +fi + +if [ -z "$GITHUB_API_URL" ]; then + if [ -z "${GITHUB_API_URL}" ]; then + echo "GITHUB_API_URL is empty!" + exit 1 + fi + GITHUB_API_URL=${GITHUB_API_URL} +fi + +echo "Fetching GitHub token..." +GITHUB_TOKEN=$(coder external-auth access-token $CODER_EXTERNAL_AUTH_ID) +if [ $? -ne 0 ]; then + printf "Authenticate with Github to automatically upload Coder public key:\n$GITHUB_TOKEN\n" + exit 1 +fi + +echo "Fetching public key from Coder..." +PUBLIC_KEY_RESPONSE=$( + curl -L -s \ + -w "\n%%{http_code}" \ + -H 'accept: application/json' \ + -H "cookie: coder_session_token=$CODER_OWNER_SESSION_TOKEN" \ + "$CODER_ACCESS_URL/api/v2/users/me/gitsshkey" +) +PUBLIC_KEY_RESPONSE_STATUS=$(tail -n1 <<< "$PUBLIC_KEY_RESPONSE") +PUBLIC_KEY_BODY=$(sed \$d <<< "$PUBLIC_KEY_RESPONSE") + +if [ "$PUBLIC_KEY_RESPONSE_STATUS" -ne 200 ]; then + echo "Failed to fetch Coder public SSH key with status code $PUBLIC_KEY_RESPONSE_STATUS!" + echo "$PUBLIC_KEY_BODY" + exit 1 +fi +PUBLIC_KEY=$(jq -r '.public_key' <<< "$PUBLIC_KEY_BODY") +if [ -z "$PUBLIC_KEY" ]; then + echo "No Coder public SSH key found!" + exit 1 +fi + +echo "Fetching public keys from GitHub..." +GITHUB_KEYS_RESPONSE=$( + curl -L -s \ + -w "\n%%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + $GITHUB_API_URL/user/keys +) +GITHUB_KEYS_RESPONSE_STATUS=$(tail -n1 <<< "$GITHUB_KEYS_RESPONSE") +GITHUB_KEYS_RESPONSE_BODY=$(sed \$d <<< "$GITHUB_KEYS_RESPONSE") + +if [ "$GITHUB_KEYS_RESPONSE_STATUS" -ne 200 ]; then + echo "Failed to fetch Coder public SSH key with status code $GITHUB_KEYS_RESPONSE_STATUS!" + echo "$GITHUB_KEYS_RESPONSE_BODY" + exit 1 +fi + +GITHUB_MATCH=$(jq -r --arg PUBLIC_KEY "$PUBLIC_KEY" '.[] | select(.key == $PUBLIC_KEY) | .key' <<< "$GITHUB_KEYS_RESPONSE_BODY") + +if [ "$PUBLIC_KEY" = "$GITHUB_MATCH" ]; then + echo "Your Coder public key is already on GitHub!" + exit 0 +fi + +echo "Your Coder public key is not in GitHub. Adding it now..." +CODER_PUBLIC_KEY_NAME="$CODER_ACCESS_URL Workspaces" +UPLOAD_RESPONSE=$( + curl -L -s \ + -X POST \ + -w "\n%%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + $GITHUB_API_URL/user/keys \ + -d "{\"title\":\"$CODER_PUBLIC_KEY_NAME\",\"key\":\"$PUBLIC_KEY\"}" +) +UPLOAD_RESPONSE_STATUS=$(tail -n1 <<< "$UPLOAD_RESPONSE") +UPLOAD_RESPONSE_BODY=$(sed \$d <<< "$UPLOAD_RESPONSE") + +if [ "$UPLOAD_RESPONSE_STATUS" -ne 201 ]; then + echo "Failed to upload Coder public SSH key with status code $UPLOAD_RESPONSE_STATUS!" + echo "$UPLOAD_RESPONSE_BODY" + exit 1 +fi + +echo "Your Coder public key has been added to GitHub!" diff --git a/registry/coder/modules/goose/README.md b/registry/coder/modules/goose/README.md new file mode 100644 index 00000000..97221e78 --- /dev/null +++ b/registry/coder/modules/goose/README.md @@ -0,0 +1,130 @@ +--- +display_name: Goose +description: Run Goose in your workspace +icon: ../.icons/goose.svg +maintainer_github: coder +verified: true +tags: [agent, goose] +--- + +# Goose + +Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks. + +```tf +module "goose" { + source = "registry.coder.com/modules/goose/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" +} +``` + +### Prerequisites + +- `screen` must be installed in your workspace to run Goose in the background +- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template + +The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. + +## Examples + +Your workspace must have `screen` installed to use this. + +### Run in the background and report tasks (Experimental) + +> This functionality is in early access as of Coder v2.21 and is still evolving. +> For now, we recommend testing it in a demo or staging environment, +> rather than deploying to production +> +> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents) +> +> Join our [Discord channel](https://discord.gg/coder) or +> [contact us](https://coder.com/contact) to get help or share feedback. + +```tf +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder-login/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} + +variable "anthropic_api_key" { + type = string + description = "The Anthropic API key" + sensitive = true +} + +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Write a prompt for Goose" + mutable = true +} + +# Set the prompt and system prompt for Goose via environment variables +resource "coder_agent" "main" { + # ... + env = { + GOOSE_SYSTEM_PROMPT = <<-EOT + You are a helpful assistant that can help write code. + + Run all long running tasks (e.g. npm run dev) in the background and not in the foreground. + + Periodically check in on background tasks. + + Notify Coder of the status of the task before and after your steps. + EOT + GOOSE_TASK_PROMPT = data.coder_parameter.ai_prompt.value + + # An API key is required for experiment_auto_configure + # See https://block.github.io/goose/docs/getting-started/providers + ANTHROPIC_API_KEY = var.anthropic_api_key # or use a coder_parameter + } +} + +module "goose" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/goose/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" + + # Enable experimental features + experiment_report_tasks = true + + # Run Goose in the background + experiment_use_screen = true + + # Avoid configuring Goose manually + experiment_auto_configure = true + + # Required for experiment_auto_configure + experiment_goose_provider = "anthropic" + experiment_goose_model = "claude-3-5-sonnet-latest" +} +``` + +## Run standalone + +Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or any task reporting to the Coder UI. + +```tf +module "goose" { + source = "registry.coder.com/modules/goose/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" + + # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL + icon = "https://raw.githubusercontent.com/block/goose/refs/heads/main/ui/desktop/src/images/icon.svg" +} +``` diff --git a/registry/coder/modules/goose/main.tf b/registry/coder/modules/goose/main.tf new file mode 100644 index 00000000..fcb6baaa --- /dev/null +++ b/registry/coder/modules/goose/main.tf @@ -0,0 +1,207 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +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 "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/goose.svg" +} + +variable "folder" { + type = string + description = "The folder to run Goose in." + default = "/home/coder" +} + +variable "install_goose" { + type = bool + description = "Whether to install Goose." + default = true +} + +variable "goose_version" { + type = string + description = "The version of Goose to install." + default = "stable" +} + +variable "experiment_use_screen" { + type = bool + description = "Whether to use screen for running Goose in the background." + default = false +} + +variable "experiment_report_tasks" { + type = bool + description = "Whether to enable task reporting." + default = false +} + +variable "experiment_auto_configure" { + type = bool + description = "Whether to automatically configure Goose." + default = false +} + +variable "experiment_goose_provider" { + type = string + description = "The provider to use for Goose (e.g., anthropic)." + default = null +} + +variable "experiment_goose_model" { + type = string + description = "The model to use for Goose (e.g., claude-3-5-sonnet-latest)." + default = null +} + +# Install and Initialize Goose +resource "coder_script" "goose" { + agent_id = var.agent_id + display_name = "Goose" + icon = var.icon + script = <<-EOT + #!/bin/bash + set -e + + # Function to check if a command exists + command_exists() { + command -v "$1" >/dev/null 2>&1 + } + + # Install Goose if enabled + if [ "${var.install_goose}" = "true" ]; then + if ! command_exists npm; then + echo "Error: npm is not installed. Please install Node.js and npm first." + exit 1 + fi + echo "Installing Goose..." + RELEASE_TAG=v${var.goose_version} curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash + fi + + # Configure Goose if auto-configure is enabled + if [ "${var.experiment_auto_configure}" = "true" ]; then + echo "Configuring Goose..." + mkdir -p "$HOME/.config/goose" + cat > "$HOME/.config/goose/config.yaml" << EOL +GOOSE_PROVIDER: ${var.experiment_goose_provider} +GOOSE_MODEL: ${var.experiment_goose_model} +extensions: + coder: + args: + - exp + - mcp + - server + cmd: coder + description: Report ALL tasks and statuses (in progress, done, failed) before and after starting + enabled: true + envs: + CODER_MCP_APP_STATUS_SLUG: goose + name: Coder + timeout: 3000 + type: stdio + developer: + display_name: Developer + enabled: true + name: developer + timeout: 300 + type: builtin +EOL + fi + + # Run with screen if enabled + if [ "${var.experiment_use_screen}" = "true" ]; then + echo "Running Goose in the background..." + + # Check if screen is installed + if ! command_exists screen; then + echo "Error: screen is not installed. Please install screen manually." + exit 1 + fi + + touch "$HOME/.goose.log" + + # Ensure the screenrc exists + if [ ! -f "$HOME/.screenrc" ]; then + echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.goose.log" + echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" + fi + + if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then + echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.goose.log" + echo "multiuser on" >> "$HOME/.screenrc" + fi + + if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then + echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.goose.log" + echo "acladd $(whoami)" >> "$HOME/.screenrc" + fi + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + screen -U -dmS goose bash -c ' + cd ${var.folder} + $HOME/.local/bin/goose run --text "$GOOSE_SYSTEM_PROMPT. Your task: $GOOSE_TASK_PROMPT" --interactive | tee -a "$HOME/.goose.log" + exec bash + ' + else + # Check if goose is installed before running + if ! command_exists $HOME/.local/bin/goose; then + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 + fi + fi + EOT + run_on_start = true +} + +resource "coder_app" "goose" { + slug = "goose" + display_name = "Goose" + agent_id = var.agent_id + command = <<-EOT + #!/bin/bash + set -e + + if [ "${var.experiment_use_screen}" = "true" ]; then + if screen -list | grep -q "goose"; then + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + echo "Attaching to existing Goose session." | tee -a "$HOME/.goose.log" + screen -xRR goose + else + echo "Starting a new Goose session." | tee -a "$HOME/.goose.log" + screen -S goose bash -c 'export LANG=en_US.UTF-8; export LC_ALL=en_US.UTF-8; $HOME/.local/bin/goose run --text "Always report status and instructions to Coder, before and after your steps" --interactive | tee -a "$HOME/.goose.log"; exec bash' + fi + else + cd ${var.folder} + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + $HOME/.local/bin/goose + fi + EOT + icon = var.icon +} diff --git a/registry/coder/modules/hcp-vault-secrets/README.md b/registry/coder/modules/hcp-vault-secrets/README.md new file mode 100644 index 00000000..fc712306 --- /dev/null +++ b/registry/coder/modules/hcp-vault-secrets/README.md @@ -0,0 +1,80 @@ +--- +display_name: "HCP Vault Secrets" +description: "Fetch secrets from HCP Vault" +icon: ../.icons/vault.svg +maintainer_github: coder +partner_github: hashicorp +verified: true +tags: [helper, integration, vault, hashicorp, hvs] +--- + +# HCP Vault Secrets + +This module lets you fetch all or selective secrets from a [HCP Vault Secrets](https://developer.hashicorp.com/hcp/docs/vault-secrets) app into your [Coder](https://coder.com) workspaces. It makes use of the [`hcp_vault_secrets_app`](https://registry.terraform.io/providers/hashicorp/hcp/latest/docs/data-sources/vault_secrets_app) data source from the [HCP provider](https://registry.terraform.io/providers/hashicorp/hcp/latest). + +```tf +module "vault" { + source = "registry.coder.com/modules/hcp-vault-secrets/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + app_name = "demo-app" + project_id = "aaa-bbb-ccc" +} +``` + +## Configuration + +To configure the HCP Vault Secrets module, follow these steps, + +1. [Create secrets in HCP Vault Secrets](https://developer.hashicorp.com/vault/tutorials/hcp-vault-secrets-get-started/hcp-vault-secrets-create-secret) +2. Create an HCP Service Principal from the HCP Vault Secrets app in the HCP console. This will give you the `HCP_CLIENT_ID` and `HCP_CLIENT_SECRET` that you need to authenticate with HCP Vault Secrets. + ![HCP vault secrets credentials](../.images/hcp-vault-secrets-credentials.png) +3. Set `HCP_CLIENT_ID` and `HCP_CLIENT_SECRET` variables on the coder provisioner (recommended) or supply them as input to the module. +4. Set the `project_id`. This is the ID of the project where the HCP Vault Secrets app is running. + +> See the [HCP Vault Secrets documentation](https://developer.hashicorp.com/hcp/docs/vault-secrets) for more information. + +## Fetch All Secrets + +To fetch all secrets from the HCP Vault Secrets app, skip the `secrets` input. + +```tf +module "vault" { + source = "registry.coder.com/modules/hcp-vault-secrets/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + app_name = "demo-app" + project_id = "aaa-bbb-ccc" +} +``` + +## Fetch Selective Secrets + +To fetch selective secrets from the HCP Vault Secrets app, set the `secrets` input. + +```tf +module "vault" { + source = "registry.coder.com/modules/hcp-vault-secrets/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + app_name = "demo-app" + project_id = "aaa-bbb-ccc" + secrets = ["MY_SECRET_1", "MY_SECRET_2"] +} +``` + +## Set Client ID and Client Secret as Inputs + +Set `client_id` and `client_secret` as module inputs. + +```tf +module "vault" { + source = "registry.coder.com/modules/hcp-vault-secrets/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + app_name = "demo-app" + project_id = "aaa-bbb-ccc" + client_id = "HCP_CLIENT_ID" + client_secret = "HCP_CLIENT_SECRET" +} +``` diff --git a/registry/coder/modules/hcp-vault-secrets/main.tf b/registry/coder/modules/hcp-vault-secrets/main.tf new file mode 100644 index 00000000..9a5e94be --- /dev/null +++ b/registry/coder/modules/hcp-vault-secrets/main.tf @@ -0,0 +1,73 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12.4" + } + hcp = { + source = "hashicorp/hcp" + version = ">= 0.82.0" + } + } +} + +provider "hcp" { + client_id = var.client_id + client_secret = var.client_secret + project_id = var.project_id +} + +provider "coder" {} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "project_id" { + type = string + description = "The ID of the HCP project." +} + +variable "client_id" { + type = string + description = <<-EOF + The client ID for the HCP Vault Secrets service principal. (Optional if HCP_CLIENT_ID is set as an environment variable.) + EOF + default = null + sensitive = true +} + +variable "client_secret" { + type = string + description = <<-EOF + The client secret for the HCP Vault Secrets service principal. (Optional if HCP_CLIENT_SECRET is set as an environment variable.) + EOF + default = null + sensitive = true +} + +variable "app_name" { + type = string + description = "The name of the secrets app in HCP Vault Secrets" +} + +variable "secrets" { + type = list(string) + description = "The names of the secrets to retrieve from HCP Vault Secrets" + default = null +} + +data "hcp_vault_secrets_app" "secrets" { + app_name = var.app_name +} + +resource "coder_env" "hvs_secrets" { + # https://support.hashicorp.com/hc/en-us/articles/4538432032787-Variable-has-a-sensitive-value-and-cannot-be-used-as-for-each-arguments + for_each = var.secrets != null ? toset(var.secrets) : nonsensitive(toset(keys(data.hcp_vault_secrets_app.secrets.secrets))) + agent_id = var.agent_id + name = each.key + value = data.hcp_vault_secrets_app.secrets.secrets[each.key] +} \ No newline at end of file diff --git a/registry/coder/modules/jetbrains-gateway/README.md b/registry/coder/modules/jetbrains-gateway/README.md new file mode 100644 index 00000000..73c1e128 --- /dev/null +++ b/registry/coder/modules/jetbrains-gateway/README.md @@ -0,0 +1,133 @@ +--- +display_name: JetBrains Gateway +description: Add a one-click button to launch JetBrains Gateway IDEs in the dashboard. +icon: ../.icons/gateway.svg +maintainer_github: coder +verified: true +tags: [ide, jetbrains, helper, parameter] +--- + +# JetBrains Gateway + +This module adds a JetBrains Gateway Button to open any workspace with a single click. + +JetBrains recommends a minimum of 4 CPU cores and 8GB of RAM. +Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prerequisites.html#min_requirements) to confirm other system requirements. + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"] + default = "GO" +} +``` + +![JetBrains Gateway IDes list](../.images/jetbrains-gateway.png) + +## Examples + +### Add GoLand and WebStorm as options with the default set to GoLand + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["GO", "WS"] + default = "GO" +} +``` + +### Use the latest version of each IDE + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["IU", "PY"] + default = "IU" + latest = true +} +``` + +### Use fixed versions set by `jetbrains_ide_versions` + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["IU", "PY"] + default = "IU" + latest = false + jetbrains_ide_versions = { + "IU" = { + build_number = "243.21565.193" + version = "2024.3" + } + "PY" = { + build_number = "243.21565.199" + version = "2024.3" + } + } +} +``` + +### Use the latest EAP version + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["GO", "WS"] + default = "GO" + latest = true + channel = "eap" +} +``` + +### Custom base link + +Due to the highest priority of the `ide_download_link` parameter in the `(jetbrains-gateway://...` within IDEA, the pre-configured download address will be overridden when using [IDEA's offline mode](https://www.jetbrains.com/help/idea/fully-offline-mode.html). Therefore, it is necessary to configure the `download_base_link` parameter for the `jetbrains_gateway` module to change the value of `ide_download_link`. + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["GO", "WS"] + releases_base_link = "https://releases.internal.site/" + download_base_link = "https://download.internal.site/" + default = "GO" +} +``` + +## Supported IDEs + +This module and JetBrains Gateway support the following JetBrains IDEs: + +- [GoLand (`GO`)](https://www.jetbrains.com/go/) +- [WebStorm (`WS`)](https://www.jetbrains.com/webstorm/) +- [IntelliJ IDEA Ultimate (`IU`)](https://www.jetbrains.com/idea/) +- [PyCharm Professional (`PY`)](https://www.jetbrains.com/pycharm/) +- [PhpStorm (`PS`)](https://www.jetbrains.com/phpstorm/) +- [CLion (`CL`)](https://www.jetbrains.com/clion/) +- [RubyMine (`RM`)](https://www.jetbrains.com/ruby/) +- [Rider (`RD`)](https://www.jetbrains.com/rider/) +- [RustRover (`RR`)](https://www.jetbrains.com/rust/) diff --git a/registry/coder/modules/jetbrains-gateway/main.test.ts b/registry/coder/modules/jetbrains-gateway/main.test.ts new file mode 100644 index 00000000..ea04a77d --- /dev/null +++ b/registry/coder/modules/jetbrains-gateway/main.test.ts @@ -0,0 +1,43 @@ +import { it, expect, describe } from "bun:test"; +import { + runTerraformInit, + testRequiredVariables, + runTerraformApply, +} from "../test"; + +describe("jetbrains-gateway", async () => { + await runTerraformInit(import.meta.dir); + + await testRequiredVariables(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + }); + + it("should create a link with the default values", async () => { + const state = await runTerraformApply(import.meta.dir, { + // These are all required. + agent_id: "foo", + folder: "/home/coder", + }); + expect(state.outputs.url.value).toBe( + "jetbrains-gateway://connect#type=coder&workspace=default&owner=default&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=243.21565.193&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.3.tar.gz", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "gateway", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("default to first ide", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + jetbrains_ides: '["IU", "GO", "PY"]', + }); + expect(state.outputs.identifier.value).toBe("IU"); + }); +}); diff --git a/registry/coder/modules/jetbrains-gateway/main.tf b/registry/coder/modules/jetbrains-gateway/main.tf new file mode 100644 index 00000000..d197399d --- /dev/null +++ b/registry/coder/modules/jetbrains-gateway/main.tf @@ -0,0 +1,341 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + http = { + source = "hashicorp/http" + version = ">= 3.0" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "slug" { + type = string + description = "The slug for the coder_app. Allows resuing the module with the same template." + default = "gateway" +} + +variable "agent_name" { + type = string + description = "Agent name. (unused). Will be removed in a future version" + + default = "" +} + +variable "folder" { + type = string + description = "The directory to open in the IDE. e.g. /home/coder/project" + validation { + condition = can(regex("^(?:/[^/]+)+$", var.folder)) + error_message = "The folder must be a full path and must not start with a ~." + } +} + +variable "default" { + default = "" + type = string + description = "Default IDE" +} + +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 "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +variable "latest" { + type = bool + description = "Whether to fetch the latest version of the IDE." + default = false +} + +variable "channel" { + type = string + description = "JetBrains IDE release channel. Valid values are release and eap." + default = "release" + validation { + condition = can(regex("^(release|eap)$", var.channel)) + error_message = "The channel must be either release or eap." + } +} + +variable "jetbrains_ide_versions" { + type = map(object({ + build_number = string + version = string + })) + description = "The set of versions for each jetbrains IDE" + default = { + "IU" = { + build_number = "243.21565.193" + version = "2024.3" + } + "PS" = { + build_number = "243.21565.202" + version = "2024.3" + } + "WS" = { + build_number = "243.21565.180" + version = "2024.3" + } + "PY" = { + build_number = "243.21565.199" + version = "2024.3" + } + "CL" = { + build_number = "243.21565.238" + version = "2024.1" + } + "GO" = { + build_number = "243.21565.208" + version = "2024.3" + } + "RM" = { + build_number = "243.21565.197" + version = "2024.3" + } + "RD" = { + build_number = "243.21565.191" + version = "2024.3" + } + "RR" = { + build_number = "243.22562.230" + version = "2024.3" + } + } + validation { + condition = ( + alltrue([ + for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code) + ]) + ) + error_message = "The jetbrains_ide_versions must contain a map of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}." + } +} + +variable "jetbrains_ides" { + type = list(string) + description = "The list of IDE product codes." + default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"] + validation { + condition = ( + alltrue([ + for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code) + ]) + ) + error_message = "The jetbrains_ides must be a list of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}." + } + # check if the list is empty + validation { + condition = length(var.jetbrains_ides) > 0 + error_message = "The jetbrains_ides must not be empty." + } + # check if the list contains duplicates + validation { + condition = length(var.jetbrains_ides) == length(toset(var.jetbrains_ides)) + error_message = "The jetbrains_ides must not contain duplicates." + } +} + +variable "releases_base_link" { + type = string + description = "" + default = "https://data.services.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.releases_base_link)) + error_message = "The releases_base_link must be a valid HTTP/S address." + } +} + +variable "download_base_link" { + type = string + description = "" + default = "https://download.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.download_base_link)) + error_message = "The download_base_link must be a valid HTTP/S address." + } +} + +data "http" "jetbrains_ide_versions" { + for_each = var.latest ? toset(var.jetbrains_ides) : toset([]) + url = "${var.releases_base_link}/products/releases?code=${each.key}&latest=true&type=${var.channel}" +} + +locals { + jetbrains_ides = { + "GO" = { + icon = "/icon/goland.svg", + name = "GoLand", + identifier = "GO", + build_number = var.jetbrains_ide_versions["GO"].build_number, + download_link = "${var.download_base_link}/go/goland-${var.jetbrains_ide_versions["GO"].version}.tar.gz" + version = var.jetbrains_ide_versions["GO"].version + }, + "WS" = { + icon = "/icon/webstorm.svg", + name = "WebStorm", + identifier = "WS", + build_number = var.jetbrains_ide_versions["WS"].build_number, + download_link = "${var.download_base_link}/webstorm/WebStorm-${var.jetbrains_ide_versions["WS"].version}.tar.gz" + version = var.jetbrains_ide_versions["WS"].version + }, + "IU" = { + icon = "/icon/intellij.svg", + name = "IntelliJ IDEA Ultimate", + identifier = "IU", + build_number = var.jetbrains_ide_versions["IU"].build_number, + download_link = "${var.download_base_link}/idea/ideaIU-${var.jetbrains_ide_versions["IU"].version}.tar.gz" + version = var.jetbrains_ide_versions["IU"].version + }, + "PY" = { + icon = "/icon/pycharm.svg", + name = "PyCharm Professional", + identifier = "PY", + build_number = var.jetbrains_ide_versions["PY"].build_number, + download_link = "${var.download_base_link}/python/pycharm-professional-${var.jetbrains_ide_versions["PY"].version}.tar.gz" + version = var.jetbrains_ide_versions["PY"].version + }, + "CL" = { + icon = "/icon/clion.svg", + name = "CLion", + identifier = "CL", + build_number = var.jetbrains_ide_versions["CL"].build_number, + download_link = "${var.download_base_link}/cpp/CLion-${var.jetbrains_ide_versions["CL"].version}.tar.gz" + version = var.jetbrains_ide_versions["CL"].version + }, + "PS" = { + icon = "/icon/phpstorm.svg", + name = "PhpStorm", + identifier = "PS", + build_number = var.jetbrains_ide_versions["PS"].build_number, + download_link = "${var.download_base_link}/webide/PhpStorm-${var.jetbrains_ide_versions["PS"].version}.tar.gz" + version = var.jetbrains_ide_versions["PS"].version + }, + "RM" = { + icon = "/icon/rubymine.svg", + name = "RubyMine", + identifier = "RM", + build_number = var.jetbrains_ide_versions["RM"].build_number, + download_link = "${var.download_base_link}/ruby/RubyMine-${var.jetbrains_ide_versions["RM"].version}.tar.gz" + version = var.jetbrains_ide_versions["RM"].version + }, + "RD" = { + icon = "/icon/rider.svg", + name = "Rider", + identifier = "RD", + build_number = var.jetbrains_ide_versions["RD"].build_number, + download_link = "${var.download_base_link}/rider/JetBrains.Rider-${var.jetbrains_ide_versions["RD"].version}.tar.gz" + version = var.jetbrains_ide_versions["RD"].version + }, + "RR" = { + icon = "/icon/rustrover.svg", + name = "RustRover", + identifier = "RR", + build_number = var.jetbrains_ide_versions["RR"].build_number, + download_link = "${var.download_base_link}/rustrover/RustRover-${var.jetbrains_ide_versions["RR"].version}.tar.gz" + version = var.jetbrains_ide_versions["RR"].version + } + } + + icon = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].icon + json_data = var.latest ? jsondecode(data.http.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].response_body) : {} + key = var.latest ? keys(local.json_data)[0] : "" + display_name = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].name + identifier = data.coder_parameter.jetbrains_ide.value + download_link = var.latest ? local.json_data[local.key][0].downloads.linux.link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link + build_number = var.latest ? local.json_data[local.key][0].build : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].build_number + version = var.latest ? local.json_data[local.key][0].version : var.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].version +} + +data "coder_parameter" "jetbrains_ide" { + type = "string" + name = "jetbrains_ide" + display_name = "JetBrains IDE" + icon = "/icon/gateway.svg" + mutable = true + default = var.default == "" ? var.jetbrains_ides[0] : var.default + order = var.coder_parameter_order + + dynamic "option" { + for_each = var.jetbrains_ides + content { + icon = local.jetbrains_ides[option.value].icon + name = local.jetbrains_ides[option.value].name + value = option.value + } + } +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "gateway" { + agent_id = var.agent_id + slug = var.slug + display_name = local.display_name + icon = local.icon + external = true + order = var.order + url = join("", [ + "jetbrains-gateway://connect#type=coder&workspace=", + data.coder_workspace.me.name, + "&owner=", + data.coder_workspace_owner.me.name, + "&folder=", + var.folder, + "&url=", + data.coder_workspace.me.access_url, + "&token=", + "$SESSION_TOKEN", + "&ide_product_code=", + data.coder_parameter.jetbrains_ide.value, + "&ide_build_number=", + local.build_number, + "&ide_download_link=", + local.download_link, + ]) +} + +output "identifier" { + value = local.identifier +} + +output "display_name" { + value = local.display_name +} + +output "icon" { + value = local.icon +} + +output "download_link" { + value = local.download_link +} + +output "build_number" { + value = local.build_number +} + +output "version" { + value = local.version +} + +output "url" { + value = coder_app.gateway.url +} diff --git a/registry/coder/modules/jfrog-oauth/.npmrc.tftpl b/registry/coder/modules/jfrog-oauth/.npmrc.tftpl new file mode 100644 index 00000000..8bb9fb8f --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/.npmrc.tftpl @@ -0,0 +1,5 @@ +email=${ARTIFACTORY_EMAIL} +%{ for REPO in REPOS ~} +${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME} +//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN} +%{ endfor ~} diff --git a/registry/coder/modules/jfrog-oauth/README.md b/registry/coder/modules/jfrog-oauth/README.md new file mode 100644 index 00000000..894e1f32 --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/README.md @@ -0,0 +1,107 @@ +--- +display_name: JFrog (OAuth) +description: Install the JF CLI and authenticate with Artifactory using OAuth. +icon: ../.icons/jfrog.svg +maintainer_github: coder +partner_github: jfrog +verified: true +tags: [integration, jfrog] +--- + +# JFrog + +Install the JF CLI and authenticate package managers with Artifactory using OAuth configured via the Coder [`external-auth`](https://coder.com/docs/v2/latest/admin/external-auth) feature. + +![JFrog OAuth](../.images/jfrog-oauth.png) + +```tf +module "jfrog" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jfrog-oauth/coder" + version = "1.0.19" + agent_id = coder_agent.example.id + jfrog_url = "https://example.jfrog.io" + username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username" + + package_managers = { + npm = ["npm", "@scoped:npm-scoped"] + go = ["go", "another-go-repo"] + pypi = ["pypi", "extra-index-pypi"] + docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"] + } +} +``` + +> Note +> This module does not install `npm`, `go`, `pip`, etc but only configure them. You need to handle the installation of these tools yourself. + +## Prerequisites + +This module is usable by JFrog self-hosted (on-premises) Artifactory as it requires configuring a custom integration. This integration benefits from Coder's [external-auth](https://coder.com/docs/v2/latest/admin/external-auth) feature and allows each user to authenticate with Artifactory using an OAuth flow and issues user-scoped tokens to each user. For configuration instructions, see this [guide](https://coder.com/docs/v2/latest/guides/artifactory-integration#jfrog-oauth) on the Coder documentation. + +## Examples + +Configure the Python pip package manager to fetch packages from Artifactory while mapping the Coder email to the Artifactory username. + +```tf +module "jfrog" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jfrog-oauth/coder" + version = "1.0.19" + agent_id = coder_agent.example.id + jfrog_url = "https://example.jfrog.io" + username_field = "email" + + package_managers = { + pypi = ["pypi"] + } +} +``` + +You should now be able to install packages from Artifactory using both the `jf pip` and `pip` command. + +```shell +jf pip install requests +``` + +```shell +pip install requests +``` + +### Configure code-server with JFrog extension + +The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extension) for VS Code allows you to interact with Artifactory from within the IDE. + +```tf +module "jfrog" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jfrog-oauth/coder" + version = "1.0.19" + agent_id = coder_agent.example.id + jfrog_url = "https://example.jfrog.io" + username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username" + configure_code_server = true # Add JFrog extension configuration for code-server + package_managers = { + npm = ["npm"] + go = ["go"] + pypi = ["pypi"] + } +} +``` + +### Using the access token in other terraform resources + +JFrog Access token is also available as a terraform output. You can use it in other terraform resources. For example, you can use it to configure an [Artifactory docker registry](https://jfrog.com/help/r/jfrog-artifactory-documentation/docker-registry) with the [docker terraform provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs). + +```tf +provider "docker" { + # ... + registry_auth { + address = "https://example.jfrog.io/artifactory/api/docker/REPO-KEY" + username = try(module.jfrog[0].username, "") + password = try(module.jfrog[0].access_token, "") + } +} +``` + +> Here `REPO_KEY` is the name of docker repository in Artifactory. diff --git a/registry/coder/modules/jfrog-oauth/main.test.ts b/registry/coder/modules/jfrog-oauth/main.test.ts new file mode 100644 index 00000000..7b0c1a5f --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/main.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "bun:test"; +import { + findResourceInstance, + runTerraformInit, + runTerraformApply, + testRequiredVariables, +} from "../test"; + +describe("jfrog-oauth", async () => { + type TestVariables = { + agent_id: string; + jfrog_url: string; + package_managers: string; + + username_field?: string; + jfrog_server_id?: string; + external_auth_id?: string; + configure_code_server?: boolean; + }; + + await runTerraformInit(import.meta.dir); + + const fakeFrogApi = "localhost:8081/artifactory/api"; + const fakeFrogUrl = "http://localhost:8081"; + const user = "default"; + + it("can run apply with required variables", async () => { + testRequiredVariables(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: "{}", + }); + }); + + it("generates an npmrc with scoped repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + npm: ["global", "@foo:foo", "@bar:bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const npmrcStanza = `cat << EOF > ~/.npmrc +email=${user}@example.com +registry=http://${fakeFrogApi}/npm/global +//${fakeFrogApi}/npm/global/:_authToken= +@foo:registry=http://${fakeFrogApi}/npm/foo +//${fakeFrogApi}/npm/foo/:_authToken= +@bar:registry=http://${fakeFrogApi}/npm/bar +//${fakeFrogApi}/npm/bar/:_authToken= + +EOF`; + expect(coderScript.script).toContain(npmrcStanza); + expect(coderScript.script).toContain( + 'jf npmc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured npm', + ); + }); + + it("generates a pip config with extra-indexes", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + pypi: ["global", "foo", "bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const pipStanza = `cat << EOF > ~/.pip/pip.conf +[global] +index-url = https://${user}:@${fakeFrogApi}/pypi/global/simple +extra-index-url = + https://${user}:@${fakeFrogApi}/pypi/foo/simple + https://${user}:@${fakeFrogApi}/pypi/bar/simple + +EOF`; + expect(coderScript.script).toContain(pipStanza); + expect(coderScript.script).toContain( + 'jf pipc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured pypi', + ); + }); + + it("registers multiple docker repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const dockerStanza = ["foo", "bar", "baz"] + .map((r) => `register_docker "${r}.jfrog.io"`) + .join("\n"); + expect(coderScript.script).toContain(dockerStanza); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured docker', + ); + }); + + it("sets goproxy with multiple repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + go: ["foo", "bar", "baz"], + }), + }); + const proxyEnv = findResourceInstance(state, "coder_env", "goproxy"); + const proxies = ["foo", "bar", "baz"] + .map((r) => `https://${user}:@${fakeFrogApi}/go/${r}`) + .join(","); + expect(proxyEnv.value).toEqual(proxies); + + const coderScript = findResourceInstance(state, "coder_script"); + expect(coderScript.script).toContain( + 'jf goc --global --repo-resolve "foo"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured go', + ); + }); +}); diff --git a/registry/coder/modules/jfrog-oauth/main.tf b/registry/coder/modules/jfrog-oauth/main.tf new file mode 100644 index 00000000..0bc22568 --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/main.tf @@ -0,0 +1,173 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "jfrog_url" { + type = string + description = "JFrog instance URL. e.g. https://myartifactory.jfrog.io" + # ensue the URL is HTTPS or HTTP + validation { + condition = can(regex("^(https|http)://", var.jfrog_url)) + error_message = "jfrog_url must be a valid URL starting with either 'https://' or 'http://'" + } +} + +variable "jfrog_server_id" { + type = string + description = "The server ID of the JFrog instance for JFrog CLI configuration" + default = "0" +} + +variable "username_field" { + type = string + description = "The field to use for the artifactory username. i.e. Coder username or email." + default = "username" + validation { + condition = can(regex("^(email|username)$", var.username_field)) + error_message = "username_field must be either 'email' or 'username'" + } +} + +variable "external_auth_id" { + type = string + description = "JFrog external auth ID. Default: 'jfrog'" + default = "jfrog" +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "configure_code_server" { + type = bool + description = "Set to true to configure code-server to use JFrog." + default = false +} + +variable "package_managers" { + type = object({ + npm = optional(list(string), []) + go = optional(list(string), []) + pypi = optional(list(string), []) + docker = optional(list(string), []) + }) + description = <<-EOF + A map of package manager names to their respective artifactory repositories. Unused package managers can be omitted. + For example: + { + npm = ["GLOBAL_NPM_REPO_KEY", "@SCOPED:NPM_REPO_KEY"] + go = ["YOUR_GO_REPO_KEY", "ANOTHER_GO_REPO_KEY"] + pypi = ["YOUR_PYPI_REPO_KEY", "ANOTHER_PYPI_REPO_KEY"] + docker = ["YOUR_DOCKER_REPO_KEY", "ANOTHER_DOCKER_REPO_KEY"] + } + EOF +} + +locals { + # The username field to use for artifactory + username = var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name + jfrog_host = split("://", var.jfrog_url)[1] + common_values = { + JFROG_URL = var.jfrog_url + JFROG_HOST = local.jfrog_host + JFROG_SERVER_ID = var.jfrog_server_id + ARTIFACTORY_USERNAME = local.username + ARTIFACTORY_EMAIL = data.coder_workspace_owner.me.email + ARTIFACTORY_ACCESS_TOKEN = data.coder_external_auth.jfrog.access_token + } + npmrc = templatefile( + "${path.module}/.npmrc.tftpl", + merge( + local.common_values, + { + REPOS = [ + for r in var.package_managers.npm : + strcontains(r, ":") ? zipmap(["SCOPE", "NAME"], ["${split(":", r)[0]}:", split(":", r)[1]]) : { SCOPE = "", NAME = r } + ] + } + ) + ) + pip_conf = templatefile( + "${path.module}/pip.conf.tftpl", merge(local.common_values, { REPOS = var.package_managers.pypi }) + ) +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_external_auth" "jfrog" { + id = var.external_auth_id +} + +resource "coder_script" "jfrog" { + agent_id = var.agent_id + display_name = "jfrog" + icon = "/icon/jfrog.svg" + script = templatefile("${path.module}/run.sh", merge( + local.common_values, + { + CONFIGURE_CODE_SERVER = var.configure_code_server + HAS_NPM = length(var.package_managers.npm) == 0 ? "" : "YES" + NPMRC = local.npmrc + REPOSITORY_NPM = try(element(var.package_managers.npm, 0), "") + HAS_GO = length(var.package_managers.go) == 0 ? "" : "YES" + REPOSITORY_GO = try(element(var.package_managers.go, 0), "") + HAS_PYPI = length(var.package_managers.pypi) == 0 ? "" : "YES" + PIP_CONF = local.pip_conf + REPOSITORY_PYPI = try(element(var.package_managers.pypi, 0), "") + HAS_DOCKER = length(var.package_managers.docker) == 0 ? "" : "YES" + REGISTER_DOCKER = join("\n", formatlist("register_docker \"%s\"", var.package_managers.docker)) + } + )) + run_on_start = true +} + +resource "coder_env" "jfrog_ide_url" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_URL" + value = var.jfrog_url +} + +resource "coder_env" "jfrog_ide_access_token" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_ACCESS_TOKEN" + value = data.coder_external_auth.jfrog.access_token +} + +resource "coder_env" "jfrog_ide_store_connection" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_STORE_CONNECTION" + value = true +} + +resource "coder_env" "goproxy" { + count = length(var.package_managers.go) == 0 ? 0 : 1 + agent_id = var.agent_id + name = "GOPROXY" + value = join(",", [ + for repo in var.package_managers.go : + "https://${local.username}:${data.coder_external_auth.jfrog.access_token}@${local.jfrog_host}/artifactory/api/go/${repo}" + ]) +} + +output "access_token" { + description = "value of the JFrog access token" + value = data.coder_external_auth.jfrog.access_token + sensitive = true +} + +output "username" { + description = "value of the JFrog username" + value = local.username +} diff --git a/registry/coder/modules/jfrog-oauth/pip.conf.tftpl b/registry/coder/modules/jfrog-oauth/pip.conf.tftpl new file mode 100644 index 00000000..e4a62e9a --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/pip.conf.tftpl @@ -0,0 +1,6 @@ +[global] +index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${try(element(REPOS, 0), "")}/simple +extra-index-url = +%{ for REPO in try(slice(REPOS, 1, length(REPOS)), []) ~} + https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPO}/simple +%{ endfor ~} diff --git a/registry/coder/modules/jfrog-oauth/run.sh b/registry/coder/modules/jfrog-oauth/run.sh new file mode 100644 index 00000000..7d36e47c --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/run.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' + +not_configured() { + type=$1 + echo "🤔 no $type repository is set, skipping $type configuration." + echo "You can configure a $type repository by providing a key for '$type' in the 'package_managers' input." +} + +config_complete() { + echo "🥳 Configuration complete!" +} + +register_docker() { + repo=$1 + echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login "$repo" --username ${ARTIFACTORY_USERNAME} --password-stdin +} + +# check if JFrog CLI is already installed +if command -v jf > /dev/null 2>&1; then + echo "✅ JFrog CLI is already installed, skipping installation." +else + echo "📦 Installing JFrog CLI..." + curl -fL https://install-cli.jfrog.io | sudo sh + sudo chmod 755 /usr/local/bin/jf +fi + +# The jf CLI checks $CI when determining whether to use interactive +# flows. +export CI=true +# Authenticate JFrog CLI with Artifactory. +echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFROG_URL}" --overwrite "${JFROG_SERVER_ID}" +# Set the configured server as the default. +jf c use "${JFROG_SERVER_ID}" + +# Configure npm to use the Artifactory "npm" repository. +if [ -z "${HAS_NPM}" ]; then + not_configured npm +else + echo "📦 Configuring npm..." + jf npmc --global --repo-resolve "${REPOSITORY_NPM}" + cat << EOF > ~/.npmrc +${NPMRC} +EOF + config_complete +fi + +# Configure the `pip` to use the Artifactory "python" repository. +if [ -z "${HAS_PYPI}" ]; then + not_configured pypi +else + echo "🐍 Configuring pip..." + jf pipc --global --repo-resolve "${REPOSITORY_PYPI}" + mkdir -p ~/.pip + cat << EOF > ~/.pip/pip.conf +${PIP_CONF} +EOF + config_complete +fi + +# Configure Artifactory "go" repository. +if [ -z "${HAS_GO}" ]; then + not_configured go +else + echo "🐹 Configuring go..." + jf goc --global --repo-resolve "${REPOSITORY_GO}" + config_complete +fi + +# Configure the JFrog CLI to use the Artifactory "docker" repository. +if [ -z "${HAS_DOCKER}" ]; then + not_configured docker +else + if command -v docker > /dev/null 2>&1; then + echo "🔑 Configuring 🐳 docker credentials..." + mkdir -p ~/.docker + ${REGISTER_DOCKER} + else + echo "🤔 no docker is installed, skipping docker configuration." + fi +fi + +# Install the JFrog vscode extension for code-server. +if [ "${CONFIGURE_CODE_SERVER}" == "true" ]; then + while ! [ -x /tmp/code-server/bin/code-server ]; do + counter=0 + if [ $counter -eq 60 ]; then + echo "Timed out waiting for /tmp/code-server/bin/code-server to be installed." + exit 1 + fi + echo "Waiting for /tmp/code-server/bin/code-server to be installed..." + sleep 1 + ((counter++)) + done + echo "📦 Installing JFrog extension..." + /tmp/code-server/bin/code-server --install-extension jfrog.jfrog-vscode-extension + echo "🥳 JFrog extension installed!" +else + echo "🤔 Skipping JFrog extension installation. Set configure_code_server to true to install the JFrog extension." +fi + +# Configure the JFrog CLI completion +echo "📦 Configuring JFrog CLI completion..." +# Get the user's shell +SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}') +# Generate the completion script +jf completion $SHELLNAME --install +begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" +# Add the completion script to the user's shell profile +if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then + if ! grep -q "$begin_stanza" ~/.bashrc; then + printf "%s\n" "$begin_stanza" >> ~/.bashrc + echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc + echo "# END: jf CLI shell completion" >> ~/.bashrc + else + echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping." + fi +elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then + if ! grep -q "$begin_stanza" ~/.zshrc; then + printf "\n%s\n" "$begin_stanza" >> ~/.zshrc + echo "autoload -Uz compinit" >> ~/.zshrc + echo "compinit" >> ~/.zshrc + echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc + echo "# END: jf CLI shell completion" >> ~/.zshrc + else + echo "🥳 ~/.zshrc already contains jf CLI shell completion configuration, skipping." + fi +else + echo "🤔 ~/.bashrc or ~/.zshrc does not exist, skipping jf CLI shell completion configuration." +fi diff --git a/registry/coder/modules/jfrog-token/.npmrc.tftpl b/registry/coder/modules/jfrog-token/.npmrc.tftpl new file mode 100644 index 00000000..8bb9fb8f --- /dev/null +++ b/registry/coder/modules/jfrog-token/.npmrc.tftpl @@ -0,0 +1,5 @@ +email=${ARTIFACTORY_EMAIL} +%{ for REPO in REPOS ~} +${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME} +//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN} +%{ endfor ~} diff --git a/registry/coder/modules/jfrog-token/README.md b/registry/coder/modules/jfrog-token/README.md new file mode 100644 index 00000000..ce165222 --- /dev/null +++ b/registry/coder/modules/jfrog-token/README.md @@ -0,0 +1,125 @@ +--- +display_name: JFrog (Token) +description: Install the JF CLI and authenticate with Artifactory using Artifactory terraform provider. +icon: ../.icons/jfrog.svg +maintainer_github: coder +partner_github: jfrog +verified: true +tags: [integration, jfrog] +--- + +# JFrog + +Install the JF CLI and authenticate package managers with Artifactory using Artifactory terraform provider. + +```tf +module "jfrog" { + source = "registry.coder.com/modules/jfrog-token/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + jfrog_url = "https://XXXX.jfrog.io" + artifactory_access_token = var.artifactory_access_token + package_managers = { + npm = ["npm", "@scoped:npm-scoped"] + go = ["go", "another-go-repo"] + pypi = ["pypi", "extra-index-pypi"] + docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"] + } +} +``` + +For detailed instructions, please see this [guide](https://coder.com/docs/v2/latest/guides/artifactory-integration#jfrog-token) on the Coder documentation. + +> Note +> This module does not install `npm`, `go`, `pip`, etc but only configure them. You need to handle the installation of these tools yourself. + +![JFrog](../.images/jfrog.png) + +## Examples + +### Configure npm, go, and pypi to use Artifactory local repositories + +```tf +module "jfrog" { + source = "registry.coder.com/modules/jfrog-token/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + jfrog_url = "https://YYYY.jfrog.io" + artifactory_access_token = var.artifactory_access_token # An admin access token + package_managers = { + npm = ["npm-local"] + go = ["go-local"] + pypi = ["pypi-local"] + } +} +``` + +You should now be able to install packages from Artifactory using both the `jf npm`, `jf go`, `jf pip` and `npm`, `go`, `pip` commands. + +```shell +jf npm install prettier +jf go get github.com/golang/example/hello +jf pip install requests +``` + +```shell +npm install prettier +go get github.com/golang/example/hello +pip install requests +``` + +### Configure code-server with JFrog extension + +The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extension) for VS Code allows you to interact with Artifactory from within the IDE. + +```tf +module "jfrog" { + source = "registry.coder.com/modules/jfrog-token/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + jfrog_url = "https://XXXX.jfrog.io" + artifactory_access_token = var.artifactory_access_token + configure_code_server = true # Add JFrog extension configuration for code-server + package_managers = { + npm = ["npm"] + go = ["go"] + pypi = ["pypi"] + } +} +``` + +### Add a custom token description + +```tf +data "coder_workspace" "me" {} + +module "jfrog" { + source = "registry.coder.com/modules/jfrog-token/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + jfrog_url = "https://XXXX.jfrog.io" + artifactory_access_token = var.artifactory_access_token + token_description = "Token for Coder workspace: ${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}" + package_managers = { + npm = ["npm"] + } +} +``` + +### Using the access token in other terraform resources + +JFrog Access token is also available as a terraform output. You can use it in other terraform resources. For example, you can use it to configure an [Artifactory docker registry](https://jfrog.com/help/r/jfrog-artifactory-documentation/docker-registry) with the [docker terraform provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs). + +```tf + +provider "docker" { + # ... + registry_auth { + address = "https://YYYY.jfrog.io/artifactory/api/docker/REPO-KEY" + username = module.jfrog.username + password = module.jfrog.access_token + } +} +``` + +> Here `REPO_KEY` is the name of docker repository in Artifactory. diff --git a/registry/coder/modules/jfrog-token/main.test.ts b/registry/coder/modules/jfrog-token/main.test.ts new file mode 100644 index 00000000..4ba2f52d --- /dev/null +++ b/registry/coder/modules/jfrog-token/main.test.ts @@ -0,0 +1,165 @@ +import { serve } from "bun"; +import { describe, expect, it } from "bun:test"; +import { + createJSONResponse, + findResourceInstance, + runTerraformInit, + runTerraformApply, + testRequiredVariables, +} from "../test"; + +describe("jfrog-token", async () => { + type TestVariables = { + agent_id: string; + jfrog_url: string; + artifactory_access_token: string; + package_managers: string; + + token_description?: string; + check_license?: boolean; + refreshable?: boolean; + expires_in?: number; + username_field?: string; + username?: string; + jfrog_server_id?: string; + configure_code_server?: boolean; + }; + + await runTerraformInit(import.meta.dir); + + // Run a fake JFrog server so the provider can initialize + // correctly. This saves us from having to make remote requests! + const fakeFrogHost = serve({ + fetch: (req) => { + const url = new URL(req.url); + // See https://jfrog.com/help/r/jfrog-rest-apis/license-information + if (url.pathname === "/artifactory/api/system/license") + return createJSONResponse({ + type: "Commercial", + licensedTo: "JFrog inc.", + validThrough: "May 15, 2036", + }); + if (url.pathname === "/access/api/v1/tokens") + return createJSONResponse({ + token_id: "xxx", + access_token: "xxx", + scopes: "any", + }); + return createJSONResponse({}); + }, + port: 0, + }); + + const fakeFrogApi = `${fakeFrogHost.hostname}:${fakeFrogHost.port}/artifactory/api`; + const fakeFrogUrl = `http://${fakeFrogHost.hostname}:${fakeFrogHost.port}`; + const user = "default"; + const token = "xxx"; + + it("can run apply with required variables", async () => { + testRequiredVariables(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: "{}", + }); + }); + + it("generates an npmrc with scoped repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + npm: ["global", "@foo:foo", "@bar:bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const npmrcStanza = `cat << EOF > ~/.npmrc +email=${user}@example.com +registry=http://${fakeFrogApi}/npm/global +//${fakeFrogApi}/npm/global/:_authToken=xxx +@foo:registry=http://${fakeFrogApi}/npm/foo +//${fakeFrogApi}/npm/foo/:_authToken=xxx +@bar:registry=http://${fakeFrogApi}/npm/bar +//${fakeFrogApi}/npm/bar/:_authToken=xxx + +EOF`; + expect(coderScript.script).toContain(npmrcStanza); + expect(coderScript.script).toContain( + 'jf npmc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured npm', + ); + }); + + it("generates a pip config with extra-indexes", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + pypi: ["global", "foo", "bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const pipStanza = `cat << EOF > ~/.pip/pip.conf +[global] +index-url = https://${user}:${token}@${fakeFrogApi}/pypi/global/simple +extra-index-url = + https://${user}:${token}@${fakeFrogApi}/pypi/foo/simple + https://${user}:${token}@${fakeFrogApi}/pypi/bar/simple + +EOF`; + expect(coderScript.script).toContain(pipStanza); + expect(coderScript.script).toContain( + 'jf pipc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured pypi', + ); + }); + + it("registers multiple docker repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const dockerStanza = ["foo", "bar", "baz"] + .map((r) => `register_docker "${r}.jfrog.io"`) + .join("\n"); + expect(coderScript.script).toContain(dockerStanza); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured docker', + ); + }); + + it("sets goproxy with multiple repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + go: ["foo", "bar", "baz"], + }), + }); + const proxyEnv = findResourceInstance(state, "coder_env", "goproxy"); + const proxies = ["foo", "bar", "baz"] + .map((r) => `https://${user}:${token}@${fakeFrogApi}/go/${r}`) + .join(","); + expect(proxyEnv.value).toEqual(proxies); + + const coderScript = findResourceInstance(state, "coder_script"); + expect(coderScript.script).toContain( + 'jf goc --global --repo-resolve "foo"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured go', + ); + }); +}); diff --git a/registry/coder/modules/jfrog-token/main.tf b/registry/coder/modules/jfrog-token/main.tf new file mode 100644 index 00000000..720e2d8c --- /dev/null +++ b/registry/coder/modules/jfrog-token/main.tf @@ -0,0 +1,219 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + artifactory = { + source = "registry.terraform.io/jfrog/artifactory" + version = "~> 10.0.2" + } + } +} + +variable "jfrog_url" { + type = string + description = "JFrog instance URL. e.g. https://myartifactory.jfrog.io" + # ensue the URL is HTTPS or HTTP + validation { + condition = can(regex("^(https|http)://", var.jfrog_url)) + error_message = "jfrog_url must be a valid URL starting with either 'https://' or 'http://'" + } +} + +variable "jfrog_server_id" { + type = string + description = "The server ID of the JFrog instance for JFrog CLI configuration" + default = "0" +} + +variable "artifactory_access_token" { + type = string + description = "The admin-level access token to use for JFrog." +} + +variable "token_description" { + type = string + description = "Free text token description. Useful for filtering and managing tokens." + default = "Token for Coder workspace" +} + +variable "check_license" { + type = bool + description = "Toggle for pre-flight checking of Artifactory license. Default to `true`." + default = true +} + +variable "refreshable" { + type = bool + description = "Is this token refreshable? Default is `false`." + default = false +} + +variable "expires_in" { + type = number + description = "The amount of time, in seconds, it would take for the token to expire." + default = null +} + +variable "username_field" { + type = string + description = "The field to use for the artifactory username. Default `username`." + default = "username" + validation { + condition = can(regex("^(email|username)$", var.username_field)) + error_message = "username_field must be either 'email' or 'username'" + } +} + +variable "username" { + type = string + description = "Username to use for Artifactory. Overrides the field specified in `username_field`" + default = null +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "configure_code_server" { + type = bool + description = "Set to true to configure code-server to use JFrog." + default = false +} + +variable "package_managers" { + type = object({ + npm = optional(list(string), []) + go = optional(list(string), []) + pypi = optional(list(string), []) + docker = optional(list(string), []) + }) + description = <<-EOF + A map of package manager names to their respective artifactory repositories. Unused package managers can be omitted. + For example: + { + npm = ["GLOBAL_NPM_REPO_KEY", "@SCOPED:NPM_REPO_KEY"] + go = ["YOUR_GO_REPO_KEY", "ANOTHER_GO_REPO_KEY"] + pypi = ["YOUR_PYPI_REPO_KEY", "ANOTHER_PYPI_REPO_KEY"] + docker = ["YOUR_DOCKER_REPO_KEY", "ANOTHER_DOCKER_REPO_KEY"] + } + EOF +} + +locals { + # The username to use for artifactory + username = coalesce(var.username, var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name) + jfrog_host = split("://", var.jfrog_url)[1] + common_values = { + JFROG_URL = var.jfrog_url + JFROG_HOST = local.jfrog_host + JFROG_SERVER_ID = var.jfrog_server_id + ARTIFACTORY_USERNAME = local.username + ARTIFACTORY_EMAIL = data.coder_workspace_owner.me.email + ARTIFACTORY_ACCESS_TOKEN = artifactory_scoped_token.me.access_token + } + npmrc = templatefile( + "${path.module}/.npmrc.tftpl", + merge( + local.common_values, + { + REPOS = [ + for r in var.package_managers.npm : + strcontains(r, ":") ? zipmap(["SCOPE", "NAME"], ["${split(":", r)[0]}:", split(":", r)[1]]) : { SCOPE = "", NAME = r } + ] + } + ) + ) + pip_conf = templatefile( + "${path.module}/pip.conf.tftpl", merge(local.common_values, { REPOS = var.package_managers.pypi }) + ) +} + +# Configure the Artifactory provider +provider "artifactory" { + url = join("/", [var.jfrog_url, "artifactory"]) + access_token = var.artifactory_access_token + check_license = var.check_license +} + +resource "artifactory_scoped_token" "me" { + # This is hacky, but on terraform plan the data source gives empty strings, + # which fails validation. + username = length(local.username) > 0 ? local.username : "dummy" + scopes = ["applied-permissions/user"] + refreshable = var.refreshable + expires_in = var.expires_in + description = var.token_description +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_script" "jfrog" { + agent_id = var.agent_id + display_name = "jfrog" + icon = "/icon/jfrog.svg" + script = templatefile("${path.module}/run.sh", merge( + local.common_values, + { + CONFIGURE_CODE_SERVER = var.configure_code_server + HAS_NPM = length(var.package_managers.npm) == 0 ? "" : "YES" + NPMRC = local.npmrc + REPOSITORY_NPM = try(element(var.package_managers.npm, 0), "") + HAS_GO = length(var.package_managers.go) == 0 ? "" : "YES" + REPOSITORY_GO = try(element(var.package_managers.go, 0), "") + HAS_PYPI = length(var.package_managers.pypi) == 0 ? "" : "YES" + PIP_CONF = local.pip_conf + REPOSITORY_PYPI = try(element(var.package_managers.pypi, 0), "") + HAS_DOCKER = length(var.package_managers.docker) == 0 ? "" : "YES" + REGISTER_DOCKER = join("\n", formatlist("register_docker \"%s\"", var.package_managers.docker)) + } + )) + run_on_start = true +} + +resource "coder_env" "jfrog_ide_url" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_URL" + value = var.jfrog_url +} + +resource "coder_env" "jfrog_ide_access_token" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_ACCESS_TOKEN" + value = artifactory_scoped_token.me.access_token +} + +resource "coder_env" "jfrog_ide_store_connection" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_STORE_CONNECTION" + value = true +} + +resource "coder_env" "goproxy" { + count = length(var.package_managers.go) == 0 ? 0 : 1 + agent_id = var.agent_id + name = "GOPROXY" + value = join(",", [ + for repo in var.package_managers.go : + "https://${local.username}:${artifactory_scoped_token.me.access_token}@${local.jfrog_host}/artifactory/api/go/${repo}" + ]) +} + +output "access_token" { + description = "value of the JFrog access token" + value = artifactory_scoped_token.me.access_token + sensitive = true +} + +output "username" { + description = "value of the JFrog username" + value = local.username +} diff --git a/registry/coder/modules/jfrog-token/pip.conf.tftpl b/registry/coder/modules/jfrog-token/pip.conf.tftpl new file mode 100644 index 00000000..e4a62e9a --- /dev/null +++ b/registry/coder/modules/jfrog-token/pip.conf.tftpl @@ -0,0 +1,6 @@ +[global] +index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${try(element(REPOS, 0), "")}/simple +extra-index-url = +%{ for REPO in try(slice(REPOS, 1, length(REPOS)), []) ~} + https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPO}/simple +%{ endfor ~} diff --git a/registry/coder/modules/jfrog-token/run.sh b/registry/coder/modules/jfrog-token/run.sh new file mode 100644 index 00000000..d3a1a74c --- /dev/null +++ b/registry/coder/modules/jfrog-token/run.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' + +not_configured() { + type=$1 + echo "🤔 no $type repository is set, skipping $type configuration." + echo "You can configure a $type repository by providing a key for '$type' in the 'package_managers' input." +} + +config_complete() { + echo "🥳 Configuration complete!" +} + +register_docker() { + repo=$1 + echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login "$repo" --username ${ARTIFACTORY_USERNAME} --password-stdin +} + +# check if JFrog CLI is already installed +if command -v jf > /dev/null 2>&1; then + echo "✅ JFrog CLI is already installed, skipping installation." +else + echo "📦 Installing JFrog CLI..." + curl -fL https://install-cli.jfrog.io | sudo sh + sudo chmod 755 /usr/local/bin/jf +fi + +# The jf CLI checks $CI when determining whether to use interactive flows. +export CI=true +# Authenticate JFrog CLI with Artifactory. +echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFROG_URL}" --overwrite "${JFROG_SERVER_ID}" +# Set the configured server as the default. +jf c use "${JFROG_SERVER_ID}" + +# Configure npm to use the Artifactory "npm" repository. +if [ -z "${HAS_NPM}" ]; then + not_configured npm +else + echo "📦 Configuring npm..." + jf npmc --global --repo-resolve "${REPOSITORY_NPM}" + cat << EOF > ~/.npmrc +${NPMRC} +EOF + config_complete +fi + +# Configure the `pip` to use the Artifactory "python" repository. +if [ -z "${HAS_PYPI}" ]; then + not_configured pypi +else + echo "🐍 Configuring pip..." + jf pipc --global --repo-resolve "${REPOSITORY_PYPI}" + mkdir -p ~/.pip + cat << EOF > ~/.pip/pip.conf +${PIP_CONF} +EOF + config_complete +fi + +# Configure Artifactory "go" repository. +if [ -z "${HAS_GO}" ]; then + not_configured go +else + echo "🐹 Configuring go..." + jf goc --global --repo-resolve "${REPOSITORY_GO}" + config_complete +fi + +# Configure the JFrog CLI to use the Artifactory "docker" repository. +if [ -z "${HAS_DOCKER}" ]; then + not_configured docker +else + if command -v docker > /dev/null 2>&1; then + echo "🔑 Configuring 🐳 docker credentials..." + mkdir -p ~/.docker + ${REGISTER_DOCKER} + else + echo "🤔 no docker is installed, skipping docker configuration." + fi +fi + +# Install the JFrog vscode extension for code-server. +if [ "${CONFIGURE_CODE_SERVER}" == "true" ]; then + while ! [ -x /tmp/code-server/bin/code-server ]; do + counter=0 + if [ $counter -eq 60 ]; then + echo "Timed out waiting for /tmp/code-server/bin/code-server to be installed." + exit 1 + fi + echo "Waiting for /tmp/code-server/bin/code-server to be installed..." + sleep 1 + ((counter++)) + done + echo "📦 Installing JFrog extension..." + /tmp/code-server/bin/code-server --install-extension jfrog.jfrog-vscode-extension + echo "🥳 JFrog extension installed!" +else + echo "🤔 Skipping JFrog extension installation. Set configure_code_server to true to install the JFrog extension." +fi + +# Configure the JFrog CLI completion +echo "📦 Configuring JFrog CLI completion..." +# Get the user's shell +SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}') +# Generate the completion script +jf completion $SHELLNAME --install +begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" +# Add the completion script to the user's shell profile +if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then + if ! grep -q "$begin_stanza" ~/.bashrc; then + printf "%s\n" "$begin_stanza" >> ~/.bashrc + echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc + echo "# END: jf CLI shell completion" >> ~/.bashrc + else + echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping." + fi +elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then + if ! grep -q "$begin_stanza" ~/.zshrc; then + printf "\n%s\n" "$begin_stanza" >> ~/.zshrc + echo "autoload -Uz compinit" >> ~/.zshrc + echo "compinit" >> ~/.zshrc + echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc + echo "# END: jf CLI shell completion" >> ~/.zshrc + else + echo "🥳 ~/.zshrc already contains jf CLI shell completion configuration, skipping." + fi +else + echo "🤔 ~/.bashrc or ~/.zshrc does not exist, skipping jf CLI shell completion configuration." +fi diff --git a/registry/coder/modules/jupyter-notebook/README.md b/registry/coder/modules/jupyter-notebook/README.md new file mode 100644 index 00000000..56f7ff18 --- /dev/null +++ b/registry/coder/modules/jupyter-notebook/README.md @@ -0,0 +1,23 @@ +--- +display_name: Jupyter Notebook +description: A module that adds Jupyter Notebook in your Coder template. +icon: ../.icons/jupyter.svg +maintainer_github: coder +verified: true +tags: [jupyter, helper, ide, web] +--- + +# Jupyter Notebook + +A module that adds Jupyter Notebook in your Coder template. + +![Jupyter Notebook](../.images/jupyter-notebook.png) + +```tf +module "jupyter-notebook" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jupyter-notebook/coder" + version = "1.0.19" + agent_id = coder_agent.example.id +} +``` diff --git a/registry/coder/modules/jupyter-notebook/main.tf b/registry/coder/modules/jupyter-notebook/main.tf new file mode 100644 index 00000000..a588ef15 --- /dev/null +++ b/registry/coder/modules/jupyter-notebook/main.tf @@ -0,0 +1,65 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "log_path" { + type = string + description = "The path to log jupyter notebook to." + default = "/tmp/jupyter-notebook.log" +} + +variable "port" { + type = number + description = "The port to run jupyter-notebook on." + default = 19999 +} + +variable "share" { + 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'." + } +} + +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 +} + +resource "coder_script" "jupyter-notebook" { + agent_id = var.agent_id + display_name = "jupyter-notebook" + icon = "/icon/jupyter.svg" + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port + }) + run_on_start = true +} + +resource "coder_app" "jupyter-notebook" { + agent_id = var.agent_id + slug = "jupyter-notebook" + display_name = "Jupyter Notebook" + url = "http://localhost:${var.port}" + icon = "/icon/jupyter.svg" + subdomain = true + share = var.share + order = var.order +} diff --git a/registry/coder/modules/jupyter-notebook/run.sh b/registry/coder/modules/jupyter-notebook/run.sh new file mode 100644 index 00000000..0c7a9b85 --- /dev/null +++ b/registry/coder/modules/jupyter-notebook/run.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env sh + +BOLD='\033[0;1m' + +printf "$${BOLD}Installing jupyter-notebook!\n" + +# check if jupyter-notebook is installed +if ! command -v jupyter-notebook > /dev/null 2>&1; then + # install jupyter-notebook + # check if pipx is installed + if ! command -v pipx > /dev/null 2>&1; then + echo "pipx is not installed" + echo "Please install pipx in your Dockerfile/VM image before using this module" + exit 1 + fi + # install jupyter notebook + pipx install -q notebook + echo "🥳 jupyter-notebook has been installed\n\n" +else + echo "🥳 jupyter-notebook is already installed\n\n" +fi + +echo "👷 Starting jupyter-notebook in background..." +echo "check logs at ${LOG_PATH}" +$HOME/.local/bin/jupyter-notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 & diff --git a/registry/coder/modules/jupyterlab/README.md b/registry/coder/modules/jupyterlab/README.md new file mode 100644 index 00000000..8c2af03f --- /dev/null +++ b/registry/coder/modules/jupyterlab/README.md @@ -0,0 +1,23 @@ +--- +display_name: JupyterLab +description: A module that adds JupyterLab in your Coder template. +icon: ../.icons/jupyter.svg +maintainer_github: coder +verified: true +tags: [jupyter, helper, ide, web] +--- + +# JupyterLab + +A module that adds JupyterLab in your Coder template. + +![JupyterLab](../.images/jupyterlab.png) + +```tf +module "jupyterlab" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jupyterlab/coder" + version = "1.0.30" + agent_id = coder_agent.example.id +} +``` diff --git a/registry/coder/modules/jupyterlab/main.test.ts b/registry/coder/modules/jupyterlab/main.test.ts new file mode 100644 index 00000000..a9789c39 --- /dev/null +++ b/registry/coder/modules/jupyterlab/main.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "bun:test"; +import { + execContainer, + executeScriptInContainer, + findResourceInstance, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + type TerraformState, +} from "../test"; + +// executes the coder script after installing pip +const executeScriptInContainerWithPip = async ( + state: TerraformState, + image: string, + shell = "sh", +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + const respPipx = await execContainer(id, [shell, "-c", "apk add pipx"]); + const resp = await execContainer(id, [shell, "-c", instance.script]); + const stdout = resp.stdout.trim().split("\n"); + const stderr = resp.stderr.trim().split("\n"); + return { + exitCode: resp.exitCode, + stdout, + stderr, + }; +}; + +// executes the coder script after installing pip +const executeScriptInContainerWithUv = async ( + state: TerraformState, + image: string, + shell = "sh", +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + const respPipx = await execContainer(id, [ + shell, + "-c", + "apk --no-cache add uv gcc musl-dev linux-headers && uv venv", + ]); + const resp = await execContainer(id, [shell, "-c", instance.script]); + const stdout = resp.stdout.trim().split("\n"); + const stderr = resp.stderr.trim().split("\n"); + return { + exitCode: resp.exitCode, + stdout, + stderr, + }; +}; + +describe("jupyterlab", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("fails without installers", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(1); + expect(output.stdout).toEqual([ + "Checking for a supported installer", + "No valid installer is not installed", + "Please install pipx or uv in your Dockerfile/VM image before running this script", + ]); + }); + + // TODO: Add faster test to run with uv. + // currently times out. + // it("runs with uv", async () => { + // const state = await runTerraformApply(import.meta.dir, { + // agent_id: "foo", + // }); + // const output = await executeScriptInContainerWithUv(state, "python:3-alpine"); + // expect(output.exitCode).toBe(0); + // expect(output.stdout).toEqual([ + // "Checking for a supported installer", + // "uv is installed", + // "\u001B[0;1mInstalling jupyterlab!", + // "🥳 jupyterlab has been installed", + // "👷 Starting jupyterlab in background...check logs at /tmp/jupyterlab.log", + // ]); + // }); + + // TODO: Add faster test to run with pipx. + // currently times out. + // it("runs with pipx", async () => { + // ... + // const output = await executeScriptInContainerWithPip(state, "alpine"); + // ... + // }); +}); diff --git a/registry/coder/modules/jupyterlab/main.tf b/registry/coder/modules/jupyterlab/main.tf new file mode 100644 index 00000000..d66edb1c --- /dev/null +++ b/registry/coder/modules/jupyterlab/main.tf @@ -0,0 +1,75 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "log_path" { + type = string + description = "The path to log jupyterlab to." + default = "/tmp/jupyterlab.log" +} + +variable "port" { + type = number + description = "The port to run jupyterlab on." + default = 19999 +} + +variable "share" { + 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'." + } +} + +variable "subdomain" { + type = bool + description = "Determines whether JupyterLab will be accessed via its own subdomain or whether it will be accessed via a path on Coder." + default = true +} + +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 +} + +resource "coder_script" "jupyterlab" { + agent_id = var.agent_id + display_name = "jupyterlab" + icon = "/icon/jupyter.svg" + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port + BASE_URL : var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab" + }) + run_on_start = true +} + +resource "coder_app" "jupyterlab" { + agent_id = var.agent_id + slug = "jupyterlab" # sync with the usage in URL + display_name = "JupyterLab" + url = var.subdomain ? "http://localhost:${var.port}" : "http://localhost:${var.port}/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab" + icon = "/icon/jupyter.svg" + subdomain = var.subdomain + share = var.share + order = var.order +} diff --git a/registry/coder/modules/jupyterlab/run.sh b/registry/coder/modules/jupyterlab/run.sh new file mode 100644 index 00000000..2dd34ace --- /dev/null +++ b/registry/coder/modules/jupyterlab/run.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env sh +INSTALLER="" +check_available_installer() { + # check if pipx is installed + echo "Checking for a supported installer" + if command -v pipx > /dev/null 2>&1; then + echo "pipx is installed" + INSTALLER="pipx" + return + fi + # check if uv is installed + if command -v uv > /dev/null 2>&1; then + echo "uv is installed" + INSTALLER="uv" + return + fi + echo "No valid installer is not installed" + echo "Please install pipx or uv in your Dockerfile/VM image before running this script" + exit 1 +} + +if [ -n "${BASE_URL}" ]; then + BASE_URL_FLAG="--ServerApp.base_url=${BASE_URL}" +fi + +BOLD='\033[0;1m' + +# check if jupyterlab is installed +if ! command -v jupyter-lab > /dev/null 2>&1; then + # install jupyterlab + check_available_installer + printf "$${BOLD}Installing jupyterlab!\n" + case $INSTALLER in + uv) + uv pip install -q jupyterlab \ + && printf "%s\n" "🥳 jupyterlab has been installed" + JUPYTERPATH="$HOME/.venv/bin/" + ;; + pipx) + pipx install jupyterlab \ + && printf "%s\n" "🥳 jupyterlab has been installed" + JUPYTERPATH="$HOME/.local/bin" + ;; + esac +else + printf "%s\n\n" "🥳 jupyterlab is already installed" +fi + +printf "👷 Starting jupyterlab in background..." +printf "check logs at ${LOG_PATH}" +$JUPYTERPATH/jupyter-lab --no-browser \ + "$BASE_URL_FLAG" \ + --ServerApp.ip='*' \ + --ServerApp.port="${PORT}" \ + --ServerApp.token='' \ + --ServerApp.password='' \ + > "${LOG_PATH}" 2>&1 & diff --git a/registry/coder/modules/kasmvnc/README.md b/registry/coder/modules/kasmvnc/README.md new file mode 100644 index 00000000..9c3b28db --- /dev/null +++ b/registry/coder/modules/kasmvnc/README.md @@ -0,0 +1,24 @@ +--- +display_name: KasmVNC +description: A modern open source VNC server +icon: ../.icons/kasmvnc.svg +maintainer_github: coder +verified: true +tags: [helper, vnc, desktop] +--- + +# KasmVNC + +Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and create an app to access it via the dashboard. + +```tf +module "kasmvnc" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/kasmvnc/coder" + version = "1.0.23" + agent_id = coder_agent.example.id + desktop_environment = "xfce" +} +``` + +> **Note:** This module only works on workspaces with a pre-installed desktop environment. As an example base image you can use `codercom/enterprise-desktop` image. diff --git a/registry/coder/modules/kasmvnc/main.test.ts b/registry/coder/modules/kasmvnc/main.test.ts new file mode 100644 index 00000000..0116d053 --- /dev/null +++ b/registry/coder/modules/kasmvnc/main.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +const allowedDesktopEnvs = ["xfce", "kde", "gnome", "lxde", "lxqt"] as const; +type AllowedDesktopEnv = (typeof allowedDesktopEnvs)[number]; + +type TestVariables = Readonly<{ + agent_id: string; + desktop_environment: AllowedDesktopEnv; + port?: string; + kasm_version?: string; +}>; + +describe("Kasm VNC", async () => { + await runTerraformInit(import.meta.dir); + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + desktop_environment: "gnome", + }); + + it("Successfully installs for all expected Kasm desktop versions", async () => { + for (const v of allowedDesktopEnvs) { + const applyWithEnv = () => { + runTerraformApply(import.meta.dir, { + agent_id: "foo", + desktop_environment: v, + }); + }; + + expect(applyWithEnv).not.toThrow(); + } + }); +}); diff --git a/registry/coder/modules/kasmvnc/main.tf b/registry/coder/modules/kasmvnc/main.tf new file mode 100644 index 00000000..4265f3c7 --- /dev/null +++ b/registry/coder/modules/kasmvnc/main.tf @@ -0,0 +1,63 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "port" { + type = number + description = "The port to run KasmVNC on." + default = 6800 +} + +variable "kasm_version" { + type = string + description = "Version of KasmVNC to install." + default = "1.3.2" +} + +variable "desktop_environment" { + type = string + description = "Specifies the desktop environment of the workspace. This should be pre-installed on the workspace." + validation { + condition = contains(["xfce", "kde", "gnome", "lxde", "lxqt"], var.desktop_environment) + error_message = "Invalid desktop environment. Please specify a valid desktop environment." + } +} + +resource "coder_script" "kasm_vnc" { + agent_id = var.agent_id + display_name = "KasmVNC" + icon = "/icon/kasmvnc.svg" + script = templatefile("${path.module}/run.sh", { + PORT : var.port, + DESKTOP_ENVIRONMENT : var.desktop_environment, + KASM_VERSION : var.kasm_version + }) + run_on_start = true +} + +resource "coder_app" "kasm_vnc" { + agent_id = var.agent_id + slug = "kasm-vnc" + display_name = "kasmVNC" + url = "http://localhost:${var.port}" + icon = "/icon/kasmvnc.svg" + subdomain = true + share = "owner" + healthcheck { + url = "http://localhost:${var.port}/app" + interval = 5 + threshold = 5 + } +} diff --git a/registry/coder/modules/kasmvnc/run.sh b/registry/coder/modules/kasmvnc/run.sh new file mode 100644 index 00000000..c285b050 --- /dev/null +++ b/registry/coder/modules/kasmvnc/run.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash + +# Exit on error, undefined variables, and pipe failures +set -euo pipefail + +# Function to check if vncserver is already installed +check_installed() { + if command -v vncserver &> /dev/null; then + echo "vncserver is already installed." + return 0 # Don't exit, just indicate it's installed + else + return 1 # Indicates not installed + fi +} + +# Function to download a file using wget, curl, or busybox as a fallback +download_file() { + local url="$1" + local output="$2" + local download_tool + + if command -v curl &> /dev/null; then + # shellcheck disable=SC2034 + download_tool=(curl -fsSL) + elif command -v wget &> /dev/null; then + # shellcheck disable=SC2034 + download_tool=(wget -q -O-) + elif command -v busybox &> /dev/null; then + # shellcheck disable=SC2034 + download_tool=(busybox wget -O-) + else + echo "ERROR: No download tool available (curl, wget, or busybox required)" + exit 1 + fi + + # shellcheck disable=SC2288 + "$${download_tool[@]}" "$url" > "$output" || { + echo "ERROR: Failed to download $url" + exit 1 + } +} + +# Function to install kasmvncserver for debian-based distros +install_deb() { + local url=$1 + local kasmdeb="/tmp/kasmvncserver.deb" + + download_file "$url" "$kasmdeb" + + CACHE_DIR="/var/lib/apt/lists/partial" + # Check if the directory exists and was modified in the last 60 minutes + if [[ ! -d "$CACHE_DIR" ]] || ! find "$CACHE_DIR" -mmin -60 -print -quit &> /dev/null; then + echo "Stale package cache, updating..." + # Update package cache with a 300-second timeout for dpkg lock + sudo apt-get -o DPkg::Lock::Timeout=300 -qq update + fi + + DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests "$kasmdeb" + rm "$kasmdeb" +} + +# Function to install kasmvncserver for rpm-based distros +install_rpm() { + local url=$1 + local kasmrpm="/tmp/kasmvncserver.rpm" + local package_manager + + if command -v dnf &> /dev/null; then + # shellcheck disable=SC2034 + package_manager=(dnf localinstall -y) + elif command -v zypper &> /dev/null; then + # shellcheck disable=SC2034 + package_manager=(zypper install -y) + elif command -v yum &> /dev/null; then + # shellcheck disable=SC2034 + package_manager=(yum localinstall -y) + elif command -v rpm &> /dev/null; then + # Do we need to manually handle missing dependencies? + # shellcheck disable=SC2034 + package_manager=(rpm -i) + else + echo "ERROR: No supported package manager available (dnf, zypper, yum, or rpm required)" + exit 1 + fi + + download_file "$url" "$kasmrpm" + + # shellcheck disable=SC2288 + sudo "$${package_manager[@]}" "$kasmrpm" || { + echo "ERROR: Failed to install $kasmrpm" + exit 1 + } + + rm "$kasmrpm" +} + +# Function to install kasmvncserver for Alpine Linux +install_alpine() { + local url=$1 + local kasmtgz="/tmp/kasmvncserver.tgz" + + download_file "$url" "$kasmtgz" + + tar -xzf "$kasmtgz" -C /usr/local/bin/ + rm "$kasmtgz" +} + +# Detect system information +if [[ ! -f /etc/os-release ]]; then + echo "ERROR: Cannot detect OS: /etc/os-release not found" + exit 1 +fi + +# shellcheck disable=SC1091 +source /etc/os-release +distro="$ID" +distro_version="$VERSION_ID" +codename="$VERSION_CODENAME" +arch="$(uname -m)" +if [[ "$ID" == "ol" ]]; then + distro="oracle" + distro_version="$${distro_version%%.*}" +elif [[ "$ID" == "fedora" ]]; then + distro_version="$(grep -oP '\(\K[\w ]+' /etc/fedora-release | tr '[:upper:]' '[:lower:]' | tr -d ' ')" +fi + +echo "Detected Distribution: $distro" +echo "Detected Version: $distro_version" +echo "Detected Codename: $codename" +echo "Detected Architecture: $arch" + +# Map arch to package arch +case "$arch" in + x86_64) + if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then + arch="amd64" + fi + ;; + aarch64) + if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then + arch="arm64" + fi + ;; + arm64) + : # This is effectively a noop + ;; + *) + echo "ERROR: Unsupported architecture: $arch" + exit 1 + ;; +esac + +# Check if vncserver is installed, and install if not +if ! check_installed; then + # Check for NOPASSWD sudo (required) + if ! command -v sudo &> /dev/null || ! sudo -n true 2> /dev/null; then + echo "ERROR: sudo NOPASSWD access required!" + exit 1 + fi + + base_url="https://github.com/kasmtech/KasmVNC/releases/download/v${KASM_VERSION}" + + echo "Installing KASM version: ${KASM_VERSION}" + case $distro in + ubuntu | debian | kali) + bin_name="kasmvncserver_$${codename}_${KASM_VERSION}_$${arch}.deb" + install_deb "$base_url/$bin_name" + ;; + oracle | fedora | opensuse) + bin_name="kasmvncserver_$${distro}_$${distro_version}_${KASM_VERSION}_$${arch}.rpm" + install_rpm "$base_url/$bin_name" + ;; + alpine) + bin_name="kasmvnc.alpine_$${distro_version//./}_$${arch}.tgz" + install_alpine "$base_url/$bin_name" + ;; + *) + echo "Unsupported distribution: $distro" + exit 1 + ;; + esac +else + echo "vncserver already installed. Skipping installation." +fi + +if command -v sudo &> /dev/null && sudo -n true 2> /dev/null; then + kasm_config_file="/etc/kasmvnc/kasmvnc.yaml" + SUDO=sudo +else + kasm_config_file="$HOME/.vnc/kasmvnc.yaml" + SUDO= + + echo "WARNING: Sudo access not available, using user config dir!" + + if [[ -f "$kasm_config_file" ]]; then + echo "WARNING: Custom user KasmVNC config exists, not overwriting!" + echo "WARNING: Ensure that you manually configure the appropriate settings." + kasm_config_file="/dev/stderr" + else + echo "WARNING: This may prevent custom user KasmVNC settings from applying!" + mkdir -p "$HOME/.vnc" + fi +fi + +echo "Writing KasmVNC config to $kasm_config_file" +$SUDO tee "$kasm_config_file" > /dev/null << EOF +network: + protocol: http + websocket_port: ${PORT} + ssl: + require_ssl: false + pem_certificate: + pem_key: + udp: + public_ip: 127.0.0.1 +EOF + +# This password is not used since we start the server without auth. +# The server is protected via the Coder session token / tunnel +# and does not listen publicly +echo -e "password\npassword\n" | vncpasswd -wo -u "$USER" + +# Start the server +printf "🚀 Starting KasmVNC server...\n" +vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > /tmp/kasmvncserver.log 2>&1 & +pid=$! + +# Wait for server to start +sleep 5 +grep -v '^[[:space:]]*$' /tmp/kasmvncserver.log | tail -n 10 +if ps -p $pid | grep -q "^$pid"; then + echo "ERROR: Failed to start KasmVNC server. Check full logs at /tmp/kasmvncserver.log" + exit 1 +fi +printf "🚀 KasmVNC server started successfully!\n" diff --git a/registry/coder/modules/personalize/README.md b/registry/coder/modules/personalize/README.md new file mode 100644 index 00000000..af307f1b --- /dev/null +++ b/registry/coder/modules/personalize/README.md @@ -0,0 +1,21 @@ +--- +display_name: Personalize +description: Allow developers to customize their workspace on start +icon: ../.icons/personalize.svg +maintainer_github: coder +verified: true +tags: [helper] +--- + +# Personalize + +Run a script on workspace start that allows developers to run custom commands to personalize their workspace. + +```tf +module "personalize" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/personalize/coder" + version = "1.0.2" + agent_id = coder_agent.example.id +} +``` diff --git a/registry/coder/modules/personalize/main.test.ts b/registry/coder/modules/personalize/main.test.ts new file mode 100644 index 00000000..b499a0b7 --- /dev/null +++ b/registry/coder/modules/personalize/main.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("personalize", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("warns without personalize script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "✨ \u001b[0;1mYou don't have a personalize script!", + "", + "Run \u001b[36;40;1mtouch ~/personalize && chmod +x ~/personalize\u001b[0m to create one.", + "It will run every time your workspace starts. Use it to install personal packages!", + ]); + }); +}); diff --git a/registry/coder/modules/personalize/main.tf b/registry/coder/modules/personalize/main.tf new file mode 100644 index 00000000..9de4b789 --- /dev/null +++ b/registry/coder/modules/personalize/main.tf @@ -0,0 +1,39 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "path" { + type = string + description = "The path to a script that will be ran on start enabling a user to personalize their workspace." + default = "~/personalize" +} + +variable "log_path" { + type = string + description = "The path to a log file that will contain the output of the personalize script." + default = "~/personalize.log" +} + +resource "coder_script" "personalize" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + PERSONALIZE_PATH : var.path, + }) + display_name = "Personalize" + icon = "/icon/personalize.svg" + log_path = var.log_path + run_on_start = true + start_blocks_login = true +} diff --git a/registry/coder/modules/personalize/run.sh b/registry/coder/modules/personalize/run.sh new file mode 100644 index 00000000..dacaf486 --- /dev/null +++ b/registry/coder/modules/personalize/run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' +CODE='\033[36;40;1m' +RESET='\033[0m' +SCRIPT="${PERSONALIZE_PATH}" +SCRIPT="$${SCRIPT/#\~/$${HOME}}" + +# If the personalize script doesn't exist, educate +# the user how they can customize their environment! +if [ ! -f $SCRIPT ]; then + printf "✨ $${BOLD}You don't have a personalize script!\n\n" + printf "Run $${CODE}touch $${SCRIPT} && chmod +x $${SCRIPT}$${RESET} to create one.\n" + printf "It will run every time your workspace starts. Use it to install personal packages!\n\n" + exit 0 +fi + +# Check if the personalize script is executable, if not, +# try to make it executable and educate the user if it fails. +if [ ! -x $SCRIPT ]; then + echo "🔐 Your personalize script isn't executable!" + printf "Run $CODE\`chmod +x $SCRIPT\`$RESET to make it executable.\n" + exit 0 +fi + +# Run the personalize script! +$SCRIPT diff --git a/registry/coder/modules/slackme/README.md b/registry/coder/modules/slackme/README.md new file mode 100644 index 00000000..f686b866 --- /dev/null +++ b/registry/coder/modules/slackme/README.md @@ -0,0 +1,85 @@ +--- +display_name: Slack Me +description: Send a Slack message when a command finishes inside a workspace! +icon: ../.icons/slack.svg +maintainer_github: coder +verified: true +tags: [helper] +--- + +# Slack Me + +Add the `slackme` command to your workspace that DMs you on Slack when your command finishes running. + +```bash +slackme npm run long-build +``` + +## Setup + +1. Navigate to [Create a Slack App](https://api.slack.com/apps?new_app=1) and select "From an app manifest". Select a workspace and paste in the following manifest, adjusting the redirect URL to your Coder deployment: + + ```json + { + "display_information": { + "name": "Command Notify", + "description": "Notify developers when commands finish running inside Coder!", + "background_color": "#1b1b1c" + }, + "features": { + "bot_user": { + "display_name": "Command Notify" + } + }, + "oauth_config": { + "redirect_urls": [ + "https:///external-auth/slack/callback" + ], + "scopes": { + "bot": ["chat:write"] + } + } + } + ``` + +2. In the "Basic Information" tab on the left after creating your app, scroll down to the "App Credentials" section. Set the following environment variables in your Coder deployment: + + ```env + CODER_EXTERNAL_AUTH_1_TYPE=slack + CODER_EXTERNAL_AUTH_1_SCOPES="chat:write" + CODER_EXTERNAL_AUTH_1_DISPLAY_NAME="Slack Me" + CODER_EXTERNAL_AUTH_1_CLIENT_ID=" + CODER_EXTERNAL_AUTH_1_CLIENT_SECRET="" + ``` + +3. Restart your Coder deployment. Any Template can now import the Slack Me module, and `slackme` will be available on the `$PATH`: + + ```tf + module "slackme" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/slackme/coder" + version = "1.0.2" + agent_id = coder_agent.example.id + auth_provider_id = "slack" + } + ``` + +## Examples + +### Custom Slack Message + +- `$COMMAND` is replaced with the command the user executed. +- `$DURATION` is replaced with a human-readable duration the command took to execute. + +```tf +module "slackme" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/slackme/coder" + version = "1.0.2" + agent_id = coder_agent.example.id + auth_provider_id = "slack" + slack_message = < { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + auth_provider_id: "foo", + }); + + it("writes to path as executable", async () => { + const { instance, id } = await setupContainer(); + await writeCoder(id, "exit 0"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, ["sh", "-c", "which slackme"]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout.trim()).toEqual("/usr/bin/slackme"); + }); + + it("prints usage with no command", async () => { + const { instance, id } = await setupContainer(); + await writeCoder(id, "echo 👋"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, ["sh", "-c", "slackme"]); + expect(exec.stdout.trim()).toStartWith( + "slackme — Send a Slack notification when a command finishes", + ); + }); + + it("displays url when not authenticated", async () => { + const { instance, id } = await setupContainer(); + await writeCoder(id, "echo 'some-url' && exit 1"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, ["sh", "-c", "slackme echo test"]); + expect(exec.stdout.trim()).toEndWith("some-url"); + }); + + it("default output", async () => { + await assertSlackMessage({ + command: "echo test", + durationMS: 2, + output: "👨‍💻 `echo test` completed in 2ms", + }); + }); + + it("formats multiline message", async () => { + await assertSlackMessage({ + command: "echo test", + format: `this command: +\`$COMMAND\` +executed`, + output: `this command: +\`echo test\` +executed`, + }); + }); + + it("formats execution with milliseconds", async () => { + await assertSlackMessage({ + command: "echo test", + format: "$COMMAND took $DURATION", + durationMS: 150, + output: "echo test took 150ms", + }); + }); + + it("formats execution with seconds", async () => { + await assertSlackMessage({ + command: "echo test", + format: "$COMMAND took $DURATION", + durationMS: 15000, + output: "echo test took 15.0s", + }); + }); + + it("formats execution with minutes", async () => { + await assertSlackMessage({ + command: "echo test", + format: "$COMMAND took $DURATION", + durationMS: 120000, + output: "echo test took 2m 0.0s", + }); + }); + + it("formats execution with hours", async () => { + await assertSlackMessage({ + command: "echo test", + format: "$COMMAND took $DURATION", + durationMS: 60000 * 60, + output: "echo test took 1hr 0m 0.0s", + }); + }); +}); + +const setupContainer = async ( + image = "alpine", + vars: Record = {}, +) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + auth_provider_id: "foo", + ...vars, + }); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + return { id, instance }; +}; + +const assertSlackMessage = async (opts: { + command: string; + format?: string; + durationMS?: number; + output: string; +}) => { + // Have to use non-null assertion because TS can't tell when the fetch + // function will run + let url!: URL; + + const fakeSlackHost = serve({ + fetch: (req) => { + url = new URL(req.url); + if (url.pathname === "/api/chat.postMessage") + return createJSONResponse({ + ok: true, + }); + return createJSONResponse({}, 404); + }, + port: 0, + }); + + const { instance, id } = await setupContainer( + "alpine/curl", + opts.format ? { slack_message: opts.format } : undefined, + ); + + await writeCoder(id, "echo 'token'"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + + exec = await execContainer(id, [ + "sh", + "-c", + `DURATION_MS=${opts.durationMS || 0} SLACK_URL="http://${ + fakeSlackHost.hostname + }:${fakeSlackHost.port}" slackme ${opts.command}`, + ]); + + expect(exec.stderr.trim()).toBe(""); + expect(url.pathname).toEqual("/api/chat.postMessage"); + expect(url.searchParams.get("channel")).toEqual("token"); + expect(url.searchParams.get("text")).toEqual(opts.output); +}; diff --git a/registry/coder/modules/slackme/main.tf b/registry/coder/modules/slackme/main.tf new file mode 100644 index 00000000..5fe948ec --- /dev/null +++ b/registry/coder/modules/slackme/main.tf @@ -0,0 +1,46 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "auth_provider_id" { + type = string + description = "The ID of an external auth provider." +} + +variable "slack_message" { + type = string + description = "The message to send to Slack." + default = "👨‍💻 `$COMMAND` completed in $DURATION" +} + +resource "coder_script" "install_slackme" { + agent_id = var.agent_id + display_name = "install_slackme" + run_on_start = true + script = < $CODER_DIR/slackme < + +Example: slackme npm run long-build +EOF +} + +pretty_duration() { + local duration_ms=$1 + + # If the duration is less than 1 second, display in milliseconds + if [ $duration_ms -lt 1000 ]; then + echo "$${duration_ms}ms" + return + fi + + # Convert the duration to seconds + local duration_sec=$((duration_ms / 1000)) + local remaining_ms=$((duration_ms % 1000)) + + # If the duration is less than 1 minute, display in seconds (with ms) + if [ $duration_sec -lt 60 ]; then + echo "$${duration_sec}.$${remaining_ms}s" + return + fi + + # Convert the duration to minutes + local duration_min=$((duration_sec / 60)) + local remaining_sec=$((duration_sec % 60)) + + # If the duration is less than 1 hour, display in minutes and seconds + if [ $duration_min -lt 60 ]; then + echo "$${duration_min}m $${remaining_sec}.$${remaining_ms}s" + return + fi + + # Convert the duration to hours + local duration_hr=$((duration_min / 60)) + local remaining_min=$((duration_min % 60)) + + # Display in hours, minutes, and seconds + echo "$${duration_hr}hr $${remaining_min}m $${remaining_sec}.$${remaining_ms}s" +} + +if [ $# -eq 0 ]; then + usage + exit 1 +fi + +BOT_TOKEN=$(coder external-auth access-token $PROVIDER_ID) +if [ $? -ne 0 ]; then + printf "Authenticate with Slack to be notified when a command finishes:\n$BOT_TOKEN\n" + exit 1 +fi + +USER_ID=$(coder external-auth access-token $PROVIDER_ID --extra "authed_user.id") +if [ $? -ne 0 ]; then + printf "Failed to get authenticated user ID:\n$USER_ID\n" + exit 1 +fi + +START=$(date +%s%N) +# Run all arguments as a command +$@ +END=$(date +%s%N) +DURATION_MS=$${DURATION_MS:-$(((END - START) / 1000000))} +PRETTY_DURATION=$(pretty_duration $DURATION_MS) + +set -e +COMMAND=$(echo $@) +SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$COMMAND|$COMMAND|g") +SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$DURATION|$PRETTY_DURATION|g") + +curl --silent -o /dev/null --header "Authorization: Bearer $BOT_TOKEN" \ + -G --data-urlencode "text=$${SLACK_MESSAGE}" \ + "$SLACK_URL/api/chat.postMessage?channel=$USER_ID&pretty=1" diff --git a/registry/coder/modules/vault-github/README.md b/registry/coder/modules/vault-github/README.md new file mode 100644 index 00000000..f801c193 --- /dev/null +++ b/registry/coder/modules/vault-github/README.md @@ -0,0 +1,83 @@ +--- +display_name: Hashicorp Vault Integration (GitHub) +description: Authenticates with Vault using GitHub +icon: ../.icons/vault.svg +maintainer_github: coder +partner_github: hashicorp +verified: true +tags: [helper, integration, vault, github] +--- + +# Hashicorp Vault Integration (GitHub) + +This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces using [external auth](https://coder.com/docs/v2/latest/admin/external-auth) for GitHub. + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-github/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" +} +``` + +Then you can use the Vault CLI in your workspaces to fetch secrets from Vault: + +```shell +vault kv get -namespace=coder -mount=secrets coder +``` + +or using the Vault API: + +```shell +curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder" +``` + +![Vault login](../.images/vault-login.png) + +## Configuration + +To configure the Vault module, you must set up a Vault GitHub auth method. See the [Vault documentation](https://www.vaultproject.io/docs/auth/github) for more information. + +## Examples + +### Configure Vault integration with a different Coder GitHub external auth ID (i.e., not the default `github`) + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-github/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + coder_github_auth_id = "my-github-auth-id" +} +``` + +### Configure Vault integration with a different Coder GitHub external auth ID and a different Vault GitHub auth path + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-github/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + coder_github_auth_id = "my-github-auth-id" + vault_github_auth_path = "my-github-auth-path" +} +``` + +### Configure Vault integration and install a specific version of the Vault CLI + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-github/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_cli_version = "1.15.0" +} +``` diff --git a/registry/coder/modules/vault-github/main.test.ts b/registry/coder/modules/vault-github/main.test.ts new file mode 100644 index 00000000..25934c85 --- /dev/null +++ b/registry/coder/modules/vault-github/main.test.ts @@ -0,0 +1,11 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "../test"; + +describe("vault-github", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + vault_addr: "foo", + }); +}); diff --git a/registry/coder/modules/vault-github/main.tf b/registry/coder/modules/vault-github/main.tf new file mode 100644 index 00000000..286025a0 --- /dev/null +++ b/registry/coder/modules/vault-github/main.tf @@ -0,0 +1,68 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12.4" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "vault_addr" { + type = string + description = "The address of the Vault server." +} + +variable "coder_github_auth_id" { + type = string + description = "The ID of the GitHub external auth." + default = "github" +} + +variable "vault_github_auth_path" { + type = string + description = "The path to the GitHub auth method." + default = "github" +} + +variable "vault_cli_version" { + type = string + description = "The version of Vault to install." + default = "latest" + validation { + condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version)) + error_message = "Vault version must be in the format 0.0.0 or latest" + } +} + +data "coder_workspace" "me" {} + +resource "coder_script" "vault" { + agent_id = var.agent_id + display_name = "Vault (GitHub)" + icon = "/icon/vault.svg" + script = templatefile("${path.module}/run.sh", { + AUTH_PATH : var.vault_github_auth_path, + GITHUB_EXTERNAL_AUTH_ID : data.coder_external_auth.github.id, + INSTALL_VERSION : var.vault_cli_version, + }) + run_on_start = true + start_blocks_login = true +} + +resource "coder_env" "vault_addr" { + agent_id = var.agent_id + name = "VAULT_ADDR" + value = var.vault_addr +} + +data "coder_external_auth" "github" { + id = var.coder_github_auth_id +} diff --git a/registry/coder/modules/vault-github/run.sh b/registry/coder/modules/vault-github/run.sh new file mode 100644 index 00000000..8ca96c0e --- /dev/null +++ b/registry/coder/modules/vault-github/run.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash + +# Convert all templated variables to shell variables +INSTALL_VERSION=${INSTALL_VERSION} +GITHUB_EXTERNAL_AUTH_ID=${GITHUB_EXTERNAL_AUTH_ID} +AUTH_PATH=${AUTH_PATH} + +fetch() { + dest="$1" + url="$2" + if command -v curl > /dev/null 2>&1; then + curl -sSL --fail "$${url}" -o "$${dest}" + elif command -v wget > /dev/null 2>&1; then + wget -O "$${dest}" "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -O "$${dest}" "$${url}" + else + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + exit 1 + fi +} + +unzip_safe() { + if command -v unzip > /dev/null 2>&1; then + command unzip "$@" + elif command -v busybox > /dev/null 2>&1; then + busybox unzip "$@" + else + printf "unzip or busybox is not installed. Please install unzip in your image.\n" + exit 1 + fi +} + +install() { + # Get the architecture of the system + ARCH=$(uname -m) + if [ "$${ARCH}" = "x86_64" ]; then + ARCH="amd64" + elif [ "$${ARCH}" = "aarch64" ]; then + ARCH="arm64" + else + printf "Unsupported architecture: $${ARCH}\n" + return 1 + fi + # Fetch the latest version of Vault if INSTALL_VERSION is 'latest' + if [ "$${INSTALL_VERSION}" = "latest" ]; then + LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1) + printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}" + if [ -z "$${LATEST_VERSION}" ]; then + printf "Failed to determine the latest Vault version.\n" + return 1 + fi + INSTALL_VERSION=$${LATEST_VERSION} + fi + + # Check if the vault CLI is installed and has the correct version + installation_needed=1 + if command -v vault > /dev/null 2>&1; then + CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ "$${CURRENT_VERSION}" = "$${INSTALL_VERSION}" ]; then + printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}" + installation_needed=0 + fi + fi + + if [ $${installation_needed} -eq 1 ]; then + # Download and install Vault + if [ -z "$${CURRENT_VERSION}" ]; then + printf "Installing Vault CLI ...\n\n" + else + printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${INSTALL_VERSION}" + fi + fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_$${ARCH}.zip" + if [ $? -ne 0 ]; then + printf "Failed to download Vault.\n" + return 1 + fi + if ! unzip_safe vault.zip; then + printf "Failed to unzip Vault.\n" + return 1 + fi + rm vault.zip + if sudo mv vault /usr/local/bin/vault 2> /dev/null; then + printf "Vault installed successfully!\n\n" + else + mkdir -p ~/.local/bin + if ! mv vault ~/.local/bin/vault; then + printf "Failed to move Vault to local bin.\n" + return 1 + fi + printf "Please add ~/.local/bin to your PATH to use vault CLI.\n" + fi + fi + return 0 +} + +TMP=$(mktemp -d) +if ! ( + cd "$TMP" + install +); then + echo "Failed to install Vault CLI." + exit 1 +fi +rm -rf "$TMP" + +# Authenticate with Vault +printf "🔑 Authenticating with Vault ...\n\n" +GITHUB_TOKEN=$(coder external-auth access-token "$${GITHUB_EXTERNAL_AUTH_ID}") +if [ $? -ne 0 ]; then + printf "Authentication with Vault failed. Please check your credentials.\n" + exit 1 +fi + +# Login to vault using the GitHub token +printf "🔑 Logging in to Vault ...\n\n" +vault login -no-print -method=github -path=/$${AUTH_PATH} token="$${GITHUB_TOKEN}" +printf "🥳 Vault authentication complete!\n\n" +printf "You can now use Vault CLI to access secrets.\n" diff --git a/registry/coder/modules/vault-jwt/README.md b/registry/coder/modules/vault-jwt/README.md new file mode 100644 index 00000000..66070397 --- /dev/null +++ b/registry/coder/modules/vault-jwt/README.md @@ -0,0 +1,81 @@ +--- +display_name: Hashicorp Vault Integration (JWT) +description: Authenticates with Vault using a JWT from Coder's OIDC provider +icon: ../.icons/vault.svg +maintainer_github: coder +partner_github: hashicorp +verified: true +tags: [helper, integration, vault, jwt, oidc] +--- + +# Hashicorp Vault Integration (JWT) + +This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces by reusing the [OIDC](https://coder.com/docs/admin/users/oidc-auth) access token from Coder's OIDC authentication method. This requires configuring the Vault [JWT/OIDC](https://developer.hashicorp.com/vault/docs/auth/jwt#configuration) auth method. + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.20" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_role = "coder" # The Vault role to use for authentication +} +``` + +Then you can use the Vault CLI in your workspaces to fetch secrets from Vault: + +```shell +vault kv get -namespace=coder -mount=secrets coder +``` + +or using the Vault API: + +```shell +curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder" +``` + +## Examples + +### Configure Vault integration with a non standard auth path (default is "jwt") + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.20" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_auth_path = "oidc" + vault_jwt_role = "coder" # The Vault role to use for authentication +} +``` + +### Map workspace owner's group to a Vault role + +```tf +data "coder_workspace_owner" "me" {} + +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.20" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_role = data.coder_workspace_owner.me.groups[0] +} +``` + +### Install a specific version of the Vault CLI + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.20" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_role = "coder" # The Vault role to use for authentication + vault_cli_version = "1.17.5" +} +``` diff --git a/registry/coder/modules/vault-jwt/main.test.ts b/registry/coder/modules/vault-jwt/main.test.ts new file mode 100644 index 00000000..2fda3d7c --- /dev/null +++ b/registry/coder/modules/vault-jwt/main.test.ts @@ -0,0 +1,12 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "../test"; + +describe("vault-jwt", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + vault_addr: "foo", + vault_jwt_role: "foo", + }); +}); diff --git a/registry/coder/modules/vault-jwt/main.tf b/registry/coder/modules/vault-jwt/main.tf new file mode 100644 index 00000000..adcc34d4 --- /dev/null +++ b/registry/coder/modules/vault-jwt/main.tf @@ -0,0 +1,64 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12.4" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "vault_addr" { + type = string + description = "The address of the Vault server." +} + +variable "vault_jwt_auth_path" { + type = string + description = "The path to the Vault JWT auth method." + default = "jwt" +} + +variable "vault_jwt_role" { + type = string + description = "The name of the Vault role to use for authentication." +} + +variable "vault_cli_version" { + type = string + description = "The version of Vault to install." + default = "latest" + validation { + condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version)) + error_message = "Vault version must be in the format 0.0.0 or latest" + } +} + +resource "coder_script" "vault" { + agent_id = var.agent_id + display_name = "Vault (GitHub)" + icon = "/icon/vault.svg" + script = templatefile("${path.module}/run.sh", { + CODER_OIDC_ACCESS_TOKEN : data.coder_workspace_owner.me.oidc_access_token, + VAULT_JWT_AUTH_PATH : var.vault_jwt_auth_path, + VAULT_JWT_ROLE : var.vault_jwt_role, + VAULT_CLI_VERSION : var.vault_cli_version, + }) + run_on_start = true + start_blocks_login = true +} + +resource "coder_env" "vault_addr" { + agent_id = var.agent_id + name = "VAULT_ADDR" + value = var.vault_addr +} + +data "coder_workspace_owner" "me" {} diff --git a/registry/coder/modules/vault-jwt/run.sh b/registry/coder/modules/vault-jwt/run.sh new file mode 100644 index 00000000..ef45884d --- /dev/null +++ b/registry/coder/modules/vault-jwt/run.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +# Convert all templated variables to shell variables +VAULT_CLI_VERSION=${VAULT_CLI_VERSION} +VAULT_JWT_AUTH_PATH=${VAULT_JWT_AUTH_PATH} +VAULT_JWT_ROLE=${VAULT_JWT_ROLE} +CODER_OIDC_ACCESS_TOKEN=${CODER_OIDC_ACCESS_TOKEN} + +fetch() { + dest="$1" + url="$2" + if command -v curl > /dev/null 2>&1; then + curl -sSL --fail "$${url}" -o "$${dest}" + elif command -v wget > /dev/null 2>&1; then + wget -O "$${dest}" "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -O "$${dest}" "$${url}" + else + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + exit 1 + fi +} + +unzip_safe() { + if command -v unzip > /dev/null 2>&1; then + command unzip "$@" + elif command -v busybox > /dev/null 2>&1; then + busybox unzip "$@" + else + printf "unzip or busybox is not installed. Please install unzip in your image.\n" + exit 1 + fi +} + +install() { + # Get the architecture of the system + ARCH=$(uname -m) + if [ "$${ARCH}" = "x86_64" ]; then + ARCH="amd64" + elif [ "$${ARCH}" = "aarch64" ]; then + ARCH="arm64" + else + printf "Unsupported architecture: $${ARCH}\n" + return 1 + fi + # Fetch the latest version of Vault if VAULT_CLI_VERSION is 'latest' + if [ "$${VAULT_CLI_VERSION}" = "latest" ]; then + LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1) + printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}" + if [ -z "$${LATEST_VERSION}" ]; then + printf "Failed to determine the latest Vault version.\n" + return 1 + fi + VAULT_CLI_VERSION=$${LATEST_VERSION} + fi + + # Check if the vault CLI is installed and has the correct version + installation_needed=1 + if command -v vault > /dev/null 2>&1; then + CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ "$${CURRENT_VERSION}" = "$${VAULT_CLI_VERSION}" ]; then + printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}" + installation_needed=0 + fi + fi + + if [ $${installation_needed} -eq 1 ]; then + # Download and install Vault + if [ -z "$${CURRENT_VERSION}" ]; then + printf "Installing Vault CLI ...\n\n" + else + printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${VAULT_CLI_VERSION}" + fi + fetch vault.zip "https://releases.hashicorp.com/vault/$${VAULT_CLI_VERSION}/vault_$${VAULT_CLI_VERSION}_linux_$${ARCH}.zip" + if [ $? -ne 0 ]; then + printf "Failed to download Vault.\n" + return 1 + fi + if ! unzip_safe vault.zip; then + printf "Failed to unzip Vault.\n" + return 1 + fi + rm vault.zip + if sudo mv vault /usr/local/bin/vault 2> /dev/null; then + printf "Vault installed successfully!\n\n" + else + mkdir -p ~/.local/bin + if ! mv vault ~/.local/bin/vault; then + printf "Failed to move Vault to local bin.\n" + return 1 + fi + printf "Please add ~/.local/bin to your PATH to use vault CLI.\n" + fi + fi + return 0 +} + +TMP=$(mktemp -d) +if ! ( + cd "$TMP" + install +); then + echo "Failed to install Vault CLI." + exit 1 +fi +rm -rf "$TMP" + +# Authenticate with Vault +printf "🔑 Authenticating with Vault ...\n\n" +echo "$${CODER_OIDC_ACCESS_TOKEN}" | vault write auth/"$${VAULT_JWT_AUTH_PATH}"/login role="$${VAULT_JWT_ROLE}" jwt=- +printf "🥳 Vault authentication complete!\n\n" +printf "You can now use Vault CLI to access secrets.\n" diff --git a/registry/coder/modules/vault-token/README.md b/registry/coder/modules/vault-token/README.md new file mode 100644 index 00000000..7e632a59 --- /dev/null +++ b/registry/coder/modules/vault-token/README.md @@ -0,0 +1,83 @@ +--- +display_name: Hashicorp Vault Integration (Token) +description: Authenticates with Vault using Token +icon: ../.icons/vault.svg +maintainer_github: coder +partner_github: hashicorp +verified: true +tags: [helper, integration, vault, token] +--- + +# Hashicorp Vault Integration (Token) + +This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces using a [Vault token](https://developer.hashicorp.com/vault/docs/auth/token). + +```tf +variable "vault_token" { + type = string + description = "The Vault token to use for authentication." + sensitive = true +} + +module "vault" { + source = "registry.coder.com/modules/vault-token/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_token = var.token + vault_addr = "https://vault.example.com" +} +``` + +Then you can use the Vault CLI in your workspaces to fetch secrets from Vault: + +```shell +vault kv get -namespace=coder -mount=secrets coder +``` + +or using the Vault API: + +```shell +curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder" +``` + +## Configuration + +To configure the Vault module, you must create a Vault token with the the required permissions and configure the module with the token and Vault address. + +1. Create a vault policy with read access to the secret mount you need your developers to access. + ```shell + vault policy write read-coder-secrets - < { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + vault_addr: "foo", + vault_token: "foo", + }); +}); diff --git a/registry/coder/modules/vault-token/main.tf b/registry/coder/modules/vault-token/main.tf new file mode 100644 index 00000000..94517d10 --- /dev/null +++ b/registry/coder/modules/vault-token/main.tf @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12.4" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "vault_addr" { + type = string + description = "The address of the Vault server." +} + +variable "vault_token" { + type = string + description = "The Vault token to use for authentication." + sensitive = true +} + +variable "vault_cli_version" { + type = string + description = "The version of Vault to install." + default = "latest" + validation { + condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version)) + error_message = "Vault version must be in the format 0.0.0 or latest" + } +} + +data "coder_workspace" "me" {} + +resource "coder_script" "vault" { + agent_id = var.agent_id + display_name = "Vault (Token)" + icon = "/icon/vault.svg" + script = templatefile("${path.module}/run.sh", { + INSTALL_VERSION : var.vault_cli_version, + }) + run_on_start = true + start_blocks_login = true +} + +resource "coder_env" "vault_addr" { + agent_id = var.agent_id + name = "VAULT_ADDR" + value = var.vault_addr +} + +resource "coder_env" "vault_token" { + agent_id = var.agent_id + name = "VAULT_TOKEN" + value = var.vault_token +} diff --git a/registry/coder/modules/vault-token/run.sh b/registry/coder/modules/vault-token/run.sh new file mode 100644 index 00000000..e1da6ee8 --- /dev/null +++ b/registry/coder/modules/vault-token/run.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +# Convert all templated variables to shell variables +INSTALL_VERSION=${INSTALL_VERSION} + +fetch() { + dest="$1" + url="$2" + if command -v curl > /dev/null 2>&1; then + curl -sSL --fail "$${url}" -o "$${dest}" + elif command -v wget > /dev/null 2>&1; then + wget -O "$${dest}" "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -O "$${dest}" "$${url}" + else + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + return 1 + fi +} + +unzip_safe() { + if command -v unzip > /dev/null 2>&1; then + command unzip "$@" + elif command -v busybox > /dev/null 2>&1; then + busybox unzip "$@" + else + printf "unzip or busybox is not installed. Please install unzip in your image.\n" + return 1 + fi +} + +install() { + # Get the architecture of the system + ARCH=$(uname -m) + if [ "$${ARCH}" = "x86_64" ]; then + ARCH="amd64" + elif [ "$${ARCH}" = "aarch64" ]; then + ARCH="arm64" + else + printf "Unsupported architecture: $${ARCH}\n" + return 1 + fi + # Fetch the latest version of Vault if INSTALL_VERSION is 'latest' + if [ "$${INSTALL_VERSION}" = "latest" ]; then + LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1) + printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}" + if [ -z "$${LATEST_VERSION}" ]; then + printf "Failed to determine the latest Vault version.\n" + return 1 + fi + INSTALL_VERSION=$${LATEST_VERSION} + fi + + # Check if the vault CLI is installed and has the correct version + installation_needed=1 + if command -v vault > /dev/null 2>&1; then + CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ "$${CURRENT_VERSION}" = "$${INSTALL_VERSION}" ]; then + printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}" + installation_needed=0 + fi + fi + + if [ $${installation_needed} -eq 1 ]; then + # Download and install Vault + if [ -z "$${CURRENT_VERSION}" ]; then + printf "Installing Vault CLI ...\n\n" + else + printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${INSTALL_VERSION}" + fi + fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_amd64.zip" + if [ $? -ne 0 ]; then + printf "Failed to download Vault.\n" + return 1 + fi + if ! unzip_safe vault.zip; then + printf "Failed to unzip Vault.\n" + return 1 + fi + rm vault.zip + if sudo mv vault /usr/local/bin/vault 2> /dev/null; then + printf "Vault installed successfully!\n\n" + else + mkdir -p ~/.local/bin + if ! mv vault ~/.local/bin/vault; then + printf "Failed to move Vault to local bin.\n" + return 1 + fi + printf "Please add ~/.local/bin to your PATH to use vault CLI.\n" + fi + fi + return 0 +} + +TMP=$(mktemp -d) +if ! ( + cd "$TMP" + install +); then + echo "Failed to install Vault CLI." + exit 1 +fi +rm -rf "$TMP" diff --git a/registry/coder/modules/vscode-desktop/README.md b/registry/coder/modules/vscode-desktop/README.md new file mode 100644 index 00000000..e32fd9bf --- /dev/null +++ b/registry/coder/modules/vscode-desktop/README.md @@ -0,0 +1,37 @@ +--- +display_name: VS Code Desktop +description: Add a one-click button to launch VS Code Desktop +icon: ../.icons/code.svg +maintainer_github: coder +verified: true +tags: [ide, vscode, helper] +--- + +# VS Code Desktop + +Add a button to open any workspace with a single click. + +Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder). + +```tf +module "vscode" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-desktop/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "vscode" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-desktop/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` diff --git a/registry/coder/modules/vscode-desktop/main.test.ts b/registry/coder/modules/vscode-desktop/main.test.ts new file mode 100644 index 00000000..7aa144ec --- /dev/null +++ b/registry/coder/modules/vscode-desktop/main.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("vscode-desktop", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "vscode", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("adds folder", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder and open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + open_recent: "true", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder but not open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + openRecent: "false", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + open_recent: "true", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("expect order to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "vscode", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); +}); diff --git a/registry/coder/modules/vscode-desktop/main.tf b/registry/coder/modules/vscode-desktop/main.tf new file mode 100644 index 00000000..16d070b4 --- /dev/null +++ b/registry/coder/modules/vscode-desktop/main.tf @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "folder" { + type = string + description = "The folder to open in VS Code." + default = "" +} + +variable "open_recent" { + type = bool + description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." + default = false +} + +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 +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "vscode" { + agent_id = var.agent_id + external = true + icon = "/icon/code.svg" + slug = "vscode" + display_name = "VS Code Desktop" + order = var.order + url = join("", [ + "vscode://coder.coder-remote/open", + "?owner=", + data.coder_workspace_owner.me.name, + "&workspace=", + data.coder_workspace.me.name, + var.folder != "" ? join("", ["&folder=", var.folder]) : "", + var.open_recent ? "&openRecent" : "", + "&url=", + data.coder_workspace.me.access_url, + "&token=$SESSION_TOKEN", + ]) +} + +output "vscode_url" { + value = coder_app.vscode.url + description = "VS Code Desktop URL." +} diff --git a/registry/coder/modules/vscode-web/README.md b/registry/coder/modules/vscode-web/README.md new file mode 100644 index 00000000..5846c04c --- /dev/null +++ b/registry/coder/modules/vscode-web/README.md @@ -0,0 +1,86 @@ +--- +display_name: VS Code Web +description: VS Code Web - Visual Studio Code in the browser +icon: ../.icons/code.svg +maintainer_github: coder +verified: true +tags: [helper, 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. + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + accept_license = true +} +``` + +![VS Code Web with GitHub Copilot and live-share](../.images/vscode-web.gif) + +## Examples + +### Install VS Code Web to a custom folder + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + install_prefix = "/home/coder/.vscode-web" + folder = "/home/coder" + accept_license = true +} +``` + +### Install Extensions + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] + accept_license = true +} +``` + +### Pre-configure Settings + +Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file: + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + extensions = ["dracula-theme.theme-dracula"] + settings = { + "workbench.colorTheme" = "Dracula" + } + accept_license = true +} +``` + +### Pin a specific VS Code Web version + +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). + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" + accept_license = true +} +``` diff --git a/registry/coder/modules/vscode-web/main.test.ts b/registry/coder/modules/vscode-web/main.test.ts new file mode 100644 index 00000000..d8e0e68e --- /dev/null +++ b/registry/coder/modules/vscode-web/main.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test"; +import { runTerraformApply, runTerraformInit } from "../test"; + +describe("vscode-web", async () => { + await runTerraformInit(import.meta.dir); + + it("accept_license should be set to true", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "false", + }); + }; + expect(t).toThrow("Invalid value for variable"); + }); + + it("use_cached and offline can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "true", + use_cached: "true", + offline: "true", + }); + }; + expect(t).toThrow("Offline and Use Cached can not be used together"); + }); + + it("offline and extensions can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "true", + offline: "true", + extensions: '["1", "2"]', + }); + }; + expect(t).toThrow("Offline mode does not allow extensions to be installed"); + }); + + // More tests depend on shebang refactors +}); diff --git a/registry/coder/modules/vscode-web/main.tf b/registry/coder/modules/vscode-web/main.tf new file mode 100644 index 00000000..11e220cd --- /dev/null +++ b/registry/coder/modules/vscode-web/main.tf @@ -0,0 +1,198 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "port" { + type = number + description = "The port to run VS Code Web on." + default = 13338 +} + +variable "display_name" { + type = string + description = "The display name for the VS Code Web application." + default = "VS Code Web" +} + +variable "slug" { + type = string + description = "The slug for the VS Code Web application." + default = "vscode-web" +} + +variable "folder" { + type = string + description = "The folder to open in vscode-web." + default = "" +} + +variable "share" { + 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'." + } +} + +variable "log_path" { + type = string + description = "The path to log." + default = "/tmp/vscode-web.log" +} + +variable "install_prefix" { + type = string + description = "The prefix to install vscode-web to." + 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." + default = [] +} + +variable "accept_license" { + type = bool + description = "Accept the VS Code Server license. https://code.visualstudio.com/license/server" + default = false + validation { + condition = var.accept_license == true + error_message = "You must accept the VS Code license agreement by setting accept_license=true." + } +} + +variable "telemetry_level" { + type = string + description = "Set the telemetry level for VS Code Web." + default = "error" + validation { + condition = var.telemetry_level == "off" || var.telemetry_level == "crash" || var.telemetry_level == "error" || var.telemetry_level == "all" + error_message = "Incorrect value. Please set either 'off', 'crash', 'error', or 'all'." + } +} + +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 "settings" { + type = any + description = "A map of settings to apply to VS Code web." + default = {} +} + +variable "offline" { + type = bool + description = "Just run VS Code Web in the background, don't fetch it from the internet." + default = false +} + +variable "use_cached" { + type = bool + description = "Uses cached copy of VS Code Web in the background, otherwise fetches it from internet." + default = false +} + +variable "extensions_dir" { + type = string + description = "Override the directory to store extensions in." + default = "" +} + +variable "auto_install_extensions" { + type = bool + description = "Automatically install recommended extensions when VS Code Web starts." + default = false +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = true +} + +data "coder_workspace_owner" "me" {} +data "coder_workspace" "me" {} + +resource "coder_script" "vscode-web" { + agent_id = var.agent_id + display_name = "VS Code Web" + icon = "/icon/code.svg" + script = templatefile("${path.module}/run.sh", { + PORT : var.port, + LOG_PATH : var.log_path, + INSTALL_PREFIX : var.install_prefix, + EXTENSIONS : join(",", var.extensions), + TELEMETRY_LEVEL : var.telemetry_level, + // This is necessary otherwise the quotes are stripped! + SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), + OFFLINE : var.offline, + USE_CACHED : var.use_cached, + EXTENSIONS_DIR : var.extensions_dir, + FOLDER : var.folder, + AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, + SERVER_BASE_PATH : local.server_base_path, + COMMIT_ID : var.commit_id, + }) + run_on_start = true + + lifecycle { + precondition { + condition = !var.offline || length(var.extensions) == 0 + error_message = "Offline mode does not allow extensions to be installed" + } + + precondition { + condition = !var.offline || !var.use_cached + error_message = "Offline and Use Cached can not be used together" + } + } +} + +resource "coder_app" "vscode-web" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = local.url + icon = "/icon/code.svg" + subdomain = var.subdomain + share = var.share + order = var.order + + healthcheck { + url = local.healthcheck_url + interval = 5 + threshold = 6 + } +} + +locals { + server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) + url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}" + healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz" +} diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh new file mode 100644 index 00000000..588cec56 --- /dev/null +++ b/registry/coder/modules/vscode-web/run.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' +EXTENSIONS=("${EXTENSIONS}") +VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server" + +# Set extension directory +EXTENSION_ARG="" +if [ -n "${EXTENSIONS_DIR}" ]; then + EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" +fi + +# Set extension directory +SERVER_BASE_PATH_ARG="" +if [ -n "${SERVER_BASE_PATH}" ]; then + SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}" +fi + +run_vscode_web() { + echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_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" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&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 + 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 + +# Create install prefix +mkdir -p ${INSTALL_PREFIX} + +printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n" + +# Download and extract vscode-server +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="x64" ;; + aarch64) ARCH="arm64" ;; + *) + echo "Unsupported architecture" + exit 1 + ;; +esac + +# 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-linux-$ARCH-web | cut -d '"' -f 2) +fi +printf "$${BOLD}VS Code Web commit id version $HASH.\n" + +output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-linux-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1) + +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" + +# Install each extension... +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=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force) + if [ $? -ne 0 ]; then + echo "Failed to install extension: $extension: $output" + 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 + 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" + # Use sed to remove single-line comments before parsing with jq + extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]') + for extension in $extensions; do + $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force + done + fi + fi +fi + +run_vscode_web diff --git a/registry/coder/modules/windows-rdp/README.md b/registry/coder/modules/windows-rdp/README.md new file mode 100644 index 00000000..b069f5e3 --- /dev/null +++ b/registry/coder/modules/windows-rdp/README.md @@ -0,0 +1,57 @@ +--- +display_name: Windows RDP +description: RDP Server and Web Client, powered by Devolutions Gateway +icon: ../.icons/desktop.svg +maintainer_github: coder +verified: true +tags: [windows, rdp, web, desktop] +--- + +# Windows RDP + +Enable Remote Desktop + a web based client on Windows workspaces, powered by [devolutions-gateway](https://github.com/Devolutions/devolutions-gateway). + +```tf +# AWS example. See below for examples of using this module with other providers +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windows-rdp/coder" + version = "1.0.18" + agent_id = resource.coder_agent.main.id + resource_id = resource.aws_instance.dev.id +} +``` + +## Video + +[![Video](./video-thumbnails/video-thumbnail.png)](https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a4be02) + +## Examples + +### With AWS + +```tf +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windows-rdp/coder" + version = "1.0.18" + agent_id = resource.coder_agent.main.id + resource_id = resource.aws_instance.dev.id +} +``` + +### With Google Cloud + +```tf +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windows-rdp/coder" + version = "1.0.18" + agent_id = resource.coder_agent.main.id + resource_id = resource.google_compute_instance.dev[0].id +} +``` + +## Roadmap + +- [ ] Test on Microsoft Azure. diff --git a/registry/coder/modules/windows-rdp/devolutions-patch.js b/registry/coder/modules/windows-rdp/devolutions-patch.js new file mode 100644 index 00000000..020a40f1 --- /dev/null +++ b/registry/coder/modules/windows-rdp/devolutions-patch.js @@ -0,0 +1,409 @@ +// @ts-check +/** + * @file Defines the custom logic for patching in UI changes/behavior into the + * base Devolutions Gateway Angular app. + * + * Defined as a JS file to remove the need to have a separate compilation step. + * It is highly recommended that you work on this file from within VS Code so + * that you can take advantage of the @ts-check directive and get some type- + * checking still. + * + * Other notes about the weird ways this file is set up: + * - A lot of the HTML selectors in this file will look nonstandard. This is + * because they are actually custom Angular components. + * - It is strongly advised that you avoid template literals that use the + * placeholder syntax via the dollar sign. The Terraform file is treating this + * as a template file, and because it also uses a similar syntax, there's a + * risk that some values will trigger false positives. If a template literal + * must be used, be sure to use a double dollar sign to escape things. + * - All the CSS should be written via custom style tags and the !important + * directive (as much as that is a bad idea most of the time). We do not + * control the Angular app, so we have to modify things from afar to ensure + * that as Angular's internal state changes, it doesn't modify its HTML nodes + * in a way that causes our custom styles to get wiped away. + * + * @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry + * @typedef {Readonly>} FormFieldEntries + */ + +/** + * The communication protocol to set Devolutions to. + */ +const PROTOCOL = "RDP"; + +/** + * The hostname to use with Devolutions. + */ +const HOSTNAME = "localhost"; + +/** + * How often to poll the screen for the main Devolutions form. + */ +const SCREEN_POLL_INTERVAL_MS = 500; + +/** + * The fields in the Devolutions sign-in form that should be populated with + * values from the Coder workspace. + * + * All properties should be defined as placeholder templates in the form + * VALUE_NAME. The Coder module, when spun up, should then run some logic to + * replace the template slots with actual values. These values should never + * change from within JavaScript itself. + * + * @satisfies {FormFieldEntries} + */ +const formFieldEntries = { + /** @readonly */ + username: { + /** @readonly */ + querySelector: "web-client-username-control input", + + /** @readonly */ + value: "${CODER_USERNAME}", + }, + + /** @readonly */ + password: { + /** @readonly */ + querySelector: "web-client-password-control input", + + /** @readonly */ + value: "${CODER_PASSWORD}", + }, +}; + +/** + * Handles typing in the values for the input form. All values are written + * immediately, even though that would be physically impossible with a real + * keyboard. + * + * Note: this code will never break, but you might get warnings in the console + * from Angular about unexpected value changes. Angular patches over a lot of + * the built-in browser APIs to support its component change detection system. + * As part of that, it has validations for checking whether an input it + * previously had control over changed without it doing anything. + * + * But the only way to simulate a keyboard input is by setting the input's + * .value property, and then firing an input event. So basically, the inner + * value will change, which Angular won't be happy about, but then the input + * event will fire and sync everything back together. + * + * @param {HTMLInputElement} inputField + * @param {string} inputText + * @returns {Promise} + */ +function setInputValue(inputField, inputText) { + return new Promise((resolve, reject) => { + // Adding timeout for input event, even though we'll be dispatching it + // immediately, just in the off chance that something in the Angular app + // intercepts it or stops it from propagating properly + const timeoutId = window.setTimeout(() => { + reject(new Error("Input event did not get processed correctly in time.")); + }, 3_000); + + const handleSuccessfulDispatch = () => { + window.clearTimeout(timeoutId); + inputField.removeEventListener("input", handleSuccessfulDispatch); + resolve(); + }; + + inputField.addEventListener("input", handleSuccessfulDispatch); + + // Code assumes that Angular will have an event handler in place to handle + // the new event + const inputEvent = new Event("input", { + bubbles: true, + cancelable: true, + }); + + inputField.value = inputText; + inputField.dispatchEvent(inputEvent); + }); +} + +/** + * Takes a Devolutions remote session form, auto-fills it with data, and then + * submits it. + * + * The logic here is more convoluted than it should be for two main reasons: + * 1. Devolutions' HTML markup has errors. There are labels, but they aren't + * bound to the inputs they're supposed to describe. This means no easy hooks + * for selecting the elements, unfortunately. + * 2. Trying to modify the .value properties on some of the inputs doesn't + * work. Probably some combo of Angular data-binding and some inputs having + * the readonly attribute. Have to simulate user input to get around this. + * + * @param {HTMLFormElement} myForm + * @returns {Promise} + */ +async function autoSubmitForm(myForm) { + const setProtocolValue = () => { + /** @type {HTMLDivElement | null} */ + const protocolDropdownTrigger = myForm.querySelector('div[role="button"]'); + if (protocolDropdownTrigger === null) { + throw new Error("No clickable trigger for setting protocol value"); + } + + protocolDropdownTrigger.click(); + + // Can't use form as container for querying the list of dropdown options, + // because the elements don't actually exist inside the form. They're placed + // in the top level of the HTML doc, and repositioned to make it look like + // they're part of the form. Avoids CSS stacking context issues, maybe? + /** @type {HTMLLIElement | null} */ + const protocolOption = document.querySelector( + 'p-dropdownitem[ng-reflect-label="' + PROTOCOL + '"] li', + ); + + if (protocolOption === null) { + throw new Error( + "Unable to find protocol option on screen that matches desired protocol", + ); + } + + protocolOption.click(); + }; + + const setHostname = () => { + /** @type {HTMLInputElement | null} */ + const hostnameInput = myForm.querySelector("p-autocomplete#hostname input"); + + if (hostnameInput === null) { + throw new Error("Unable to find field for adding hostname"); + } + + return setInputValue(hostnameInput, HOSTNAME); + }; + + const setCoderFormFieldValues = async () => { + // The RDP form will not appear on screen unless the dropdown is set to use + // the RDP protocol + const rdpSubsection = myForm.querySelector("rdp-form"); + if (rdpSubsection === null) { + throw new Error( + "Unable to find RDP subsection. Is the value of the protocol set to RDP?", + ); + } + + for (const { value, querySelector } of Object.values(formFieldEntries)) { + /** @type {HTMLInputElement | null} */ + const input = document.querySelector(querySelector); + + if (input === null) { + throw new Error( + 'Unable to element that matches query "' + querySelector + '"', + ); + } + + await setInputValue(input, value); + } + }; + + const triggerSubmission = () => { + /** @type {HTMLButtonElement | null} */ + const submitButton = myForm.querySelector( + 'p-button[ng-reflect-type="submit"] button', + ); + + if (submitButton === null) { + throw new Error("Unable to find submission button"); + } + + if (submitButton.disabled) { + throw new Error( + "Unable to submit form because submit button is disabled. Are all fields filled out correctly?", + ); + } + + submitButton.click(); + }; + + setProtocolValue(); + await setHostname(); + await setCoderFormFieldValues(); + triggerSubmission(); +} + +/** + * Sets up logic for auto-populating the form data when the form appears on + * screen. + * + * @returns {void} + */ +function setupFormDetection() { + /** @type {HTMLFormElement | null} */ + let formValueFromLastMutation = null; + + /** @returns {void} */ + const onDynamicTabMutation = () => { + /** @type {HTMLFormElement | null} */ + const latestForm = document.querySelector("web-client-form > form"); + + // Only try to auto-fill if we went from having no form on screen to + // having a form on screen. That way, we don't accidentally override the + // form if the user is trying to customize values, and this essentially + // makes the script values function as default values + const mounted = formValueFromLastMutation === null && latestForm !== null; + if (mounted) { + autoSubmitForm(latestForm); + } + + formValueFromLastMutation = latestForm; + }; + + /** @type {number | undefined} */ + let pollingId = undefined; + + /** @returns {void} */ + const checkScreenForDynamicTab = () => { + const dynamicTab = document.querySelector("web-client-dynamic-tab"); + + // Keep polling until the main content container is on screen + if (dynamicTab === null) { + return; + } + + window.clearInterval(pollingId); + + // Call the mutation callback manually, to ensure it runs at least once + onDynamicTabMutation(); + + // Having the mutation observer is kind of an extra safety net that isn't + // really expected to run that often. Most of the content in the dynamic + // tab is being rendered through Canvas, which won't trigger any mutations + // that the observer can detect + const dynamicTabObserver = new MutationObserver(onDynamicTabMutation); + dynamicTabObserver.observe(dynamicTab, { + subtree: true, + childList: true, + }); + }; + + pollingId = window.setInterval( + checkScreenForDynamicTab, + SCREEN_POLL_INTERVAL_MS, + ); +} + +/** + * Sets up custom styles for hiding default Devolutions elements that Coder + * users shouldn't need to care about. + * + * @returns {void} + */ +function setupAlwaysOnStyles() { + const styleId = "coder-patch--styles-always-on"; + const existingContainer = document.querySelector("#" + styleId); + if (existingContainer) { + return; + } + + const styleContainer = document.createElement("style"); + styleContainer.id = styleId; + styleContainer.innerHTML = ` + /* app-menu corresponds to the sidebar of the default view. */ + app-menu { + display: none !important; + } + `; + + document.head.appendChild(styleContainer); +} + +function hideFormForInitialSubmission() { + const styleId = "coder-patch--styles-initial-submission"; + const cssOpacityVariableName = "--coder-opacity-multiplier"; + + /** @type {HTMLStyleElement | null} */ + let styleContainer = document.querySelector("#" + styleId); + if (!styleContainer) { + styleContainer = document.createElement("style"); + styleContainer.id = styleId; + styleContainer.innerHTML = ` + /* + Have to use opacity instead of visibility, because the element still + needs to be interactive via the script so that it can be auto-filled. + */ + :root { + /* + Can be 0 or 1. Start off invisible to avoid risks of UI flickering, + but the rest of the function should be in charge of making the form + container visible again if something goes wrong during setup. + + Double dollar sign needed to avoid Terraform script false positives + */ + $${cssOpacityVariableName}: 0; + } + + /* + web-client-form is the container for the main session form, while + the div is for the dropdown that is used for selecting the protocol. + The dropdown is not inside of the form for CSS styling reasons, so we + need to select both. + */ + web-client-form, + body > div.p-overlay { + /* + Double dollar sign needed to avoid Terraform script false positives + */ + opacity: calc(100% * var($${cssOpacityVariableName})) !important; + } + `; + + document.head.appendChild(styleContainer); + } + + // The root node being undefined should be physically impossible (if it's + // undefined, the browser itself is busted), but we need to do a type check + // here so that the rest of the function doesn't need to do type checks over + // and over. + const rootNode = document.querySelector(":root"); + if (!(rootNode instanceof HTMLHtmlElement)) { + // Remove the container entirely because if the browser is busted, who knows + // if the CSS variables can be applied correctly. Better to have something + // be a bit more ugly/painful to use, than have it be impossible to use + styleContainer.remove(); + return; + } + + // It's safe to make the form visible preemptively because Devolutions + // outputs the Windows view through an HTML canvas that it overlays on top + // of the rest of the app. Even if the form isn't hidden at the style level, + // it will still be covered up. + const restoreOpacity = () => { + rootNode.style.setProperty(cssOpacityVariableName, "1"); + }; + + // If this file gets more complicated, it might make sense to set up the + // timeout and event listener so that if one triggers, it cancels the other, + // but having restoreOpacity run more than once is a no-op for right now. + // Not a big deal if these don't get cleaned up. + + // Have the form automatically reappear no matter what, so that if something + // does break, the user isn't left out to dry + window.setTimeout(restoreOpacity, 5_000); + + /** @type {HTMLFormElement | null} */ + const form = document.querySelector("web-client-form > form"); + form?.addEventListener( + "submit", + () => { + // Not restoring opacity right away just to give the HTML canvas a little + // bit of time to get spun up and cover up the main form + window.setTimeout(restoreOpacity, 1_000); + }, + { once: true }, + ); +} + +// Always safe to call these immediately because even if the Angular app isn't +// loaded by the time the function gets called, the CSS will always be globally +// available for when Angular is finally ready +setupAlwaysOnStyles(); +hideFormForInitialSubmission(); + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setupFormDetection); +} else { + setupFormDetection(); +} diff --git a/registry/coder/modules/windows-rdp/main.test.ts b/registry/coder/modules/windows-rdp/main.test.ts new file mode 100644 index 00000000..ba5e21a5 --- /dev/null +++ b/registry/coder/modules/windows-rdp/main.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "bun:test"; +import { + type TerraformState, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +type TestVariables = Readonly<{ + agent_id: string; + resource_id: string; + share?: string; + admin_username?: string; + admin_password?: string; +}>; + +function findWindowsRdpScript(state: TerraformState): string | null { + for (const resource of state.resources) { + const isRdpScriptResource = + resource.type === "coder_script" && resource.name === "windows-rdp"; + + if (!isRdpScriptResource) { + continue; + } + + for (const instance of resource.instances) { + if ( + instance.attributes.display_name === "windows-rdp" && + typeof instance.attributes.script === "string" + ) { + return instance.attributes.script; + } + } + } + + return null; +} + +/** + * @todo It would be nice if we had a way to verify that the Devolutions root + * HTML file is modified to include the import for the patched Coder script, + * but the current test setup doesn't really make that viable + */ +describe("Web RDP", async () => { + await runTerraformInit(import.meta.dir); + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + resource_id: "bar", + }); + + it("Has the PowerShell script install Devolutions Gateway", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + resource_id: "bar", + }); + + const lines = findWindowsRdpScript(state) + ?.split("\n") + .filter(Boolean) + .map((line) => line.trim()); + + expect(lines).toEqual( + expect.arrayContaining([ + '$moduleName = "DevolutionsGateway"', + // Devolutions does versioning in the format year.minor.patch + expect.stringMatching(/^\$moduleVersion = "\d{4}\.\d+\.\d+"$/), + "Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force", + ]), + ); + }); + + it("Injects Terraform's username and password into the JS patch file", async () => { + /** + * Using a regex as a quick-and-dirty way to get at the username and + * password values. + * + * Tried going through the trouble of extracting out the form entries + * variable from the main output, converting it from Prettier/JS-based JSON + * text to universal JSON text, and exposing it as a parsed JSON value. That + * got to be a bit too much, though. + * + * Regex is a little bit more verbose and pedantic than normal. Want to + * have some basic safety nets for validating the structure of the form + * entries variable after the JS file has had values injected. Even with all + * the wildcard classes set to lazy mode, we want to make sure that they + * don't overshoot and grab too much content. + * + * Written and tested via Regex101 + * @see {@link https://regex101.com/r/UMgQpv/2} + */ + const formEntryValuesRe = + /^const formFieldEntries = \{$.*?^\s+username: \{$.*?^\s*?querySelector.*?,$.*?^\s*value: "(?.+?)",$.*?password: \{$.*?^\s+querySelector: .*?,$.*?^\s*value: "(?.+?)",$.*?^};$/ms; + + // Test that things work with the default username/password + const defaultState = await runTerraformApply( + import.meta.dir, + { + agent_id: "foo", + resource_id: "bar", + }, + ); + + const defaultRdpScript = findWindowsRdpScript(defaultState); + expect(defaultRdpScript).toBeString(); + + const defaultResultsGroup = + formEntryValuesRe.exec(defaultRdpScript ?? "")?.groups ?? {}; + + expect(defaultResultsGroup.username).toBe("Administrator"); + expect(defaultResultsGroup.password).toBe("coderRDP!"); + + // Test that custom usernames/passwords are also forwarded correctly + const customAdminUsername = "crouton"; + const customAdminPassword = "VeryVeryVeryVeryVerySecurePassword97!"; + const customizedState = await runTerraformApply( + import.meta.dir, + { + agent_id: "foo", + resource_id: "bar", + admin_username: customAdminUsername, + admin_password: customAdminPassword, + }, + ); + + const customRdpScript = findWindowsRdpScript(customizedState); + expect(customRdpScript).toBeString(); + + const customResultsGroup = + formEntryValuesRe.exec(customRdpScript ?? "")?.groups ?? {}; + + expect(customResultsGroup.username).toBe(customAdminUsername); + expect(customResultsGroup.password).toBe(customAdminPassword); + }); +}); diff --git a/registry/coder/modules/windows-rdp/main.tf b/registry/coder/modules/windows-rdp/main.tf new file mode 100644 index 00000000..10ece09c --- /dev/null +++ b/registry/coder/modules/windows-rdp/main.tf @@ -0,0 +1,86 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "share" { + 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'." + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "resource_id" { + type = string + description = "The ID of the primary Coder resource (e.g. VM)." +} + +variable "admin_username" { + type = string + default = "Administrator" +} + +variable "admin_password" { + type = string + default = "coderRDP!" + sensitive = true +} + +resource "coder_script" "windows-rdp" { + agent_id = var.agent_id + display_name = "windows-rdp" + icon = "/icon/desktop.svg" + + script = templatefile("${path.module}/powershell-installation-script.tftpl", { + admin_username = var.admin_username + admin_password = var.admin_password + + # Wanted to have this be in the powershell template file, but Terraform + # doesn't allow recursive calls to the templatefile function. Have to feed + # results of the JS template replace into the powershell template + patch_file_contents = templatefile("${path.module}/devolutions-patch.js", { + CODER_USERNAME = var.admin_username + CODER_PASSWORD = var.admin_password + }) + }) + + run_on_start = true +} + +resource "coder_app" "windows-rdp" { + agent_id = var.agent_id + share = var.share + slug = "web-rdp" + display_name = "Web RDP" + url = "http://localhost:7171" + icon = "/icon/desktop.svg" + subdomain = true + + healthcheck { + url = "http://localhost:7171" + interval = 5 + threshold = 15 + } +} + +resource "coder_app" "rdp-docs" { + agent_id = var.agent_id + display_name = "Local RDP" + slug = "rdp-docs" + icon = "https://raw.githubusercontent.com/matifali/logos/main/windows.svg" + url = "https://coder.com/docs/ides/remote-desktops#rdp-desktop" + external = true +} diff --git a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl new file mode 100644 index 00000000..1b7ab487 --- /dev/null +++ b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl @@ -0,0 +1,85 @@ +function Set-AdminPassword { + param ( + [string]$adminPassword + ) + # Set admin password + Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force) + # Enable admin user + Get-LocalUser -Name "${admin_username}" | Enable-LocalUser +} + +function Configure-RDP { + # Enable RDP + New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 -PropertyType DWORD -Force + # Disable NLA + New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "UserAuthentication" -Value 0 -PropertyType DWORD -Force + New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "SecurityLayer" -Value 1 -PropertyType DWORD -Force + # Enable RDP through Windows Firewall + Enable-NetFirewallRule -DisplayGroup "Remote Desktop" +} + +function Install-DevolutionsGateway { +# Define the module name and version +$moduleName = "DevolutionsGateway" +$moduleVersion = "2024.1.5" + +# Install the module with the specified version for all users +# This requires administrator privileges +try { + # Install-PackageProvider is required for AWS. Need to set command to + # terminate on failure so that try/catch actually triggers + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop + Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force +} +catch { + # If the first command failed, assume that we're on GCP and run + # Install-Module only + Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force +} + +# Construct the module path for system-wide installation +$moduleBasePath = "C:\Windows\system32\config\systemprofile\Documents\PowerShell\Modules\$moduleName\$moduleVersion" +$modulePath = Join-Path -Path $moduleBasePath -ChildPath "$moduleName.psd1" + +# Import the module using the full path +Import-Module $modulePath +Install-DGatewayPackage + +# Configure Devolutions Gateway +$Hostname = "localhost" +$HttpListener = New-DGatewayListener 'http://*:7171' 'http://*:7171' +$WebApp = New-DGatewayWebAppConfig -Enabled $true -Authentication None +$ConfigParams = @{ + Hostname = $Hostname + Listeners = @($HttpListener) + WebApp = $WebApp +} +Set-DGatewayConfig @ConfigParams +New-DGatewayProvisionerKeyPair -Force + +# Configure and start the Windows service +Set-Service 'DevolutionsGateway' -StartupType 'Automatic' +Start-Service 'DevolutionsGateway' +} + +function Patch-Devolutions-HTML { +$root = "C:\Program Files\Devolutions\Gateway\webapp\client" +$devolutionsHtml = "$root\index.html" +$patch = '' + +# Always copy the file in case we change it. +@' +${patch_file_contents} +'@ | Set-Content "$root\coder.js" + +# Only inject the src if we have not before. +$isPatched = Select-String -Path "$devolutionsHtml" -Pattern "$patch" -SimpleMatch +if ($isPatched -eq $null) { + (Get-Content $devolutionsHtml).Replace('', "$patch") | Set-Content $devolutionsHtml +} +} + +Set-AdminPassword -adminPassword "${admin_password}" +Configure-RDP +Install-DevolutionsGateway +Patch-Devolutions-HTML diff --git a/registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png b/registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..f37d65da937b043baaca3879514ba8757c2bf6e5 GIT binary patch literal 100943 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 literal 0 HcmV?d00001 diff --git a/registry/thezoker/README.md b/registry/thezoker/README.md new file mode 100644 index 00000000..e69de29b diff --git a/registry/thezoker/modules/nodejs/README.md b/registry/thezoker/modules/nodejs/README.md new file mode 100644 index 00000000..b4420c1d --- /dev/null +++ b/registry/thezoker/modules/nodejs/README.md @@ -0,0 +1,61 @@ +--- +display_name: nodejs +description: Install Node.js via nvm +icon: ../.icons/node.svg +maintainer_github: TheZoker +verified: false +tags: [helper] +--- + +# nodejs + +Automatically installs [Node.js](https://github.com/nodejs/node) via [nvm](https://github.com/nvm-sh/nvm). It can also install multiple versions of node and set a default version. If no options are specified, the latest version is installed. + +```tf +module "nodejs" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/nodejs/coder" + version = "1.0.10" + agent_id = coder_agent.example.id +} +``` + +### Install multiple versions + +This installs multiple versions of Node.js: + +```tf +module "nodejs" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/nodejs/coder" + version = "1.0.10" + agent_id = coder_agent.example.id + node_versions = [ + "18", + "20", + "node" + ] + default_node_version = "20" +} +``` + +### Full example + +A example with all available options: + +```tf +module "nodejs" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/nodejs/coder" + version = "1.0.10" + agent_id = coder_agent.example.id + nvm_version = "v0.39.7" + nvm_install_prefix = "/opt/nvm" + node_versions = [ + "16", + "18", + "node" + ] + default_node_version = "16" +} +``` diff --git a/registry/thezoker/modules/nodejs/main.test.ts b/registry/thezoker/modules/nodejs/main.test.ts new file mode 100644 index 00000000..39e48f49 --- /dev/null +++ b/registry/thezoker/modules/nodejs/main.test.ts @@ -0,0 +1,12 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "../test"; + +describe("nodejs", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + // More tests depend on shebang refactors +}); diff --git a/registry/thezoker/modules/nodejs/main.tf b/registry/thezoker/modules/nodejs/main.tf new file mode 100644 index 00000000..9c9c5c76 --- /dev/null +++ b/registry/thezoker/modules/nodejs/main.tf @@ -0,0 +1,52 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "nvm_version" { + type = string + description = "The version of nvm to install." + default = "master" +} + +variable "nvm_install_prefix" { + type = string + description = "The prefix to install nvm to (relative to $HOME)." + default = ".nvm" +} + +variable "node_versions" { + type = list(string) + description = "A list of Node.js versions to install." + default = ["node"] +} + +variable "default_node_version" { + type = string + description = "The default Node.js version" + default = "node" +} + +resource "coder_script" "nodejs" { + agent_id = var.agent_id + display_name = "Node.js:" + script = templatefile("${path.module}/run.sh", { + NVM_VERSION : var.nvm_version, + INSTALL_PREFIX : var.nvm_install_prefix, + NODE_VERSIONS : join(",", var.node_versions), + DEFAULT : var.default_node_version, + }) + run_on_start = true + start_blocks_login = true +} diff --git a/registry/thezoker/modules/nodejs/run.sh b/registry/thezoker/modules/nodejs/run.sh new file mode 100644 index 00000000..78e940a7 --- /dev/null +++ b/registry/thezoker/modules/nodejs/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +NVM_VERSION='${NVM_VERSION}' +NODE_VERSIONS='${NODE_VERSIONS}' +INSTALL_PREFIX='${INSTALL_PREFIX}' +DEFAULT='${DEFAULT}' +BOLD='\033[0;1m' +CODE='\033[36;40;1m' +RESET='\033[0m' + +printf "$${BOLD}Installing nvm!$${RESET}\n" + +export NVM_DIR="$HOME/$${INSTALL_PREFIX}/nvm" +mkdir -p "$NVM_DIR" + +script="$(curl -sS -o- "https://raw.githubusercontent.com/nvm-sh/nvm/$${NVM_VERSION}/install.sh" 2>&1)" +if [ $? -ne 0 ]; then + echo "Failed to download nvm installation script: $script" + exit 1 +fi + +output="$(bash <<< "$script" 2>&1)" +if [ $? -ne 0 ]; then + echo "Failed to install nvm: $output" + exit 1 +fi + +printf "🥳 nvm has been installed\n\n" + +# Set up nvm for the rest of the script. +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + +# Install each node version... +IFS=',' read -r -a VERSIONLIST <<< "$${NODE_VERSIONS}" +for version in "$${VERSIONLIST[@]}"; do + if [ -z "$version" ]; then + continue + fi + printf "🛠️ Installing node version $${CODE}$version$${RESET}...\n" + output=$(nvm install "$version" 2>&1) + if [ $? -ne 0 ]; then + echo "Failed to install version: $version: $output" + exit 1 + fi +done + +# Set default if provided +if [ -n "$${DEFAULT}" ]; then + printf "🛠️ Setting default node version $${CODE}$DEFAULT$${RESET}...\n" + output=$(nvm alias default $DEFAULT 2>&1) +fi diff --git a/registry/whizus/modules/exoscale-instance-type/README.md b/registry/whizus/modules/exoscale-instance-type/README.md new file mode 100644 index 00000000..19083c3a --- /dev/null +++ b/registry/whizus/modules/exoscale-instance-type/README.md @@ -0,0 +1,117 @@ +--- +display_name: exoscale-instance-type +description: A parameter with human readable exoscale instance names +icon: ../.icons/exoscale.svg +maintainer_github: WhizUs +verified: false +tags: [helper, parameter, instances, exoscale] +--- + +# exoscale-instance-type + +A parameter with all Exoscale instance types. This allows developers to select +their desired virtual machine for the workspace. + +Customize the preselected parameter value: + +```tf +module "exoscale-instance-type" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-instance-type/coder" + version = "1.0.12" + default = "standard.medium" +} + +resource "exoscale_compute_instance" "instance" { + type = module.exoscale-instance-type.value + # ... +} + +resource "coder_metadata" "workspace_info" { + item { + key = "instance type" + value = module.exoscale-instance-type.name + } +} +``` + +![Exoscale instance types](../.images/exoscale-instance-types.png) + +## Examples + +### Customize type + +Change the display name a type using the corresponding maps: + +```tf +module "exoscale-instance-type" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-instance-type/coder" + version = "1.0.12" + default = "standard.medium" + + custom_names = { + "standard.medium" : "Mittlere Instanz" # German translation + } + + custom_descriptions = { + "standard.medium" : "4 GB Arbeitsspeicher, 2 Kerne, 10 - 400 GB Festplatte" # German translation + } +} + +resource "exoscale_compute_instance" "instance" { + type = module.exoscale-instance-type.value + # ... +} + +resource "coder_metadata" "workspace_info" { + item { + key = "instance type" + value = module.exoscale-instance-type.name + } +} +``` + +![Exoscale instance types Custom](../.images/exoscale-instance-custom.png) + +### Use category and exclude type + +Show only gpu1 types + +```tf +module "exoscale-instance-type" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-instance-type/coder" + version = "1.0.12" + default = "gpu.large" + type_category = ["gpu"] + exclude = [ + "gpu2.small", + "gpu2.medium", + "gpu2.large", + "gpu2.huge", + "gpu3.small", + "gpu3.medium", + "gpu3.large", + "gpu3.huge" + ] +} + +resource "exoscale_compute_instance" "instance" { + type = module.exoscale-instance-type.value + # ... +} + +resource "coder_metadata" "workspace_info" { + item { + key = "instance type" + value = module.exoscale-instance-type.name + } +} +``` + +![Exoscale instance types category and exclude](../.images/exoscale-instance-exclude.png) + +## Related templates + +A related exoscale template will be provided soon. diff --git a/registry/whizus/modules/exoscale-instance-type/main.test.ts b/registry/whizus/modules/exoscale-instance-type/main.test.ts new file mode 100644 index 00000000..e4b998bc --- /dev/null +++ b/registry/whizus/modules/exoscale-instance-type/main.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("exoscale-instance-type", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "gpu3.huge", + type_category: `["gpu", "cpu"]`, + }); + expect(state.outputs.value.value).toBe("gpu3.huge"); + }); + + it("fails because of wrong categroy definition", async () => { + expect(async () => { + await runTerraformApply(import.meta.dir, { + default: "gpu3.huge", + // type_category: ["standard"] is standard + }); + }).toThrow('default value "gpu3.huge" must be defined as one of options'); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/whizus/modules/exoscale-instance-type/main.tf b/registry/whizus/modules/exoscale-instance-type/main.tf new file mode 100644 index 00000000..65d37291 --- /dev/null +++ b/registry/whizus/modules/exoscale-instance-type/main.tf @@ -0,0 +1,286 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "display_name" { + default = "Exoscale instance type" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "Select the exoscale instance type to use for the workspace. Check out the pricing page for more information: https://www.exoscale.com/pricing" + description = "The description of the parameter." + type = string +} + +variable "default" { + default = "" + description = "The default instance type to use if no type is specified. One of [\"standard.micro\", \"standard.tiny\", \"standard.small\", \"standard.medium\", \"standard.large\", \"standard.extra\", \"standard.huge\", \"standard.mega\", \"standard.titan\", \"standard.jumbo\", \"standard.colossus\", \"cpu.extra\", \"cpu.huge\", \"cpu.mega\", \"cpu.titan\", \"memory.extra\", \"memory.huge\", \"memory.mega\", \"memory.titan\", \"storage.extra\", \"storage.huge\", \"storage.mega\", \"storage.titan\", \"storage.jumbo\", \"gpu.small\", \"gpu.medium\", \"gpu.large\", \"gpu.huge\", \"gpu2.small\", \"gpu2.medium\", \"gpu2.large\", \"gpu2.huge\", \"gpu3.small\", \"gpu3.medium\", \"gpu3.large\", \"gpu3.huge\"]" + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for instance type IDs." + type = map(string) +} +variable "custom_descriptions" { + default = {} + description = "A map of custom descriptions for instance type IDs." + type = map(string) +} + +variable "type_category" { + default = ["standard"] + description = "A list of instance type categories the user is allowed to choose. One of [\"standard\", \"cpu\", \"memory\", \"storage\", \"gpu\"]" + type = list(string) +} + +variable "exclude" { + default = [] + description = "A list of instance type IDs to exclude. One of [\"standard.micro\", \"standard.tiny\", \"standard.small\", \"standard.medium\", \"standard.large\", \"standard.extra\", \"standard.huge\", \"standard.mega\", \"standard.titan\", \"standard.jumbo\", \"standard.colossus\", \"cpu.extra\", \"cpu.huge\", \"cpu.mega\", \"cpu.titan\", \"memory.extra\", \"memory.huge\", \"memory.mega\", \"memory.titan\", \"storage.extra\", \"storage.huge\", \"storage.mega\", \"storage.titan\", \"storage.jumbo\", \"gpu.small\", \"gpu.medium\", \"gpu.large\", \"gpu.huge\", \"gpu2.small\", \"gpu2.medium\", \"gpu2.large\", \"gpu2.huge\", \"gpu3.small\", \"gpu3.medium\", \"gpu3.large\", \"gpu3.huge\"]" + type = list(string) +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + # https://www.exoscale.com/pricing/ + + standard_instances = [ + { + value = "standard.micro", + name = "Standard Micro", + description = "512 MB RAM, 1 Core, 10 - 200 GB Disk" + }, + { + value = "standard.tiny", + name = "Standard Tiny", + description = "1 GB RAM, 1 Core, 10 - 400 GB Disk" + }, + { + value = "standard.small", + name = "Standard Small", + description = "2 GB RAM, 2 Cores, 10 - 400 GB Disk" + }, + { + value = "standard.medium", + name = "Standard Medium", + description = "4 GB RAM, 2 Cores, 10 - 400 GB Disk" + }, + { + value = "standard.large", + name = "Standard Large", + description = "8 GB RAM, 4 Cores, 10 - 400 GB Disk" + }, + { + value = "standard.extra", + name = "Standard Extra", + description = "rge", + description = "16 GB RAM, 4 Cores, 10 - 800 GB Disk" + }, + { + value = "standard.huge", + name = "Standard Huge", + description = "32 GB RAM, 8 Cores, 10 - 800 GB Disk" + }, + { + value = "standard.mega", + name = "Standard Mega", + description = "64 GB RAM, 12 Cores, 10 - 800 GB Disk" + }, + { + value = "standard.titan", + name = "Standard Titan", + description = "128 GB RAM, 16 Cores, 10 - 1.6 TB Disk" + }, + { + value = "standard.jumbo", + name = "Standard Jumbo", + description = "256 GB RAM, 24 Cores, 10 - 1.6 TB Disk" + }, + { + value = "standard.colossus", + name = "Standard Colossus", + description = "320 GB RAM, 40 Cores, 10 - 1.6 TB Disk" + } + ] + cpu_instances = [ + { + value = "cpu.extra", + name = "CPU Extra-Large", + description = "16 GB RAM, 8 Cores, 10 - 800 GB Disk" + }, + { + value = "cpu.huge", + name = "CPU Huge", + description = "32 GB RAM, 16 Cores, 10 - 800 GB Disk" + }, + { + value = "cpu.mega", + name = "CPU Mega", + description = "64 GB RAM, 32 Cores, 10 - 800 GB Disk" + }, + { + value = "cpu.titan", + name = "CPU Titan", + description = "128 GB RAM, 40 Cores, 0.1 - 1.6 TB Disk" + } + ] + memory_instances = [ + { + value = "memory.extra", + name = "Memory Extra-Large", + description = "16 GB RAM, 2 Cores, 10 - 800 GB Disk" + }, + { + value = "memory.huge", + name = "Memory Huge", + description = "32 GB RAM, 4 Cores, 10 - 800 GB Disk" + }, + { + value = "memory.mega", + name = "Memory Mega", + description = "64 GB RAM, 8 Cores, 10 - 800 GB Disk" + }, + { + value = "memory.titan", + name = "Memory Titan", + description = "128 GB RAM, 12 Cores, 0.1 - 1.6 TB Disk" + } + ] + storage_instances = [ + { + value = "storage.extra", + name = "Storage Extra-Large", + description = "16 GB RAM, 4 Cores, 1 - 2 TB Disk" + }, + { + value = "storage.huge", + name = "Storage Huge", + description = "32 GB RAM, 8 Cores, 2 - 3 TB Disk" + }, + { + value = "storage.mega", + name = "Storage Mega", + description = "64 GB RAM, 12 Cores, 3 - 5 TB Disk" + }, + { + value = "storage.titan", + name = "Storage Titan", + description = "128 GB RAM, 16 Cores, 5 - 10 TB Disk" + }, + { + value = "storage.jumbo", + name = "Storage Jumbo", + description = "225 GB RAM, 24 Cores, 10 - 15 TB Disk" + } + ] + gpu_instances = [ + { + value = "gpu.small", + name = "GPU1 Small", + description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk" + }, + { + value = "gpu.medium", + name = "GPU1 Medium", + description = "90 GB RAM, 16 Cores, 2 GPU, 0.1 - 1.2 TB Disk" + }, + { + value = "gpu.large", + name = "GPU1 Large", + description = "120 GB RAM, 24 Cores, 3 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu.huge", + name = "GPU1 Huge", + description = "225 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu2.small", + name = "GPU2 Small", + description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk" + }, + { + value = "gpu2.medium", + name = "GPU2 Medium", + description = "90 GB RAM, 16 Cores, 2 GPU, 0.1 - 1.2 TB Disk" + }, + { + value = "gpu2.large", + name = "GPU2 Large", + description = "120 GB RAM, 24 Cores, 3 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu2.huge", + name = "GPU2 Huge", + description = "225 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu3.small", + name = "GPU3 Small", + description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk" + }, + { + value = "gpu3.medium", + name = "GPU3 Medium", + description = "120 GB RAM, 24 Cores, 2 GPU, 0.1 - 1.2 TB Disk" + }, + { + value = "gpu3.large", + name = "GPU3 Large", + description = "224 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu3.huge", + name = "GPU3 Huge", + description = "448 GB RAM, 96 Cores, 8 GPU, 0.1 - 1.6 TB Disk" + } + ] +} + +data "coder_parameter" "instance_type" { + name = "exoscale_instance_type" + display_name = var.display_name + description = var.description + default = var.default == "" ? null : var.default + order = var.coder_parameter_order + mutable = var.mutable + dynamic "option" { + for_each = [for k, v in concat( + contains(var.type_category, "standard") ? local.standard_instances : [], + contains(var.type_category, "cpu") ? local.cpu_instances : [], + contains(var.type_category, "memory") ? local.memory_instances : [], + contains(var.type_category, "storage") ? local.storage_instances : [], + contains(var.type_category, "gpu") ? local.gpu_instances : [] + ) : v if !(contains(var.exclude, v.value))] + content { + name = try(var.custom_names[option.value.value], option.value.name) + description = try(var.custom_descriptions[option.value.value], option.value.description) + value = option.value.value + } + } +} + +output "value" { + value = data.coder_parameter.instance_type.value +} diff --git a/registry/whizus/modules/exoscale-zone/README.md b/registry/whizus/modules/exoscale-zone/README.md new file mode 100644 index 00000000..611aee5b --- /dev/null +++ b/registry/whizus/modules/exoscale-zone/README.md @@ -0,0 +1,100 @@ +--- +display_name: exoscale-zone +description: A parameter with human zone names and icons +icon: ../.icons/exoscale.svg +maintainer_github: WhizUs +verified: false +tags: [helper, parameter, zones, regions, exoscale] +--- + +# exoscale-zone + +A parameter with all Exoscale zones. This allows developers to select +the zone closest to them. + +Customize the preselected parameter value: + +```tf +module "exoscale-zone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-zone/coder" + version = "1.0.12" + default = "ch-dk-2" +} + + +data "exoscale_compute_template" "my_template" { + zone = module.exoscale-zone.value + name = "Linux Ubuntu 22.04 LTS 64-bit" +} + +resource "exoscale_compute_instance" "instance" { + zone = module.exoscale-zone.value + # ... +} +``` + +![Exoscale Zones](../.images/exoscale-zones.png) + +## Examples + +### Customize zones + +Change the display name and icon for a zone using the corresponding maps: + +```tf +module "exoscale-zone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-zone/coder" + version = "1.0.12" + default = "at-vie-1" + + custom_names = { + "at-vie-1" : "Home Vienna" + } + + custom_icons = { + "at-vie-1" : "/emojis/1f3e0.png" + } +} + +data "exoscale_compute_template" "my_template" { + zone = module.exoscale-zone.value + name = "Linux Ubuntu 22.04 LTS 64-bit" +} + +resource "exoscale_compute_instance" "instance" { + zone = module.exoscale-zone.value + # ... +} +``` + +![Exoscale Custom](../.images/exoscale-custom.png) + +### Exclude regions + +Hide the Switzerland zones Geneva and Zurich + +```tf +module "exoscale-zone" { + source = "registry.coder.com/modules/exoscale-zone/coder" + version = "1.0.12" + exclude = ["ch-gva-2", "ch-dk-2"] +} + +data "exoscale_compute_template" "my_template" { + zone = module.exoscale-zone.value + name = "Linux Ubuntu 22.04 LTS 64-bit" +} + +resource "exoscale_compute_instance" "instance" { + zone = module.exoscale-zone.value + # ... +} +``` + +![Exoscale Exclude](../.images/exoscale-exclude.png) + +## Related templates + +An exoscale sample template will be delivered soon. diff --git a/registry/whizus/modules/exoscale-zone/main.test.ts b/registry/whizus/modules/exoscale-zone/main.test.ts new file mode 100644 index 00000000..1751cb14 --- /dev/null +++ b/registry/whizus/modules/exoscale-zone/main.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("exoscale-zone", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "at-vie-1", + }); + expect(state.outputs.value.value).toBe("at-vie-1"); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/whizus/modules/exoscale-zone/main.tf b/registry/whizus/modules/exoscale-zone/main.tf new file mode 100644 index 00000000..090acb4c --- /dev/null +++ b/registry/whizus/modules/exoscale-zone/main.tf @@ -0,0 +1,116 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "display_name" { + default = "Exoscale Region" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "The region to deploy workspace infrastructure." + description = "The description of the parameter." + type = string +} + +variable "default" { + default = "" + description = "The default region to use if no region is specified." + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "exclude" { + default = [] + description = "A list of region IDs to exclude." + type = list(string) +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + # This is a static list because the zones don't change _that_ + # frequently and including the `exoscale_zones` data source requires + # the provider, which requires a zone. + # https://www.exoscale.com/datacenters/ + zones = { + "de-fra-1" = { + name = "Frankfurt - Germany" + icon = "/emojis/1f1e9-1f1ea.png" + } + "at-vie-1" = { + name = "Vienna 1 - Austria" + icon = "/emojis/1f1e6-1f1f9.png" + } + "at-vie-2" = { + name = "Vienna 2 - Austria" + icon = "/emojis/1f1e6-1f1f9.png" + } + "ch-gva-2" = { + name = "Geneva - Switzerland" + icon = "/emojis/1f1e8-1f1ed.png" + } + "ch-dk-2" = { + name = "Zurich - Switzerland" + icon = "/emojis/1f1e8-1f1ed.png" + } + "bg-sof-1" = { + name = "Sofia - Bulgaria" + icon = "/emojis/1f1e7-1f1ec.png" + } + "de-muc-1" = { + name = "Munich - Germany" + icon = "/emojis/1f1e9-1f1ea.png" + } + } +} + +data "coder_parameter" "zone" { + name = "exoscale_zone" + display_name = var.display_name + description = var.description + default = var.default == "" ? null : var.default + order = var.coder_parameter_order + mutable = var.mutable + dynamic "option" { + for_each = { for k, v in local.zones : k => v if !(contains(var.exclude, k)) } + content { + name = try(var.custom_names[option.key], option.value.name) + icon = try(var.custom_icons[option.key], option.value.icon) + value = option.key + } + } +} + +output "value" { + value = data.coder_parameter.zone.value +} \ No newline at end of file