chore: copy over main modules
This commit is contained in:
parent
2317f92abd
commit
e905d7a3d5
48
registry/coder/modules/amazon-dcv-windows/README.md
Normal file
48
registry/coder/modules/amazon-dcv-windows/README.md
Normal file
@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
170
registry/coder/modules/amazon-dcv-windows/install-dcv.ps1
Normal file
170
registry/coder/modules/amazon-dcv-windows/install-dcv.ps1
Normal file
@ -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"
|
||||
85
registry/coder/modules/amazon-dcv-windows/main.tf
Normal file
85
registry/coder/modules/amazon-dcv-windows/main.tf
Normal file
@ -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
|
||||
}
|
||||
24
registry/coder/modules/apache-airflow/README.md
Normal file
24
registry/coder/modules/apache-airflow/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
65
registry/coder/modules/apache-airflow/main.tf
Normal file
65
registry/coder/modules/apache-airflow/main.tf
Normal file
@ -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
|
||||
}
|
||||
19
registry/coder/modules/apache-airflow/run.sh
Normal file
19
registry/coder/modules/apache-airflow/run.sh
Normal file
@ -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
|
||||
85
registry/coder/modules/aws-region/README.md
Normal file
85
registry/coder/modules/aws-region/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
33
registry/coder/modules/aws-region/main.test.ts
Normal file
33
registry/coder/modules/aws-region/main.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
199
registry/coder/modules/aws-region/main.tf
Normal file
199
registry/coder/modules/aws-region/main.tf
Normal file
@ -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
|
||||
}
|
||||
84
registry/coder/modules/azure-region/README.md
Normal file
84
registry/coder/modules/azure-region/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
33
registry/coder/modules/azure-region/main.test.ts
Normal file
33
registry/coder/modules/azure-region/main.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
333
registry/coder/modules/azure-region/main.tf
Normal file
333
registry/coder/modules/azure-region/main.tf
Normal file
@ -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
|
||||
}
|
||||
114
registry/coder/modules/claude-code/README.md
Normal file
114
registry/coder/modules/claude-code/README.md
Normal file
@ -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"
|
||||
}
|
||||
```
|
||||
170
registry/coder/modules/claude-code/main.tf
Normal file
170
registry/coder/modules/claude-code/main.tf
Normal file
@ -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
|
||||
}
|
||||
115
registry/coder/modules/code-server/README.md
Normal file
115
registry/coder/modules/code-server/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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 `<author>.<name>` 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
|
||||
}
|
||||
```
|
||||
38
registry/coder/modules/code-server/main.test.ts
Normal file
38
registry/coder/modules/code-server/main.test.ts
Normal file
@ -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
|
||||
});
|
||||
175
registry/coder/modules/code-server/main.tf
Normal file
175
registry/coder/modules/code-server/main.tf
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
128
registry/coder/modules/code-server/run.sh
Normal file
128
registry/coder/modules/code-server/run.sh
Normal file
@ -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
|
||||
23
registry/coder/modules/coder-login/README.md
Normal file
23
registry/coder/modules/coder-login/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
10
registry/coder/modules/coder-login/main.test.ts
Normal file
10
registry/coder/modules/coder-login/main.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
31
registry/coder/modules/coder-login/main.tf
Normal file
31
registry/coder/modules/coder-login/main.tf
Normal file
@ -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
|
||||
}
|
||||
|
||||
15
registry/coder/modules/coder-login/run.sh
Normal file
15
registry/coder/modules/coder-login/run.sh
Normal file
@ -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
|
||||
37
registry/coder/modules/cursor/README.md
Normal file
37
registry/coder/modules/cursor/README.md
Normal file
@ -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"
|
||||
}
|
||||
```
|
||||
88
registry/coder/modules/cursor/main.test.ts
Normal file
88
registry/coder/modules/cursor/main.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
62
registry/coder/modules/cursor/main.tf
Normal file
62
registry/coder/modules/cursor/main.tf
Normal file
@ -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."
|
||||
}
|
||||
84
registry/coder/modules/dotfiles/README.md
Normal file
84
registry/coder/modules/dotfiles/README.md
Normal file
@ -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"
|
||||
}
|
||||
```
|
||||
40
registry/coder/modules/dotfiles/main.test.ts
Normal file
40
registry/coder/modules/dotfiles/main.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
91
registry/coder/modules/dotfiles/main.tf
Normal file
91
registry/coder/modules/dotfiles/main.tf
Normal file
@ -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
|
||||
}
|
||||
26
registry/coder/modules/dotfiles/run.sh
Normal file
26
registry/coder/modules/dotfiles/run.sh
Normal file
@ -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
|
||||
62
registry/coder/modules/filebrowser/README.md
Normal file
62
registry/coder/modules/filebrowser/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
}
|
||||
```
|
||||
123
registry/coder/modules/filebrowser/main.tf
Normal file
123
registry/coder/modules/filebrowser/main.tf
Normal file
@ -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"
|
||||
}
|
||||
37
registry/coder/modules/filebrowser/run.sh
Normal file
37
registry/coder/modules/filebrowser/run.sh
Normal file
@ -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"
|
||||
70
registry/coder/modules/fly-region/README.md
Normal file
70
registry/coder/modules/fly-region/README.md
Normal file
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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"]
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### 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!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
32
registry/coder/modules/fly-region/main.test.ts
Normal file
32
registry/coder/modules/fly-region/main.test.ts
Normal file
@ -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("");
|
||||
});
|
||||
});
|
||||
287
registry/coder/modules/fly-region/main.tf
Normal file
287
registry/coder/modules/fly-region/main.tf
Normal file
@ -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
|
||||
}
|
||||
81
registry/coder/modules/gcp-region/README.md
Normal file
81
registry/coder/modules/gcp-region/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
}
|
||||
```
|
||||
52
registry/coder/modules/gcp-region/main.test.ts
Normal file
52
registry/coder/modules/gcp-region/main.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
748
registry/coder/modules/gcp-region/main.tf
Normal file
748
registry/coder/modules/gcp-region/main.tf
Normal file
@ -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)
|
||||
}
|
||||
182
registry/coder/modules/git-clone/README.md
Normal file
182
registry/coder/modules/git-clone/README.md
Normal file
@ -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"
|
||||
}
|
||||
```
|
||||
247
registry/coder/modules/git-clone/main.test.ts
Normal file
247
registry/coder/modules/git-clone/main.test.ts
Normal file
@ -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...",
|
||||
]);
|
||||
});
|
||||
});
|
||||
121
registry/coder/modules/git-clone/main.tf
Normal file
121
registry/coder/modules/git-clone/main.tf
Normal file
@ -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
|
||||
}
|
||||
47
registry/coder/modules/git-clone/run.sh
Normal file
47
registry/coder/modules/git-clone/run.sh
Normal file
@ -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
|
||||
29
registry/coder/modules/git-commit-signing/README.md
Normal file
29
registry/coder/modules/git-commit-signing/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
25
registry/coder/modules/git-commit-signing/main.tf
Normal file
25
registry/coder/modules/git-commit-signing/main.tf
Normal file
@ -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
|
||||
}
|
||||
42
registry/coder/modules/git-commit-signing/run.sh
Normal file
42
registry/coder/modules/git-commit-signing/run.sh
Normal file
@ -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
|
||||
52
registry/coder/modules/git-config/README.md
Normal file
52
registry/coder/modules/git-config/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
127
registry/coder/modules/git-config/main.test.ts
Normal file
127
registry/coder/modules/git-config/main.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
84
registry/coder/modules/git-config/main.tf
Normal file
84
registry/coder/modules/git-config/main.tf
Normal file
@ -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
|
||||
}
|
||||
55
registry/coder/modules/github-upload-public-key/README.md
Normal file
55
registry/coder/modules/github-upload-public-key/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
132
registry/coder/modules/github-upload-public-key/main.test.ts
Normal file
132
registry/coder/modules/github-upload-public-key/main.test.ts
Normal file
@ -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<string, string> = {},
|
||||
) => {
|
||||
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<Server> => {
|
||||
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;
|
||||
};
|
||||
43
registry/coder/modules/github-upload-public-key/main.tf
Normal file
43
registry/coder/modules/github-upload-public-key/main.tf
Normal file
@ -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
|
||||
}
|
||||
110
registry/coder/modules/github-upload-public-key/run.sh
Normal file
110
registry/coder/modules/github-upload-public-key/run.sh
Normal file
@ -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!"
|
||||
130
registry/coder/modules/goose/README.md
Normal file
130
registry/coder/modules/goose/README.md
Normal file
@ -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"
|
||||
}
|
||||
```
|
||||
207
registry/coder/modules/goose/main.tf
Normal file
207
registry/coder/modules/goose/main.tf
Normal file
@ -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
|
||||
}
|
||||
80
registry/coder/modules/hcp-vault-secrets/README.md
Normal file
80
registry/coder/modules/hcp-vault-secrets/README.md
Normal file
@ -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.
|
||||

|
||||
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"
|
||||
}
|
||||
```
|
||||
73
registry/coder/modules/hcp-vault-secrets/main.tf
Normal file
73
registry/coder/modules/hcp-vault-secrets/main.tf
Normal file
@ -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]
|
||||
}
|
||||
133
registry/coder/modules/jetbrains-gateway/README.md
Normal file
133
registry/coder/modules/jetbrains-gateway/README.md
Normal file
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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/)
|
||||
43
registry/coder/modules/jetbrains-gateway/main.test.ts
Normal file
43
registry/coder/modules/jetbrains-gateway/main.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
341
registry/coder/modules/jetbrains-gateway/main.tf
Normal file
341
registry/coder/modules/jetbrains-gateway/main.tf
Normal file
@ -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
|
||||
}
|
||||
5
registry/coder/modules/jfrog-oauth/.npmrc.tftpl
Normal file
5
registry/coder/modules/jfrog-oauth/.npmrc.tftpl
Normal file
@ -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 ~}
|
||||
107
registry/coder/modules/jfrog-oauth/README.md
Normal file
107
registry/coder/modules/jfrog-oauth/README.md
Normal file
@ -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.
|
||||
|
||||

|
||||
|
||||
```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.
|
||||
129
registry/coder/modules/jfrog-oauth/main.test.ts
Normal file
129
registry/coder/modules/jfrog-oauth/main.test.ts
Normal file
@ -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<TestVariables>(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<TestVariables>(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<TestVariables>(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<TestVariables>(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<TestVariables>(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',
|
||||
);
|
||||
});
|
||||
});
|
||||
173
registry/coder/modules/jfrog-oauth/main.tf
Normal file
173
registry/coder/modules/jfrog-oauth/main.tf
Normal file
@ -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
|
||||
}
|
||||
6
registry/coder/modules/jfrog-oauth/pip.conf.tftpl
Normal file
6
registry/coder/modules/jfrog-oauth/pip.conf.tftpl
Normal file
@ -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 ~}
|
||||
131
registry/coder/modules/jfrog-oauth/run.sh
Normal file
131
registry/coder/modules/jfrog-oauth/run.sh
Normal file
@ -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
|
||||
5
registry/coder/modules/jfrog-token/.npmrc.tftpl
Normal file
5
registry/coder/modules/jfrog-token/.npmrc.tftpl
Normal file
@ -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 ~}
|
||||
125
registry/coder/modules/jfrog-token/README.md
Normal file
125
registry/coder/modules/jfrog-token/README.md
Normal file
@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
165
registry/coder/modules/jfrog-token/main.test.ts
Normal file
165
registry/coder/modules/jfrog-token/main.test.ts
Normal file
@ -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<TestVariables>(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<TestVariables>(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<TestVariables>(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<TestVariables>(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<TestVariables>(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',
|
||||
);
|
||||
});
|
||||
});
|
||||
219
registry/coder/modules/jfrog-token/main.tf
Normal file
219
registry/coder/modules/jfrog-token/main.tf
Normal file
@ -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
|
||||
}
|
||||
6
registry/coder/modules/jfrog-token/pip.conf.tftpl
Normal file
6
registry/coder/modules/jfrog-token/pip.conf.tftpl
Normal file
@ -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 ~}
|
||||
130
registry/coder/modules/jfrog-token/run.sh
Normal file
130
registry/coder/modules/jfrog-token/run.sh
Normal file
@ -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
|
||||
23
registry/coder/modules/jupyter-notebook/README.md
Normal file
23
registry/coder/modules/jupyter-notebook/README.md
Normal file
@ -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.
|
||||
|
||||

|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
65
registry/coder/modules/jupyter-notebook/main.tf
Normal file
65
registry/coder/modules/jupyter-notebook/main.tf
Normal file
@ -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
|
||||
}
|
||||
25
registry/coder/modules/jupyter-notebook/run.sh
Normal file
25
registry/coder/modules/jupyter-notebook/run.sh
Normal file
@ -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 &
|
||||
23
registry/coder/modules/jupyterlab/README.md
Normal file
23
registry/coder/modules/jupyterlab/README.md
Normal file
@ -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.
|
||||
|
||||

|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
107
registry/coder/modules/jupyterlab/main.test.ts
Normal file
107
registry/coder/modules/jupyterlab/main.test.ts
Normal file
@ -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");
|
||||
// ...
|
||||
// });
|
||||
});
|
||||
75
registry/coder/modules/jupyterlab/main.tf
Normal file
75
registry/coder/modules/jupyterlab/main.tf
Normal file
@ -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
|
||||
}
|
||||
57
registry/coder/modules/jupyterlab/run.sh
Normal file
57
registry/coder/modules/jupyterlab/run.sh
Normal file
@ -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 &
|
||||
24
registry/coder/modules/kasmvnc/README.md
Normal file
24
registry/coder/modules/kasmvnc/README.md
Normal file
@ -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.
|
||||
37
registry/coder/modules/kasmvnc/main.test.ts
Normal file
37
registry/coder/modules/kasmvnc/main.test.ts
Normal file
@ -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<TestVariables>(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<TestVariables>(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
desktop_environment: v,
|
||||
});
|
||||
};
|
||||
|
||||
expect(applyWithEnv).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
63
registry/coder/modules/kasmvnc/main.tf
Normal file
63
registry/coder/modules/kasmvnc/main.tf
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
235
registry/coder/modules/kasmvnc/run.sh
Normal file
235
registry/coder/modules/kasmvnc/run.sh
Normal file
@ -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"
|
||||
21
registry/coder/modules/personalize/README.md
Normal file
21
registry/coder/modules/personalize/README.md
Normal file
@ -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
|
||||
}
|
||||
```
|
||||
29
registry/coder/modules/personalize/main.test.ts
Normal file
29
registry/coder/modules/personalize/main.test.ts
Normal file
@ -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!",
|
||||
]);
|
||||
});
|
||||
});
|
||||
39
registry/coder/modules/personalize/main.tf
Normal file
39
registry/coder/modules/personalize/main.tf
Normal file
@ -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
|
||||
}
|
||||
27
registry/coder/modules/personalize/run.sh
Normal file
27
registry/coder/modules/personalize/run.sh
Normal file
@ -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
|
||||
85
registry/coder/modules/slackme/README.md
Normal file
85
registry/coder/modules/slackme/README.md
Normal file
@ -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://<your coder deployment>/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="<your client id>
|
||||
CODER_EXTERNAL_AUTH_1_CLIENT_SECRET="<your 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 = <<EOF
|
||||
👋 Hey there from Coder! $COMMAND took $DURATION to execute!
|
||||
EOF
|
||||
}
|
||||
```
|
||||
166
registry/coder/modules/slackme/main.test.ts
Normal file
166
registry/coder/modules/slackme/main.test.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { serve } from "bun";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
createJSONResponse,
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
writeCoder,
|
||||
} from "../test";
|
||||
|
||||
describe("slackme", async () => {
|
||||
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<string, string> = {},
|
||||
) => {
|
||||
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);
|
||||
};
|
||||
46
registry/coder/modules/slackme/main.tf
Normal file
46
registry/coder/modules/slackme/main.tf
Normal file
@ -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 = <<OUTER
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
CODER_DIR=$(dirname $(which coder))
|
||||
cat > $CODER_DIR/slackme <<INNER
|
||||
${replace(templatefile("${path.module}/slackme.sh", {
|
||||
PROVIDER_ID : var.auth_provider_id,
|
||||
SLACK_MESSAGE : replace(var.slack_message, "`", "\\`"),
|
||||
}), "$", "\\$")}
|
||||
INNER
|
||||
|
||||
chmod +x $CODER_DIR/slackme
|
||||
OUTER
|
||||
}
|
||||
88
registry/coder/modules/slackme/slackme.sh
Normal file
88
registry/coder/modules/slackme/slackme.sh
Normal file
@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
PROVIDER_ID=${PROVIDER_ID}
|
||||
SLACK_MESSAGE=$(
|
||||
cat << "EOF"
|
||||
${SLACK_MESSAGE}
|
||||
EOF
|
||||
)
|
||||
SLACK_URL=$${SLACK_URL:-https://slack.com}
|
||||
|
||||
usage() {
|
||||
cat << EOF
|
||||
slackme — Send a Slack notification when a command finishes
|
||||
Usage: slackme <command>
|
||||
|
||||
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"
|
||||
83
registry/coder/modules/vault-github/README.md
Normal file
83
registry/coder/modules/vault-github/README.md
Normal file
@ -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"
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 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"
|
||||
}
|
||||
```
|
||||
11
registry/coder/modules/vault-github/main.test.ts
Normal file
11
registry/coder/modules/vault-github/main.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
68
registry/coder/modules/vault-github/main.tf
Normal file
68
registry/coder/modules/vault-github/main.tf
Normal file
@ -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
|
||||
}
|
||||
119
registry/coder/modules/vault-github/run.sh
Normal file
119
registry/coder/modules/vault-github/run.sh
Normal file
@ -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"
|
||||
81
registry/coder/modules/vault-jwt/README.md
Normal file
81
registry/coder/modules/vault-jwt/README.md
Normal file
@ -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"
|
||||
}
|
||||
```
|
||||
12
registry/coder/modules/vault-jwt/main.test.ts
Normal file
12
registry/coder/modules/vault-jwt/main.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
64
registry/coder/modules/vault-jwt/main.tf
Normal file
64
registry/coder/modules/vault-jwt/main.tf
Normal file
@ -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" {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user