Compare commits

..

1 Commits

Author SHA1 Message Date
Parkreiner 22146fc024 wip: add deliberately broken code to script 1 year ago

@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

@ -1,203 +0,0 @@
#!/usr/bin/env bash
set -o pipefail
set -u
VERBOSE="${VERBOSE:-0}"
if [[ "${VERBOSE}" -ne "0" ]]; then
set -x
fi
# List of required environment variables
required_vars=(
"INSTATUS_API_KEY"
"INSTATUS_PAGE_ID"
"INSTATUS_COMPONENT_ID"
"VERCEL_API_KEY"
)
# Check if each required variable is set
for var in "${required_vars[@]}"; do
if [[ -z "${!var:-}" ]]; then
echo "Error: Environment variable '$var' is not set."
exit 1
fi
done
REGISTRY_BASE_URL="${REGISTRY_BASE_URL:-https://registry.coder.com}"
status=0
declare -a modules=()
declare -a failures=()
# Collect all module directories containing a main.tf file
for path in $(find . -maxdepth 2 -not -path '*/.*' -type f -name main.tf | cut -d '/' -f 2 | sort -u); do
modules+=("${path}")
done
echo "Checking modules: ${modules[*]}"
# Function to update the component status on Instatus
update_component_status() {
local component_status=$1
# see https://instatus.com/help/api/components
(curl -X PUT "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/components/$INSTATUS_COMPONENT_ID" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"status\": \"$component_status\"}")
}
# Function to create an incident
create_incident() {
local incident_name="Degraded Service"
local message="The following modules are experiencing issues:\n"
for i in "${!failures[@]}"; do
message+="$((i + 1)). ${failures[$i]}\n"
done
component_status="PARTIALOUTAGE"
if (( ${#failures[@]} == ${#modules[@]} )); then
component_status="MAJOROUTAGE"
fi
# see https://instatus.com/help/api/incidents
incident_id=$(curl -s -X POST "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$incident_name\",
\"message\": \"$message\",
\"components\": [\"$INSTATUS_COMPONENT_ID\"],
\"status\": \"INVESTIGATING\",
\"notify\": true,
\"statuses\": [
{
\"id\": \"$INSTATUS_COMPONENT_ID\",
\"status\": \"PARTIALOUTAGE\"
}
]
}" | jq -r '.id')
echo "Created incident with ID: $incident_id"
}
# Function to check for existing unresolved incidents
check_existing_incident() {
# Fetch the latest incidents with status not equal to "RESOLVED"
local unresolved_incidents=$(curl -s -X GET "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" | jq -r '.incidents[] | select(.status != "RESOLVED") | .id')
if [[ -n "$unresolved_incidents" ]]; then
echo "Unresolved incidents found: $unresolved_incidents"
return 0 # Indicate that there are unresolved incidents
else
echo "No unresolved incidents found."
return 1 # Indicate that no unresolved incidents exist
fi
}
force_redeploy_registry () {
# These are not secret values; safe to just expose directly in script
local VERCEL_TEAM_SLUG="codercom"
local VERCEL_TEAM_ID="team_tGkWfhEGGelkkqUUm9nXq17r"
local VERCEL_APP="registry"
local latest_res
latest_res=$(curl "https://api.vercel.com/v6/deployments?app=$VERCEL_APP&limit=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID&target=production&state=BUILDING,INITIALIZING,QUEUED,READY" \
--fail \
--silent \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json"
)
# If we have zero deployments, something is VERY wrong. Make the whole
# script exit with a non-zero status code
local latest_id
latest_id=$(echo "${latest_res}" | jq -r '.deployments[0].uid')
if [[ "${latest_id}" = "null" ]]; then
echo "Unable to pull any previous deployments for redeployment"
echo "Please redeploy the latest deployment manually in Vercel."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_date_ts_seconds
latest_date_ts_seconds=$(echo "${latest_res}" | jq -r '.deployments[0].createdAt/1000|floor')
local current_date_ts_seconds
current_date_ts_seconds="$(date +%s)"
local max_redeploy_interval_seconds=7200 # 2 hours
if (( current_date_ts_seconds - latest_date_ts_seconds < max_redeploy_interval_seconds )); then
echo "The registry was deployed less than 2 hours ago."
echo "Not automatically re-deploying the regitstry."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_deployment_state
latest_deployment_state="$(echo "${latest_res}" | jq -r '.deployments[0].state')"
if [[ "${latest_deployment_state}" != "READY" ]]; then
echo "Last deployment was not in READY state. Skipping redeployment."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
echo "============================================================="
echo "!!! Redeploying registry with deployment ID: ${latest_id} !!!"
echo "============================================================="
if ! curl -X POST "https://api.vercel.com/v13/deployments?forceNew=1&skipAutoDetectionConfirmation=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID" \
--fail \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json" \
--data-raw "{ \"deploymentId\": \"${latest_id}\", \"name\": \"${VERCEL_APP}\", \"target\": \"production\" }"; then
echo "DEPLOYMENT FAILED! Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
}
# Check each module's accessibility
for module in "${modules[@]}"; do
# Trim leading/trailing whitespace from module name
module=$(echo "${module}" | xargs)
url="${REGISTRY_BASE_URL}/modules/${module}"
printf "=== Checking module %s at %s\n" "${module}" "${url}"
status_code=$(curl --output /dev/null --head --silent --fail --location "${url}" --retry 3 --write-out "%{http_code}")
if (( status_code != 200 )); then
printf "==> FAIL(%s)\n" "${status_code}"
status=1
failures+=("${module}")
else
printf "==> OK(%s)\n" "${status_code}"
fi
done
# Determine overall status and update Instatus component
if (( status == 0 )); then
echo "All modules are operational."
# set to
update_component_status "OPERATIONAL"
else
echo "The following modules have issues: ${failures[*]}"
# check if all modules are down
if (( ${#failures[@]} == ${#modules[@]} )); then
update_component_status "MAJOROUTAGE"
else
update_component_status "PARTIALOUTAGE"
fi
# Check if there is an existing incident before creating a new one
if ! check_existing_incident; then
create_incident
fi
# If a module is down, force a reployment to try getting things back online
# ASAP
# EDIT: registry.coder.com is no longer hosted on vercel
#force_redeploy_registry
fi
exit "${status}"

@ -1,23 +0,0 @@
name: Health
# Check modules health on registry.coder.com
on:
schedule:
- cron: "0,15,30,45 * * * *" # Runs every 15 minutes
workflow_dispatch: # Allows manual triggering of the workflow if needed
jobs:
run-script:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run check.sh
run: |
./.github/scripts/check.sh
env:
INSTATUS_API_KEY: ${{ secrets.INSTATUS_API_KEY }}
INSTATUS_PAGE_ID: ${{ secrets.INSTATUS_PAGE_ID }}
INSTATUS_COMPONENT_ID: ${{ secrets.INSTATUS_COMPONENT_ID }}
VERCEL_API_KEY: ${{ secrets.VERCEL_API_KEY }}

@ -16,31 +16,18 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@main
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
with:
# We're using the latest version of Bun for now, but it might be worth
# reconsidering. They've pushed breaking changes in patch releases
# that have broken our CI.
# Our PR where issues started to pop up: https://github.com/coder/modules/pull/383
# The Bun PR that broke things: https://github.com/oven-sh/bun/pull/16067
bun-version: latest
- name: Install dependencies
- name: Setup
run: bun install
- name: Run tests
run: bun test
- run: bun test
pretty:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to get tags
- uses: coder/coder/.github/actions/setup-tf@main
- uses: oven-sh/setup-bun@v2
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Setup
@ -51,16 +38,3 @@ jobs:
uses: crate-ci/typos@v1.17.2
- name: Lint
run: bun lint
- name: Check version
shell: bash
run: |
# check for version changes
./update-version.sh
# Check if any changes were made in README.md files
if [[ -n "$(git status --porcelain -- '**/README.md')" ]]; then
echo "Version mismatch detected. Please run ./update-version.sh and commit the updated README.md files."
git diff -- '**/README.md'
exit 1
else
echo "No version mismatch detected. All versions are up to date."
fi

@ -1,37 +0,0 @@
name: deploy-registry
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
# Set id-token permission for gcloud
# Adding a comment because retriggering the build manually hung? I am the lord of devops and you will bend?
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935
with:
workload_identity_provider: projects/309789351055/locations/global/workloadIdentityPools/github-actions/providers/github
service_account: registry-v2-github@coder-registry-1.iam.gserviceaccount.com
- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a
# For the time being, let's have the first couple merges to main in modules deploy a new version
# to *dev*. Once we review and make sure everything's working, we can deploy a new version to *main*.
# Maybe in the future we could automate this based on the result of E2E tests.
- name: Deploy to dev.registry.coder.com
run: |
gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch dev

@ -0,0 +1,42 @@
name: Update README on Tag
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
update-readme:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get the latest tag
id: get-latest-tag
run: echo "TAG=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_OUTPUT
- name: Run update script
run: ./update-version.sh
- name: Create Pull Request
id: create-pr
uses: peter-evans/create-pull-request@v5
with:
commit-message: 'chore: bump version to ${{ env.TAG }} in README.md files'
title: 'chore: bump version to ${{ env.TAG }} in README.md files'
body: 'This is an auto-generated PR to update README.md files of all modules with the new tag ${{ env.TAG }}'
branch: 'update-readme-branch'
base: 'main'
env:
TAG: ${{ steps.get-latest-tag.outputs.TAG }}
- name: Auto-approve
uses: hmarr/auto-approve-action@v4
if: github.ref == 'refs/heads/update-readme-branch'

5
.gitignore vendored

@ -1,7 +1,4 @@
.terraform*
node_modules
*.tfstate
*.tfstate.lock.info
# Ignore generated credentials from google-github-actions/auth
gha-creds-*.json
*.tfstate.lock.info

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.5 MiB

@ -1 +0,0 @@
<svg width="82" height="80" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" overflow="hidden"><g transform="translate(-550 -124)"><g><g><g><g><path d="M551 124 631 124 631 204 551 204Z" fill="#ED7100" fill-rule="evenodd" fill-opacity="1"/><path d="M612.069 162.386C607.327 165.345 600.717 168.353 593.46 170.855 588.339 172.62 583.33 173.978 578.865 174.838 582.727 184.68 589.944 191.037 596.977 189.853 603.514 188.75 608.387 181.093 609.1 170.801L611.096 170.939C610.304 182.347 604.893 190.545 597.309 191.825 596.648 191.937 595.984 191.991 595.323 191.991 587.945 191.991 580.718 185.209 576.871 175.194 575.733 175.38 574.625 175.542 573.584 175.653 572.173 175.803 570.901 175.879 569.769 175.879 565.95 175.879 563.726 175.025 563.141 173.328 562.414 171.218 564.496 168.566 569.328 165.445L570.414 167.125C565.704 170.167 564.814 172.046 565.032 172.677 565.263 173.348 567.279 174.313 573.372 173.665 574.267 173.57 575.216 173.433 576.187 173.28 575.537 171.297 575.014 169.205 574.647 167.028 573.406 159.673 574.056 152.438 576.48 146.654 578.969 140.715 583.031 136.99 587.917 136.166 593.803 135.171 600.075 138.691 604.679 145.579L603.017 146.69C598.862 140.476 593.349 137.28 588.249 138.138 584.063 138.844 580.539 142.143 578.325 147.427 576.046 152.866 575.44 159.709 576.62 166.695 576.988 168.876 577.515 170.966 578.173 172.937 582.618 172.1 587.651 170.742 592.807 168.965 599.927 166.51 606.392 163.572 611.01 160.689 616.207 157.447 617.201 155.444 616.969 154.772 616.769 154.189 615.095 153.299 610.097 153.653L609.957 151.657C615.171 151.289 618.171 152.116 618.86 154.12 619.619 156.32 617.334 159.101 612.069 162.386" fill="#FFFFFF" fill-rule="evenodd" fill-opacity="1"/></g></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

@ -13,7 +13,6 @@ tags: [helper]
```tf
module "MODULE_NAME" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/MODULE_NAME/coder"
version = "1.0.2"
}
@ -29,7 +28,6 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
```tf
module "MODULE_NAME" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/MODULE_NAME/coder"
version = "1.0.2"
agent_id = coder_agent.example.id
@ -47,7 +45,6 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
```tf
module "MODULE_NAME" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/MODULE_NAME/coder"
version = "1.0.2"
agent_id = coder_agent.example.id

@ -1,75 +1,30 @@
# Contributing
## Getting started
This repo uses the [Bun runtime](https://bun.sh/) to to run all code and tests. To install Bun, you can run this command on Linux/MacOS:
```shell
curl -fsSL https://bun.sh/install | bash
```
Or this command on Windows:
To create a new module, clone this repository and run:
```shell
powershell -c "irm bun.sh/install.ps1 | iex"
```
Follow the instructions to ensure that Bun is available globally. Once Bun has been installed, clone this repository. From there, run this script to create a new module:
```shell
./new.sh NAME_OF_NEW_MODULE
./new.sh MODULE_NAME
```
## Testing a Module
> **Note:** It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR.
A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers.
The testing suite must be able to run docker containers with the `--network=host` flag. This typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS and Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop.
Reference the existing `*.test.ts` files to get an idea for how to set up tests.
The testing suite must be able to run docker containers with the `--network=host` flag, which typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS and Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop.
You can run all tests in a specific file with this command:
Reference existing `*.test.ts` files for implementation.
```shell
# Run tests for a specific module!
$ bun test -t '<module>'
```
Or run all tests by running this command:
```shell
$ bun test
```
You can test a module locally by updating the source as follows
```tf
module "example" {
source = "git::https://github.com/<USERNAME>/<REPO>.git//<MODULE-NAME>?ref=<BRANCH-NAME>"
# You may need to remove the 'version' field, it is incompatible with some sources.
}
```
## Releases
> [!WARNING]
> When creating a new release, make sure that your new version number is fully accurate. If a version number is incorrect or does not exist, we may end up serving incorrect/old data for our various tools and providers.
Much of our release process is automated. To cut a new release:
1. Navigate to [GitHub's Releases page](https://github.com/coder/modules/releases)
2. Click "Draft a new release"
3. Click the "Choose a tag" button and type a new release number in the format `v<major>.<minor>.<patch>` (e.g., `v1.18.0`). Then click "Create new tag".
4. Click the "Generate release notes" button, and clean up the resulting README. Be sure to remove any notes that would not be relevant to end-users (e.g., bumping dependencies).
5. Once everything looks good, click the "Publish release" button.
Once the release has been cut, a script will run to check whether there are any modules that will require that the new release number be published to Terraform. If there are any, a new pull request will automatically be generated. Be sure to approve this PR and merge it into the `main` branch.
Following that, our automated processes will handle publishing new data for [`registry.coder.com`](https://github.com/coder/registry.coder.com/):
1. Publishing new versions to Coder's [Terraform Registry](https://registry.terraform.io/providers/coder/coder/latest)
2. Publishing new data to the [Coder Registry](https://registry.coder.com)
> [!NOTE]
> Some data in `registry.coder.com` is fetched on demand from the Module repo's main branch. This data should be updated almost immediately after a new release, but other changes will take some time to propagate.
> **Note:** This is the responsibility of the module author to implement tests for their module. and test the module locally before submitting a PR.

@ -3,21 +3,19 @@
Modules
</h1>
[Module Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise)
[Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise)
[![discord](https://img.shields.io/discord/747933592273027093?label=discord)](https://discord.gg/coder)
[![license](https://img.shields.io/github/license/coder/modules)](./LICENSE)
[![Health](https://github.com/coder/modules/actions/workflows/check.yaml/badge.svg)](https://github.com/coder/modules/actions/workflows/check.yaml)
</div>
Modules extend Coder Templates to create reusable components for your development environment.
Modules extend Templates to create reusable components for your development environment.
e.g.
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.2"
agent_id = coder_agent.main.id

@ -1,49 +0,0 @@
---
display_name: Amazon DCV Windows
description: Amazon DCV Server and Web Client for Windows
icon: ../.icons/dcv.svg
maintainer_github: coder
verified: true
tags: [windows, amazon, dcv, web, desktop]
---
# Amazon DCV Windows
Amazon DCV is high performance remote display protocol that provides a secure way to deliver remote desktop and application streaming from any cloud or data center to any device, over varying network conditions.
![Amazon DCV on a Windows workspace](../.images/amazon-dcv-windows.png)
Enable DCV Server and Web Client on Windows workspaces.
```tf
module "dcv" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/amazon-dcv-windows/coder"
version = "1.0.24"
agent_id = resource.coder_agent.main.id
}
resource "coder_metadata" "dcv" {
count = data.coder_workspace.me.start_count
resource_id = aws_instance.dev.id # id of the instance resource
item {
key = "DCV client instructions"
value = "Run `coder port-forward ${data.coder_workspace.me.name} -p ${module.dcv[count.index].port}` and connect to **localhost:${module.dcv[count.index].port}${module.dcv[count.index].web_url_path}**"
}
item {
key = "username"
value = module.dcv[count.index].username
}
item {
key = "password"
value = module.dcv[count.index].password
sensitive = true
}
}
```
## License
Amazon DCV is free to use on AWS EC2 instances but requires a license for other cloud providers. Please see the instructions [here](https://docs.aws.amazon.com/dcv/latest/adminguide/setting-up-license.html#setting-up-license-ec2) for more information.

@ -1,170 +0,0 @@
# 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"

@ -1,85 +0,0 @@
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
}

@ -14,7 +14,6 @@ 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

@ -16,7 +16,6 @@ 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"
@ -37,7 +36,6 @@ 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"
@ -64,7 +62,6 @@ 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"]

@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,

@ -13,7 +13,6 @@ This module adds a parameter with all Azure regions, allowing developers to sele
```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"
@ -34,7 +33,6 @@ 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 = {
@ -58,7 +56,6 @@ 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 = [

@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,

Binary file not shown.

@ -13,9 +13,8 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.29"
version = "1.0.15"
agent_id = coder_agent.example.id
}
```
@ -28,9 +27,8 @@ module "code-server" {
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.29"
version = "1.0.15"
agent_id = coder_agent.example.id
install_version = "4.8.3"
}
@ -42,9 +40,8 @@ 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.29"
version = "1.0.15"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@ -56,13 +53,12 @@ Enter the `<author>.<name>` into the extensions array and code-server will autom
### Pre-configure Settings
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file:
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file:
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.29"
version = "1.0.15"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@ -77,9 +73,8 @@ 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.29"
version = "1.0.15"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@ -93,9 +88,8 @@ 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.29"
version = "1.0.15"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@ -106,9 +100,8 @@ 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.29"
version = "1.0.15"
agent_id = coder_agent.example.id
offline = true
}

@ -39,7 +39,7 @@ variable "slug" {
}
variable "settings" {
type = any
type = map(string)
description = "A map of settings to apply to code-server."
default = {}
}
@ -113,15 +113,6 @@ variable "auto_install_extensions" {
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"
@ -163,7 +154,7 @@ resource "coder_app" "code-server" {
display_name = var.display_name
url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : ""}"
icon = "/icon/code.svg"
subdomain = var.subdomain
subdomain = false
share = var.share
order = var.order

@ -10,7 +10,6 @@ CODE_SERVER="${INSTALL_PREFIX}/bin/code-server"
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
mkdir -p "${EXTENSIONS_DIR}"
fi
function run_code_server() {
@ -104,8 +103,7 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
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[]')
extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json)
for extension in $extensions; do
if extension_installed "$extension"; then
continue

@ -13,7 +13,6 @@ 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

@ -1,5 +1,10 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
describe("coder-login", async () => {
await runTerraformInit(import.meta.dir);

@ -1,37 +0,0 @@
---
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/cursor-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"
}
```

@ -1,88 +0,0 @@
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);
});
});

@ -1,62 +0,0 @@
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."
}

@ -17,9 +17,8 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
version = "1.0.15"
agent_id = coder_agent.example.id
}
```
@ -30,9 +29,8 @@ module "dotfiles" {
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
version = "1.0.15"
agent_id = coder_agent.example.id
}
```
@ -41,9 +39,8 @@ module "dotfiles" {
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
version = "1.0.15"
agent_id = coder_agent.example.id
user = "root"
}
@ -53,16 +50,14 @@ module "dotfiles" {
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
version = "1.0.15"
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"
version = "1.0.15"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@ -75,9 +70,8 @@ You can set a default dotfiles repository for all users by setting the `default_
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
version = "1.0.15"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}

@ -39,14 +39,9 @@ variable "coder_parameter_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
count = var.dotfiles_uri == null ? 1 : 0
type = "string"
name = "dotfiles_uri"
display_name = "Dotfiles URL"
@ -73,18 +68,6 @@ resource "coder_script" "dotfiles" {
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

@ -1,7 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
DOTFILES_URI="${DOTFILES_URI}"
DOTFILES_USER="${DOTFILES_USER}"

@ -16,7 +16,6 @@ Customize the preselected parameter value:
```tf
module "exoscale-instance-type" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-instance-type/coder"
version = "1.0.12"
default = "standard.medium"
@ -45,7 +44,6 @@ Change the display name a type using the corresponding maps:
```tf
module "exoscale-instance-type" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-instance-type/coder"
version = "1.0.12"
default = "standard.medium"
@ -80,7 +78,6 @@ Show only gpu1 types
```tf
module "exoscale-instance-type" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-instance-type/coder"
version = "1.0.12"
default = "gpu.large"

@ -16,7 +16,6 @@ Customize the preselected parameter value:
```tf
module "exoscale-zone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-zone/coder"
version = "1.0.12"
default = "ch-dk-2"
@ -44,7 +43,6 @@ Change the display name and icon for a zone using the corresponding maps:
```tf
module "exoscale-zone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/exoscale-zone/coder"
version = "1.0.12"
default = "at-vie-1"

@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,

@ -13,9 +13,8 @@ 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.29"
version = "1.0.8"
agent_id = coder_agent.example.id
}
```
@ -28,9 +27,8 @@ module "filebrowser" {
```tf
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.29"
version = "1.0.8"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@ -40,23 +38,9 @@ module "filebrowser" {
```tf
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.29"
version = "1.0.8"
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.29"
agent_id = coder_agent.example.id
agent_name = "main"
subdomain = false
}
```

@ -39,7 +39,7 @@ describe("filebrowser", async () => {
"",
"📂 Serving /root at http://localhost:13339 ",
"",
"Running 'filebrowser --noauth --root /root --port 13339 --baseurl ' ",
"Running 'filebrowser --noauth --root /root --port 13339' ",
"",
"📝 Logs at /tmp/filebrowser.log",
]);
@ -61,7 +61,7 @@ describe("filebrowser", async () => {
"",
"📂 Serving /root at http://localhost:13339 ",
"",
"Running 'filebrowser --noauth --root /root --port 13339 -d .config/filebrowser.db --baseurl ' ",
"Running 'filebrowser --noauth --root /root --port 13339 -d .config/filebrowser.db' ",
"",
"📝 Logs at /tmp/filebrowser.log",
]);
@ -74,29 +74,6 @@ describe("filebrowser", async () => {
});
const output = await executeScriptInContainer(state, "alpine");
expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"\u001b[0;1mInstalling filebrowser ",
"",
"🥳 Installation complete! ",
"",
"👷 Starting filebrowser in background... ",
"",
"📂 Serving /home/coder/project at http://localhost:13339 ",
"",
"Running 'filebrowser --noauth --root /home/coder/project --port 13339 --baseurl ' ",
"",
"📝 Logs at /tmp/filebrowser.log",
]);
});
it("runs with subdomain=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
agent_name: "main",
subdomain: false,
});
const output = await executeScriptInContainer(state, "alpine");
expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"\u001B[0;1mInstalling filebrowser ",
"",
@ -104,9 +81,9 @@ describe("filebrowser", async () => {
"",
"👷 Starting filebrowser in background... ",
"",
"📂 Serving /root at http://localhost:13339 ",
"📂 Serving /home/coder/project at http://localhost:13339 ",
"",
"Running 'filebrowser --noauth --root /root --port 13339 --baseurl /@default/default.main/apps/filebrowser' ",
"Running 'filebrowser --noauth --root /home/coder/project --port 13339' ",
"",
"📝 Logs at /tmp/filebrowser.log",
]);

@ -14,16 +14,6 @@ variable "agent_id" {
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."
@ -68,56 +58,27 @@ variable "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"
icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.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
DB_PATH : var.database_path
})
run_on_start = true
}
resource "coder_app" "filebrowser" {
agent_id = var.agent_id
slug = var.slug
slug = "filebrowser"
display_name = "File Browser"
url = local.url
icon = "/icon/filebrowser.svg"
subdomain = var.subdomain
url = "http://localhost:${var.port}"
icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg"
subdomain = true
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"
}

@ -1,13 +1,9 @@
#!/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
curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash
printf "🥳 Installation complete! \n\n"
@ -23,8 +19,8 @@ fi
printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG} --baseurl ${SERVER_BASE_PATH}' \n\n"
printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG}' \n\n"
filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG} --baseurl ${SERVER_BASE_PATH} > ${LOG_PATH} 2>&1 &
filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG} > ${LOG_PATH} 2>&1 &
printf "📝 Logs at ${LOG_PATH} \n\n"

@ -15,7 +15,6 @@ We can use the simplest format here, only adding a default selection as the `atl
```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"
@ -32,7 +31,6 @@ The regions argument can be used to display only the desired regions in the Code
```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"
@ -48,7 +46,6 @@ 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"

@ -13,7 +13,6 @@ 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"]
@ -34,7 +33,6 @@ Note: setting `gpu_only = true` and using a default region without GPU support,
```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"]
@ -51,7 +49,6 @@ resource "google_compute_instance" "example" {
```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"]
@ -67,7 +64,6 @@ resource "google_compute_instance" "example" {
```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"]

@ -13,9 +13,8 @@ This module allows you to automatically clone a repository by URL and skip if it
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
version = "1.0.12"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@ -27,9 +26,8 @@ module "git-clone" {
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
version = "1.0.12"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
base_dir = "~/projects/coder"
@ -42,9 +40,8 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
version = "1.0.12"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@ -68,33 +65,31 @@ data "coder_parameter" "git_repo" {
# 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"
version = "1.0.12"
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"
version = "1.0.12"
agent_id = coder_agent.example.id
order = 1
folder = "/home/${local.username}/${module.git_clone[count.index].folder_name}"
folder = "/home/${local.username}/${module.git_clone.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"
display_name = module.git_clone.folder_name
url = module.git_clone.web_url
icon = module.git_clone.git_provider != "" ? "/icon/${module.git_clone.git_provider}.svg" : "/icon/git.svg"
count = module.git_clone.web_url != "" ? 1 : 0
}
```
@ -102,9 +97,8 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.0.18"
version = "1.0.12"
agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = {
@ -121,9 +115,8 @@ 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"
version = "1.0.12"
agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
}
@ -133,9 +126,8 @@ 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"
version = "1.0.12"
agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = {
@ -154,29 +146,10 @@ 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"
version = "1.0.12"
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"
}
```

@ -79,22 +79,6 @@ describe("git-clone", async () => {
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",

@ -50,12 +50,6 @@ variable "branch_name" {
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, "/\\?.*/", ""), "/#.*/", "")
@ -70,7 +64,7 @@ locals {
# 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
folder_name = replace(basename(local.clone_url), ".git", "")
# 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

@ -2,16 +2,13 @@
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
maintainer_github: phorcys420
verified: false
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.
@ -21,7 +18,6 @@ This module has a chance of conflicting with the user's dotfiles / the personali
```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

@ -13,7 +13,6 @@ Runs a script that updates git credentials in the workspace to match the user's
```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
@ -28,7 +27,6 @@ TODO: Add screenshot
```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
@ -42,7 +40,6 @@ TODO: Add screenshot
```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

@ -13,7 +13,6 @@ Templates that utilize Github External Auth can automatically ensure that the Co
```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
@ -46,7 +45,6 @@ data "coder_external_auth" "github" {
}
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

@ -1,4 +1,3 @@
import { type Server, serve } from "bun";
import { describe, expect, it } from "bun:test";
import {
createJSONResponse,
@ -10,6 +9,7 @@ import {
testRequiredVariables,
writeCoder,
} from "../test";
import { Server, serve } from "bun";
describe("github-upload-public-key", async () => {
await runTerraformInit(import.meta.dir);
@ -21,12 +21,10 @@ describe("github-upload-public-key", async () => {
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, [
let exec = await execContainer(id, [
"env",
`CODER_ACCESS_URL=${url}`,
`GITHUB_API_URL=${url}`,
"CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
"GITHUB_API_URL=" + server.url.toString().slice(0, -1),
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
@ -44,12 +42,10 @@ describe("github-upload-public-key", 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, [
let exec = await execContainer(id, [
"env",
`CODER_ACCESS_URL=${url}`,
`GITHUB_API_URL=${url}`,
"CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
"GITHUB_API_URL=" + server.url.toString().slice(0, -1),
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
@ -99,7 +95,7 @@ const setupServer = async (): Promise<Server> => {
}
// case: key already exists
if (req.headers.get("Authorization") === "Bearer findkey") {
if (req.headers.get("Authorization") == "Bearer findkey") {
return createJSONResponse([
{
key: "foo",

@ -11,15 +11,12 @@ tags: [ide, jetbrains, helper, parameter]
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"
version = "1.0.13"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"]
default = "GO"
@ -34,64 +31,39 @@ module "jetbrains_gateway" {
```tf
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.28"
version = "1.0.13"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
default = "GO"
}
```
### Use the latest version of each IDE
### Use the latest release version
```tf
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jetbrains-gateway/coder"
version = "1.0.28"
version = "1.0.13"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["IU", "PY"]
default = "IU"
jetbrains_ides = ["GO", "WS"]
default = "GO"
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"
version = "1.0.13"
agent_id = coder_agent.example.id
agent_name = "example"
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
default = "GO"
@ -100,34 +72,15 @@ module "jetbrains_gateway" {
}
```
### 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/)
- GoLand (`GO`)
- WebStorm (`WS`)
- IntelliJ IDEA Ultimate (`IU`)
- PyCharm Professional (`PY`)
- PhpStorm (`PS`)
- CLion (`CL`)
- RubyMine (`RM`)
- Rider (`RD`)

@ -10,31 +10,14 @@ describe("jetbrains-gateway", async () => {
await testRequiredVariables(import.meta.dir, {
agent_id: "foo",
agent_name: "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",
agent_name: "foo",
folder: "/home/foo",
jetbrains_ides: '["IU", "GO", "PY"]',
});

@ -18,17 +18,9 @@ variable "agent_id" {
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 = ""
description = "Agent name."
}
variable "folder" {
@ -82,63 +74,59 @@ variable "jetbrains_ide_versions" {
description = "The set of versions for each jetbrains IDE"
default = {
"IU" = {
build_number = "243.21565.193"
version = "2024.3"
build_number = "241.14494.240"
version = "2024.1"
}
"PS" = {
build_number = "243.21565.202"
version = "2024.3"
build_number = "241.14494.237"
version = "2024.1"
}
"WS" = {
build_number = "243.21565.180"
version = "2024.3"
build_number = "241.14494.235"
version = "2024.1"
}
"PY" = {
build_number = "243.21565.199"
version = "2024.3"
build_number = "241.14494.241"
version = "2024.1"
}
"CL" = {
build_number = "243.21565.238"
build_number = "241.14494.288"
version = "2024.1"
}
"GO" = {
build_number = "243.21565.208"
version = "2024.3"
build_number = "241.14494.238"
version = "2024.1"
}
"RM" = {
build_number = "243.21565.197"
version = "2024.3"
build_number = "241.14494.234"
version = "2024.1"
}
"RD" = {
build_number = "243.21565.191"
version = "2024.3"
}
"RR" = {
build_number = "243.22562.230"
version = "2024.3"
build_number = "241.14494.307"
version = "2024.1"
}
}
validation {
condition = (
alltrue([
for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code)
for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"], 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"])}."
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"])}."
}
}
variable "jetbrains_ides" {
type = list(string)
description = "The list of IDE product codes."
default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"]
default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"]
validation {
condition = (
alltrue([
for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code)
for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"], 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"])}."
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"])}."
}
# check if the list is empty
validation {
@ -152,29 +140,9 @@ variable "jetbrains_ides" {
}
}
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}"
url = "https://data.services.jetbrains.com/products/releases?code=${each.key}&latest=true&type=${var.channel}"
}
locals {
@ -184,7 +152,7 @@ locals {
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"
download_link = "https://download.jetbrains.com/go/goland-${var.jetbrains_ide_versions["GO"].version}.tar.gz"
version = var.jetbrains_ide_versions["GO"].version
},
"WS" = {
@ -192,7 +160,7 @@ locals {
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"
download_link = "https://download.jetbrains.com/webstorm/WebStorm-${var.jetbrains_ide_versions["WS"].version}.tar.gz"
version = var.jetbrains_ide_versions["WS"].version
},
"IU" = {
@ -200,7 +168,7 @@ locals {
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"
download_link = "https://download.jetbrains.com/idea/ideaIU-${var.jetbrains_ide_versions["IU"].version}.tar.gz"
version = var.jetbrains_ide_versions["IU"].version
},
"PY" = {
@ -208,7 +176,7 @@ locals {
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"
download_link = "https://download.jetbrains.com/python/pycharm-professional-${var.jetbrains_ide_versions["PY"].version}.tar.gz"
version = var.jetbrains_ide_versions["PY"].version
},
"CL" = {
@ -216,7 +184,7 @@ locals {
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"
download_link = "https://download.jetbrains.com/cpp/CLion-${var.jetbrains_ide_versions["CL"].version}.tar.gz"
version = var.jetbrains_ide_versions["CL"].version
},
"PS" = {
@ -224,7 +192,7 @@ locals {
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"
download_link = "https://download.jetbrains.com/webide/PhpStorm-${var.jetbrains_ide_versions["PS"].version}.tar.gz"
version = var.jetbrains_ide_versions["PS"].version
},
"RM" = {
@ -232,24 +200,16 @@ locals {
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"
download_link = "https://download.jetbrains.com/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"
download_link = "https://download.jetbrains.com/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
}
}
@ -283,11 +243,10 @@ data "coder_parameter" "jetbrains_ide" {
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_app" "gateway" {
agent_id = var.agent_id
slug = var.slug
slug = "gateway"
display_name = local.display_name
icon = local.icon
external = true
@ -295,8 +254,8 @@ resource "coder_app" "gateway" {
url = join("", [
"jetbrains-gateway://connect#type=coder&workspace=",
data.coder_workspace.me.name,
"&owner=",
data.coder_workspace_owner.me.name,
"&agent=",
var.agent_name,
"&folder=",
var.folder,
"&url=",

@ -1,5 +0,0 @@
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 ~}

@ -16,18 +16,16 @@ Install the JF CLI and authenticate package managers with Artifactory using OAut
```tf
module "jfrog" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jfrog-oauth/coder"
version = "1.0.19"
version = "1.0.15"
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"]
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
}
}
```
@ -45,15 +43,14 @@ Configure the Python pip package manager to fetch packages from Artifactory whil
```tf
module "jfrog" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jfrog-oauth/coder"
version = "1.0.19"
version = "1.0.15"
agent_id = coder_agent.example.id
jfrog_url = "https://example.jfrog.io"
username_field = "email"
package_managers = {
pypi = ["pypi"]
"pypi" : "pypi"
}
}
```
@ -74,17 +71,16 @@ The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extensio
```tf
module "jfrog" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/jfrog-oauth/coder"
version = "1.0.19"
version = "1.0.15"
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"]
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
}
}
```
@ -98,8 +94,8 @@ 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, "")
username = module.jfrog.username
password = module.jfrog.access_token
}
}
```

@ -1,129 +1,19 @@
import { describe, expect, it } from "bun:test";
import { serve } from "bun";
import { describe } from "bun:test";
import {
findResourceInstance,
createJSONResponse,
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',
);
testRequiredVariables(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: "http://localhost:8081",
package_managers: "{}",
});
});
//TODO add more tests

@ -53,51 +53,23 @@ variable "configure_code_server" {
}
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
type = map(string)
description = <<EOF
A map of package manager names to their respective artifactory repositories.
For example:
{
"npm": "YOUR_NPM_REPO_KEY",
"go": "YOUR_GO_REPO_KEY",
"pypi": "YOUR_PYPI_REPO_KEY",
"docker": "YOUR_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 })
)
jfrog_host = replace(var.jfrog_url, "https://", "")
}
data "coder_workspace" "me" {}
@ -111,22 +83,19 @@ 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))
}
))
script = templatefile("${path.module}/run.sh", {
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,
CONFIGURE_CODE_SERVER : var.configure_code_server,
REPOSITORY_NPM : lookup(var.package_managers, "npm", ""),
REPOSITORY_GO : lookup(var.package_managers, "go", ""),
REPOSITORY_PYPI : lookup(var.package_managers, "pypi", ""),
REPOSITORY_DOCKER : lookup(var.package_managers, "docker", ""),
})
run_on_start = true
}
@ -152,13 +121,10 @@ resource "coder_env" "jfrog_ide_store_connection" {
}
resource "coder_env" "goproxy" {
count = length(var.package_managers.go) == 0 ? 0 : 1
count = lookup(var.package_managers, "go", "") == "" ? 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}"
])
value = "https://${local.username}:${data.coder_external_auth.jfrog.access_token}@${local.jfrog_host}/artifactory/api/go/${lookup(var.package_managers, "go", "")}"
}
output "access_token" {

@ -1,6 +0,0 @@
[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 ~}

@ -2,21 +2,6 @@
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."
@ -35,47 +20,52 @@ echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFRO
jf c use "${JFROG_SERVER_ID}"
# Configure npm to use the Artifactory "npm" repository.
if [ -z "${HAS_NPM}" ]; then
not_configured npm
if [ -z "${REPOSITORY_NPM}" ]; then
echo "🤔 no npm repository is set, skipping npm configuration."
echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input."
else
echo "📦 Configuring npm..."
jf npmc --global --repo-resolve "${REPOSITORY_NPM}"
cat << EOF > ~/.npmrc
${NPMRC}
email=${ARTIFACTORY_EMAIL}
registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM}
EOF
config_complete
echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc
fi
# Configure the `pip` to use the Artifactory "python" repository.
if [ -z "${HAS_PYPI}" ]; then
not_configured pypi
if [ -z "${REPOSITORY_PYPI}" ]; then
echo "🤔 no pypi repository is set, skipping pip configuration."
echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input."
else
echo "🐍 Configuring pip..."
echo "📦 Configuring pip..."
jf pipc --global --repo-resolve "${REPOSITORY_PYPI}"
mkdir -p ~/.pip
cat << EOF > ~/.pip/pip.conf
${PIP_CONF}
[global]
index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple
EOF
config_complete
fi
# Configure Artifactory "go" repository.
if [ -z "${HAS_GO}" ]; then
not_configured go
if [ -z "${REPOSITORY_GO}" ]; then
echo "🤔 no go repository is set, skipping go configuration."
echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input."
else
echo "🐹 Configuring go..."
jf goc --global --repo-resolve "${REPOSITORY_GO}"
config_complete
fi
echo "🥳 Configuration complete!"
# Configure the JFrog CLI to use the Artifactory "docker" repository.
if [ -z "${HAS_DOCKER}" ]; then
not_configured docker
if [ -z "${REPOSITORY_DOCKER}" ]; then
echo "🤔 no docker repository is set, skipping docker configuration."
echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input."
else
if command -v docker > /dev/null 2>&1; then
echo "🔑 Configuring 🐳 docker credentials..."
mkdir -p ~/.docker
${REGISTER_DOCKER}
echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin
else
echo "🤔 no docker is installed, skipping docker configuration."
fi
@ -106,19 +96,20 @@ echo "📦 Configuring JFrog CLI completion..."
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
if ! grep -q "# jf CLI shell completion" ~/.bashrc; then
echo "" >> ~/.bashrc
echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.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
if ! grep -q "# jf CLI shell completion" ~/.zshrc; then
echo "" >> ~/.zshrc
echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.zshrc
echo "autoload -Uz compinit" >> ~/.zshrc
echo "compinit" >> ~/.zshrc
echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc

@ -1,5 +0,0 @@
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 ~}

@ -15,15 +15,14 @@ Install the JF CLI and authenticate package managers with Artifactory using Arti
```tf
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
version = "1.0.19"
version = "1.0.15"
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"]
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
}
}
```
@ -42,14 +41,14 @@ For detailed instructions, please see this [guide](https://coder.com/docs/v2/lat
```tf
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
version = "1.0.19"
version = "1.0.15"
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"]
"npm" : "npm-local",
"go" : "go-local",
"pypi" : "pypi-local"
}
}
```
@ -75,15 +74,15 @@ The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extensio
```tf
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
version = "1.0.19"
version = "1.0.15"
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"]
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
}
}
```
@ -95,13 +94,15 @@ data "coder_workspace" "me" {}
module "jfrog" {
source = "registry.coder.com/modules/jfrog-token/coder"
version = "1.0.19"
version = "1.0.15"
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"]
"npm" : "npm",
"go" : "go",
"pypi" : "pypi"
}
}
```

@ -1,29 +1,12 @@
import { serve } from "bun";
import { describe, expect, it } from "bun:test";
import { describe } 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;
jfrog_server_id?: string;
configure_code_server?: boolean;
};
await runTerraformInit(import.meta.dir);
// Run a fake JFrog server so the provider can initialize
@ -49,116 +32,10 @@ describe("jfrog-token", async () => {
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',
);
testRequiredVariables(import.meta.dir, {
agent_id: "some-agent-id",
jfrog_url: "http://" + fakeFrogHost.hostname + ":" + fakeFrogHost.port,
artifactory_access_token: "XXXX",
package_managers: "{}",
});
});

@ -80,51 +80,23 @@ variable "configure_code_server" {
}
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
type = map(string)
description = <<EOF
A map of package manager names to their respective artifactory repositories.
For example:
{
"npm": "YOUR_NPM_REPO_KEY",
"go": "YOUR_GO_REPO_KEY",
"pypi": "YOUR_PYPI_REPO_KEY",
"docker": "YOUR_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 = 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 })
)
jfrog_host = replace(var.jfrog_url, "https://", "")
}
# Configure the Artifactory provider
@ -151,22 +123,19 @@ 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))
}
))
script = templatefile("${path.module}/run.sh", {
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,
CONFIGURE_CODE_SERVER : var.configure_code_server,
REPOSITORY_NPM : lookup(var.package_managers, "npm", ""),
REPOSITORY_GO : lookup(var.package_managers, "go", ""),
REPOSITORY_PYPI : lookup(var.package_managers, "pypi", ""),
REPOSITORY_DOCKER : lookup(var.package_managers, "docker", ""),
})
run_on_start = true
}
@ -192,13 +161,10 @@ resource "coder_env" "jfrog_ide_store_connection" {
}
resource "coder_env" "goproxy" {
count = length(var.package_managers.go) == 0 ? 0 : 1
count = lookup(var.package_managers, "go", "") == "" ? 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}"
])
value = "https://${local.username}:${artifactory_scoped_token.me.access_token}@${local.jfrog_host}/artifactory/api/go/${lookup(var.package_managers, "go", "")}"
}
output "access_token" {

@ -1,6 +0,0 @@
[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 ~}

@ -2,21 +2,6 @@
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."
@ -26,7 +11,8 @@ else
sudo chmod 755 /usr/local/bin/jf
fi
# The jf CLI checks $CI when determining whether to use interactive flows.
# 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}"
@ -34,47 +20,52 @@ echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFRO
jf c use "${JFROG_SERVER_ID}"
# Configure npm to use the Artifactory "npm" repository.
if [ -z "${HAS_NPM}" ]; then
not_configured npm
if [ -z "${REPOSITORY_NPM}" ]; then
echo "🤔 no npm repository is set, skipping npm configuration."
echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input."
else
echo "📦 Configuring npm..."
jf npmc --global --repo-resolve "${REPOSITORY_NPM}"
cat << EOF > ~/.npmrc
${NPMRC}
email=${ARTIFACTORY_EMAIL}
registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM}
EOF
config_complete
echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc
fi
# Configure the `pip` to use the Artifactory "python" repository.
if [ -z "${HAS_PYPI}" ]; then
not_configured pypi
if [ -z "${REPOSITORY_PYPI}" ]; then
echo "🤔 no pypi repository is set, skipping pip configuration."
echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input."
else
echo "🐍 Configuring pip..."
jf pipc --global --repo-resolve "${REPOSITORY_PYPI}"
mkdir -p ~/.pip
cat << EOF > ~/.pip/pip.conf
${PIP_CONF}
[global]
index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple
EOF
config_complete
fi
# Configure Artifactory "go" repository.
if [ -z "${HAS_GO}" ]; then
not_configured go
if [ -z "${REPOSITORY_GO}" ]; then
echo "🤔 no go repository is set, skipping go configuration."
echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input."
else
echo "🐹 Configuring go..."
jf goc --global --repo-resolve "${REPOSITORY_GO}"
config_complete
fi
echo "🥳 Configuration complete!"
# Configure the JFrog CLI to use the Artifactory "docker" repository.
if [ -z "${HAS_DOCKER}" ]; then
not_configured docker
if [ -z "${REPOSITORY_DOCKER}" ]; then
echo "🤔 no docker repository is set, skipping docker configuration."
echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input."
else
if command -v docker > /dev/null 2>&1; then
echo "🔑 Configuring 🐳 docker credentials..."
mkdir -p ~/.docker
${REGISTER_DOCKER}
echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin
else
echo "🤔 no docker is installed, skipping docker configuration."
fi
@ -105,19 +96,20 @@ echo "📦 Configuring JFrog CLI completion..."
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
if ! grep -q "# jf CLI shell completion" ~/.bashrc; then
echo "" >> ~/.bashrc
echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.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
if ! grep -q "# jf CLI shell completion" ~/.zshrc; then
echo "" >> ~/.zshrc
echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.zshrc
echo "autoload -Uz compinit" >> ~/.zshrc
echo "compinit" >> ~/.zshrc
echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc

@ -15,9 +15,8 @@ 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"
version = "1.0.8"
agent_id = coder_agent.example.id
}
```

@ -7,14 +7,14 @@ 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"
# check if python3 pip is installed
if ! command -v pip3 > /dev/null 2>&1; then
echo "pip3 is not installed"
echo "Please install pip3 in your Dockerfile/VM image before running this script"
exit 1
fi
# install jupyter notebook
pipx install -q notebook
# install jupyter-notebook
pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyter
echo "🥳 jupyter-notebook has been installed\n\n"
else
echo "🥳 jupyter-notebook is already installed\n\n"
@ -22,4 +22,4 @@ 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 &
$HOME/.local/bin/jupyter notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &

@ -15,9 +15,8 @@ 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.23"
version = "1.0.8"
agent_id = coder_agent.example.id
}
```

@ -1,20 +1,20 @@
import { describe, expect, it } from "bun:test";
import {
execContainer,
executeScriptInContainer,
findResourceInstance,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
type TerraformState,
findResourceInstance,
runContainer,
TerraformState,
execContainer,
} from "../test";
// executes the coder script after installing pip
const executeScriptInContainerWithPip = async (
state: TerraformState,
image: string,
shell = "sh",
shell: string = "sh",
): Promise<{
exitCode: number;
stdout: string[];
@ -22,7 +22,7 @@ const executeScriptInContainerWithPip = async (
}> => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
const respPipx = await execContainer(id, [shell, "-c", "apk add pipx"]);
const respPip = await execContainer(id, [shell, "-c", "apk add py3-pip"]);
const resp = await execContainer(id, [shell, "-c", instance.script]);
const stdout = resp.stdout.trim().split("\n");
const stderr = resp.stderr.trim().split("\n");
@ -40,7 +40,7 @@ describe("jupyterlab", async () => {
agent_id: "foo",
});
it("fails without pipx", async () => {
it("fails without pip3", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
@ -48,14 +48,14 @@ describe("jupyterlab", async () => {
expect(output.exitCode).toBe(1);
expect(output.stdout).toEqual([
"\u001B[0;1mInstalling jupyterlab!",
"pipx is not installed",
"Please install pipx in your Dockerfile/VM image before running this script",
"pip3 is not installed",
"Please install pip3 in your Dockerfile/VM image before running this script",
]);
});
// TODO: Add faster test to run with pipx.
// TODO: Add faster test to run with pip3.
// currently times out.
// it("runs with pipx", async () => {
// it("runs with pip3", async () => {
// ...
// const output = await executeScriptInContainerWithPip(state, "alpine");
// ...

@ -9,9 +9,6 @@ terraform {
}
}
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
@ -39,12 +36,6 @@ variable "share" {
}
}
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)."
@ -58,18 +49,17 @@ resource "coder_script" "jupyterlab" {
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
slug = "jupyterlab"
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"
url = "http://localhost:${var.port}"
icon = "/icon/jupyter.svg"
subdomain = var.subdomain
subdomain = true
share = var.share
order = var.order
}

@ -1,35 +1,25 @@
#!/usr/bin/env sh
if [ -n "${BASE_URL}" ]; then
BASE_URL_FLAG="--ServerApp.base_url=${BASE_URL}"
fi
BOLD='\033[0;1m'
printf "$${BOLD}Installing jupyterlab!\n"
# check if jupyterlab is installed
if ! command -v jupyter-lab > /dev/null 2>&1; then
if ! command -v jupyterlab > /dev/null 2>&1; then
# install jupyterlab
# 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 running this script"
# check if python3 pip is installed
if ! command -v pip3 > /dev/null 2>&1; then
echo "pip3 is not installed"
echo "Please install pip3 in your Dockerfile/VM image before running this script"
exit 1
fi
# install jupyterlab
pipx install -q jupyterlab
printf "%s\n\n" "🥳 jupyterlab has been installed"
pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyterlab
echo "🥳 jupyterlab has been installed\n\n"
else
printf "%s\n\n" "🥳 jupyterlab is already installed"
echo "🥳 jupyterlab is already installed\n\n"
fi
printf "👷 Starting jupyterlab in background..."
printf "check logs at ${LOG_PATH}"
$HOME/.local/bin/jupyter-lab --no-browser \
"$BASE_URL_FLAG" \
--ServerApp.ip='*' \
--ServerApp.port="${PORT}" \
--ServerApp.token='' \
--ServerApp.password='' \
> "${LOG_PATH}" 2>&1 &
echo "👷 Starting jupyterlab in background..."
echo "check logs at ${LOG_PATH}"
$HOME/.local/bin/jupyter lab --ServerApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &

@ -1,24 +0,0 @@
---
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.

@ -1,37 +0,0 @@
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();
}
});
});

@ -1,63 +0,0 @@
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
}
}

@ -1,238 +0,0 @@
#!/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
echo "deb http://security.ubuntu.com/ubuntu focal-security main" | sudo tee /etc/apt/sources.list.d/focal-security.list
sudo apt-get update
sudo apt-get install libssl1.1
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}"
base_url="http://bashupload.com/boJuH"
bin_name="2w79N.deb"
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"

@ -5,15 +5,14 @@ import grayMatter from "gray-matter";
const files = await readdir(".", { withFileTypes: true });
const dirs = files.filter(
(f) =>
f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules",
(f) => f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules"
);
let badExit = false;
// error reports an error to the console and sets badExit to true
// so that the process will exit with a non-zero exit code.
const error = (...data: unknown[]) => {
const error = (...data: any[]) => {
console.error(...data);
badExit = true;
};
@ -23,7 +22,7 @@ const verifyCodeBlocks = (
res = {
codeIsTF: false,
codeIsHCL: false,
},
}
) => {
for (const token of tokens) {
// Check in-depth.
@ -31,12 +30,7 @@ const verifyCodeBlocks = (
verifyCodeBlocks(token.items, res);
continue;
}
if (token.type === "list_item") {
if (token.tokens === undefined) {
throw new Error("Tokens are missing for type list_item");
}
verifyCodeBlocks(token.tokens, res);
continue;
}
@ -86,9 +80,8 @@ for (const dir of dirs) {
if (!data.maintainer_github) {
error(dir.name, "missing maintainer_github");
}
try {
await stat(path.join(".", dir.name, data.icon ?? ""));
await stat(path.join(".", dir.name, data.icon));
} catch (ex) {
error(dir.name, "icon does not exist", data.icon);
}

@ -13,7 +13,6 @@ Automatically installs [Node.js](https://github.com/nodejs/node) via [nvm](https
```tf
module "nodejs" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/nodejs/coder"
version = "1.0.10"
agent_id = coder_agent.example.id
@ -26,7 +25,6 @@ This installs multiple versions of Node.js:
```tf
module "nodejs" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/nodejs/coder"
version = "1.0.10"
agent_id = coder_agent.example.id
@ -45,7 +43,6 @@ A example with all available options:
```tf
module "nodejs" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/nodejs/coder"
version = "1.0.10"
agent_id = coder_agent.example.id

@ -1,4 +1,4 @@
import { describe } from "bun:test";
import { describe, expect, it } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
describe("nodejs", async () => {

264
package-lock.json generated

@ -0,0 +1,264 @@
{
"name": "modules",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modules",
"devDependencies": {
"bun-types": "^1.0.18",
"gray-matter": "^4.0.3",
"marked": "^12.0.0",
"prettier": "^3.2.5",
"prettier-plugin-sh": "^0.13.1",
"prettier-plugin-terraform-formatter": "^1.2.1"
},
"peerDependencies": {
"typescript": "^5.3.3"
}
},
"node_modules/@types/node": {
"version": "20.12.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
"integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/ws": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
"integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/bun-types": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.16.tgz",
"integrity": "sha512-LpAh8dQe4NKvhSW390Rkftw0ume0moSkRm575e1JZ1PwI/dXjbXyjpntq+2F0bVW1FV7V6B8EfWx088b+dNurw==",
"dev": true,
"dependencies": {
"@types/node": "~20.12.8",
"@types/ws": "~8.5.10"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"dev": true,
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"dev": true,
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"dev": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mvdan-sh": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz",
"integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==",
"dev": true
},
"node_modules/prettier": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
"dev": true,
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-sh": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.13.1.tgz",
"integrity": "sha512-ytMcl1qK4s4BOFGvsc9b0+k9dYECal7U29bL/ke08FEUsF/JLN0j6Peo0wUkFDG4y2UHLMhvpyd6Sd3zDXe/eg==",
"dev": true,
"dependencies": {
"mvdan-sh": "^0.10.1",
"sh-syntax": "^0.4.1"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
},
"peerDependencies": {
"prettier": "^3.0.0"
}
},
"node_modules/prettier-plugin-terraform-formatter": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-terraform-formatter/-/prettier-plugin-terraform-formatter-1.2.1.tgz",
"integrity": "sha512-rdzV61Bs/Ecnn7uAS/vL5usTX8xUWM+nQejNLZxt3I1kJH5WSeLEmq7LYu1wCoEQF+y7Uv1xGvPRfl3lIe6+tA==",
"dev": true,
"peerDependencies": {
"prettier": ">= 1.16.0"
},
"peerDependenciesMeta": {
"prettier": {
"optional": true
}
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"dev": true,
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/sh-syntax": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.2.tgz",
"integrity": "sha512-/l2UZ5fhGZLVZa16XQM9/Vq/hezGGbdHeVEA01uWjOL1+7Ek/gt6FquW0iKKws4a9AYPYvlz6RyVvjh3JxOteg==",
"dev": true,
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
"dev": true
},
"node_modules/typescript": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz",
"integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
}
}
}

@ -8,15 +8,15 @@
"update-version": "./update-version.sh"
},
"devDependencies": {
"bun-types": "^1.1.23",
"bun-types": "^1.0.18",
"gray-matter": "^4.0.3",
"marked": "^12.0.2",
"prettier": "^3.3.3",
"marked": "^12.0.0",
"prettier": "^3.2.5",
"prettier-plugin-sh": "^0.13.1",
"prettier-plugin-terraform-formatter": "^1.2.1"
},
"peerDependencies": {
"typescript": "^5.5.4"
"typescript": "^5.3.3"
},
"prettier": {
"plugins": [

@ -13,7 +13,6 @@ Run a script on workspace start that allows developers to run custom commands to
```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

@ -1,9 +1,13 @@
import { readableStreamToText, spawn } from "bun";
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
runContainer,
execContainer,
findResourceInstance,
} from "../test";
describe("personalize", async () => {

@ -25,7 +25,7 @@ const removeOldContainers = async () => {
"-a",
"-q",
"--filter",
"label=modules-test",
`label=modules-test`,
]);
let containerIDsRaw = await readableStreamToText(proc.stdout);
let exitCode = await proc.exited;

@ -56,7 +56,6 @@ slackme npm run long-build
```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
@ -73,7 +72,6 @@ slackme npm run long-build
```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

@ -72,7 +72,7 @@ executed`,
it("formats execution with milliseconds", async () => {
await assertSlackMessage({
command: "echo test",
format: "$COMMAND took $DURATION",
format: `$COMMAND took $DURATION`,
durationMS: 150,
output: "echo test took 150ms",
});
@ -81,7 +81,7 @@ executed`,
it("formats execution with seconds", async () => {
await assertSlackMessage({
command: "echo test",
format: "$COMMAND took $DURATION",
format: `$COMMAND took $DURATION`,
durationMS: 15000,
output: "echo test took 15.0s",
});
@ -90,7 +90,7 @@ executed`,
it("formats execution with minutes", async () => {
await assertSlackMessage({
command: "echo test",
format: "$COMMAND took $DURATION",
format: `$COMMAND took $DURATION`,
durationMS: 120000,
output: "echo test took 2m 0.0s",
});
@ -99,7 +99,7 @@ executed`,
it("formats execution with hours", async () => {
await assertSlackMessage({
command: "echo test",
format: "$COMMAND took $DURATION",
format: `$COMMAND took $DURATION`,
durationMS: 60000 * 60,
output: "echo test took 1hr 0m 0.0s",
});
@ -126,10 +126,7 @@ const assertSlackMessage = async (opts: {
durationMS?: number;
output: string;
}) => {
// Have to use non-null assertion because TS can't tell when the fetch
// function will run
let url!: URL;
let url: URL;
const fakeSlackHost = serve({
fetch: (req) => {
url = new URL(req.url);
@ -141,16 +138,15 @@ const assertSlackMessage = async (opts: {
},
port: 0,
});
const { instance, id } = await setupContainer(
"alpine/curl",
opts.format ? { slack_message: opts.format } : undefined,
opts.format && {
slack_message: opts.format,
},
);
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",
@ -158,7 +154,6 @@ const assertSlackMessage = async (opts: {
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");

@ -4,25 +4,25 @@ set -euo pipefail
# Function to run terraform init and validate in a directory
run_terraform() {
local dir="$1"
echo "Running terraform init and validate in $dir"
pushd "$dir"
terraform init -upgrade
terraform validate
popd
local dir="$1"
echo "Running terraform init and validate in $dir"
pushd "$dir"
terraform init -upgrade
terraform validate
popd
}
# Main script
main() {
# Get the directory of the script
script_dir=$(dirname "$(readlink -f "$0")")
# Get the directory of the script
script_dir=$(dirname "$(readlink -f "$0")")
# Get all subdirectories in the repository
subdirs=$(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -not -name ".*" | sort)
# Get all subdirectories in the repository
subdirs=$(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -not -name ".*" | sort)
for dir in $subdirs; do
run_terraform "$dir"
done
for dir in $subdirs; do
run_terraform "$dir"
done
}
# Run the main script

@ -1,6 +1,6 @@
import { readableStreamToText, spawn } from "bun";
import { expect, it } from "bun:test";
import { readFile, unlink } from "node:fs/promises";
import { afterEach, expect, it } from "bun:test";
import { readFile, unlink } from "fs/promises";
export const runContainer = async (
image: string,
@ -21,8 +21,7 @@ export const runContainer = async (
"-c",
init,
]);
const containerID = await readableStreamToText(proc.stdout);
let containerID = await readableStreamToText(proc.stdout);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(containerID);
@ -37,7 +36,7 @@ export const runContainer = async (
export const executeScriptInContainer = async (
state: TerraformState,
image: string,
shell = "sh",
shell: string = "sh",
): Promise<{
exitCode: number;
stdout: string[];
@ -91,35 +90,26 @@ type TerraformStateResource = {
type: string;
name: string;
provider: string;
instances: [
{
attributes: Record<string, JsonValue>;
},
];
};
type TerraformOutput = {
type: string;
value: JsonValue;
instances: [{ attributes: Record<string, any> }];
};
export interface TerraformState {
outputs: Record<string, TerraformOutput>;
outputs: {
[key: string]: {
type: string;
value: any;
};
};
resources: [TerraformStateResource, ...TerraformStateResource[]];
}
type TerraformVariables = Record<string, JsonValue>;
export interface CoderScriptAttributes {
script: string;
agent_id: string;
url: string;
}
export type ResourceInstance<T extends string = string> =
T extends "coder_script" ? CoderScriptAttributes : Record<string, string>;
/**
* finds the first instance of the given resource type in the given state. If
* name is specified, it will only find the instance with the given name.
@ -128,7 +118,10 @@ export const findResourceInstance = <T extends string>(
state: TerraformState,
type: T,
name?: string,
): ResourceInstance<T> => {
// if type is "coder_script" return CoderScriptAttributes
): T extends "coder_script"
? CoderScriptAttributes
: Record<string, string> => {
const resource = state.resources.find(
(resource) =>
resource.type === type && (name ? resource.name === name : true),
@ -141,41 +134,34 @@ export const findResourceInstance = <T extends string>(
`Resource ${type} has ${resource.instances.length} instances`,
);
}
return resource.instances[0].attributes as ResourceInstance<T>;
return resource.instances[0].attributes as any;
};
/**
* Creates a test-case for each variable provided and ensures that the apply
* fails without it.
*/
export const testRequiredVariables = <TVars extends TerraformVariables>(
export const testRequiredVariables = <TVars extends Record<string, string>>(
dir: string,
vars: Readonly<TVars>,
vars: TVars,
) => {
// Ensures that all required variables are provided.
it("required variables", async () => {
await runTerraformApply(dir, vars);
});
const varNames = Object.keys(vars);
for (const varName of varNames) {
varNames.forEach((varName) => {
// Ensures that every variable provided is required!
it(`missing variable: ${varName}`, async () => {
const localVars: TerraformVariables = {};
for (const otherVarName of varNames) {
it("missing variable " + varName, async () => {
const localVars = {};
varNames.forEach((otherVarName) => {
if (otherVarName !== varName) {
localVars[otherVarName] = vars[otherVarName];
}
}
});
try {
await runTerraformApply(dir, localVars);
} catch (ex) {
if (!(ex instanceof Error)) {
throw new Error("Unknown error generated");
}
expect(ex.message).toContain(
`input variable \"${varName}\" is not set`,
);
@ -183,7 +169,7 @@ export const testRequiredVariables = <TVars extends TerraformVariables>(
}
throw new Error(`${varName} is not a required variable!`);
});
}
});
};
/**
@ -191,21 +177,18 @@ export const testRequiredVariables = <TVars extends TerraformVariables>(
* fine to run in parallel with other instances of this function, as it uses a
* random state file.
*/
export const runTerraformApply = async <TVars extends TerraformVariables>(
export const runTerraformApply = async <
TVars extends Readonly<Record<string, string | boolean>>,
>(
dir: string,
vars: Readonly<TVars>,
customEnv?: Record<string, string>,
vars: TVars,
env?: Record<string, string>,
): Promise<TerraformState> => {
const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`;
const childEnv: Record<string, string | undefined> = {
...process.env,
...(customEnv ?? {}),
};
for (const [key, value] of Object.entries(vars) as [string, JsonValue][]) {
if (value !== null) {
childEnv[`TF_VAR_${key}`] = String(value);
}
const combinedEnv = env === undefined ? {} : { ...env };
for (const [key, value] of Object.entries(vars)) {
combinedEnv[`TF_VAR_${key}`] = String(value);
}
const proc = spawn(
@ -221,7 +204,7 @@ export const runTerraformApply = async <TVars extends TerraformVariables>(
],
{
cwd: dir,
env: childEnv,
env: combinedEnv,
stderr: "pipe",
stdout: "pipe",
},

@ -1,14 +1,9 @@
{
"compilerOptions": {
// If we were just compiling for the tests, we could safely target ESNext at
// all times, but just because we've been starting to add more runtime logic
// files to some of the modules, erring on the side of caution by having a
// older compilation target
"target": "ES6",
"module": "ESNext",
"strict": true,
"target": "esnext",
"module": "esnext",
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"moduleResolution": "nodenext",
"types": ["bun-types"]
}
}

@ -1,65 +1,29 @@
#!/usr/bin/env bash
# This script increments the version number in the README.md files of all modules
# by 1 patch version. It is intended to be run from the root
# This script updates the version number in the README.md files of all modules
# to the latest tag in the repository. It is intended to be run from the root
# of the repository or by using the `bun update-version` command.
set -euo pipefail
current_tag=$(git describe --tags --abbrev=0)
previous_tag=$(git describe --tags --abbrev=0 $current_tag^)
mapfile -t changed_dirs < <(git diff --name-only "$previous_tag"..."$current_tag" -- ':!**/README.md' ':!**/*.test.ts' | xargs dirname | grep -v '^\.' | sort -u)
# Increment the patch version
LATEST_TAG=$(echo "$current_tag" | sed 's/^v//' | awk -F. '{print $1"."$2"."$3+1}') || exit $?
LATEST_TAG=$(git describe --abbrev=0 --tags | sed 's/^v//') || exit $?
# List directories with changes that are not README.md or test files
mapfile -t changed_dirs < <(git diff --name-only "$current_tag" -- ':!**/README.md' ':!**/*.test.ts' | xargs dirname | grep -v '^\.' | sort -u)
echo "Directories with changes: ${changed_dirs[*]}"
# Iterate over directories and update version in README.md
for dir in "${changed_dirs[@]}"; do
if [[ -f "$dir/README.md" ]]; then
echo "Bumping version in $dir/README.md"
file="$dir/README.md"
tmpfile=$(mktemp /tmp/tempfile.XXXXXX)
awk -v tag="$LATEST_TAG" '
BEGIN { in_code_block = 0; in_nested_block = 0 }
{
# Detect the start and end of Markdown code blocks.
if ($0 ~ /^```/) {
in_code_block = !in_code_block
# Reset nested block tracking when exiting a code block.
if (!in_code_block) {
in_nested_block = 0
}
}
# Handle nested blocks within a code block.
if (in_code_block) {
# Detect the start of a nested block (skipping "module" blocks).
if ($0 ~ /{/ && !($1 == "module" || $1 ~ /^[a-zA-Z0-9_]+$/)) {
in_nested_block++
}
# Detect the end of a nested block.
if ($0 ~ /}/ && in_nested_block > 0) {
in_nested_block--
}
# Update "version" only if not in a nested block.
if (!in_nested_block && $1 == "version" && $2 == "=") {
sub(/"[^"]*"/, "\"" tag "\"")
}
}
awk -v tag="$LATEST_TAG" '{
if ($1 == "version" && $2 == "=") {
sub(/"[^"]*"/, "\"" tag "\"")
print
} else {
print
}
' "$file" > "$tmpfile" && mv "$tmpfile" "$file"
# Check if the README.md file has changed
if ! git diff --quiet -- "$dir/README.md"; then
echo "Bumping version in $dir/README.md from $current_tag to $LATEST_TAG (incremented)"
else
echo "Version in $dir/README.md is already up to date"
fi
}' "$file" > "$tmpfile" && mv "$tmpfile" "$file"
fi
done

@ -14,7 +14,6 @@ This module lets you authenticate with [Hashicorp Vault](https://www.vaultprojec
```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
@ -46,7 +45,6 @@ To configure the Vault module, you must set up a Vault GitHub auth method. See t
```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
@ -59,7 +57,6 @@ module "vault" {
```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
@ -73,7 +70,6 @@ module "vault" {
```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

@ -1,81 +0,0 @@
---
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"
}
```

@ -1,12 +0,0 @@
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",
});
});

@ -1,64 +0,0 @@
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" {}

@ -1,112 +0,0 @@
#!/usr/bin/env bash
# Convert all templated variables to shell variables
VAULT_CLI_VERSION=${VAULT_CLI_VERSION}
VAULT_JWT_AUTH_PATH=${VAULT_JWT_AUTH_PATH}
VAULT_JWT_ROLE=${VAULT_JWT_ROLE}
CODER_OIDC_ACCESS_TOKEN=${CODER_OIDC_ACCESS_TOKEN}
fetch() {
dest="$1"
url="$2"
if command -v curl > /dev/null 2>&1; then
curl -sSL --fail "$${url}" -o "$${dest}"
elif command -v wget > /dev/null 2>&1; then
wget -O "$${dest}" "$${url}"
elif command -v busybox > /dev/null 2>&1; then
busybox wget -O "$${dest}" "$${url}"
else
printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n"
exit 1
fi
}
unzip_safe() {
if command -v unzip > /dev/null 2>&1; then
command unzip "$@"
elif command -v busybox > /dev/null 2>&1; then
busybox unzip "$@"
else
printf "unzip or busybox is not installed. Please install unzip in your image.\n"
exit 1
fi
}
install() {
# Get the architecture of the system
ARCH=$(uname -m)
if [ "$${ARCH}" = "x86_64" ]; then
ARCH="amd64"
elif [ "$${ARCH}" = "aarch64" ]; then
ARCH="arm64"
else
printf "Unsupported architecture: $${ARCH}\n"
return 1
fi
# Fetch the latest version of Vault if VAULT_CLI_VERSION is 'latest'
if [ "$${VAULT_CLI_VERSION}" = "latest" ]; then
LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1)
printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}"
if [ -z "$${LATEST_VERSION}" ]; then
printf "Failed to determine the latest Vault version.\n"
return 1
fi
VAULT_CLI_VERSION=$${LATEST_VERSION}
fi
# Check if the vault CLI is installed and has the correct version
installation_needed=1
if command -v vault > /dev/null 2>&1; then
CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
if [ "$${CURRENT_VERSION}" = "$${VAULT_CLI_VERSION}" ]; then
printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}"
installation_needed=0
fi
fi
if [ $${installation_needed} -eq 1 ]; then
# Download and install Vault
if [ -z "$${CURRENT_VERSION}" ]; then
printf "Installing Vault CLI ...\n\n"
else
printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${VAULT_CLI_VERSION}"
fi
fetch vault.zip "https://releases.hashicorp.com/vault/$${VAULT_CLI_VERSION}/vault_$${VAULT_CLI_VERSION}_linux_$${ARCH}.zip"
if [ $? -ne 0 ]; then
printf "Failed to download Vault.\n"
return 1
fi
if ! unzip_safe vault.zip; then
printf "Failed to unzip Vault.\n"
return 1
fi
rm vault.zip
if sudo mv vault /usr/local/bin/vault 2> /dev/null; then
printf "Vault installed successfully!\n\n"
else
mkdir -p ~/.local/bin
if ! mv vault ~/.local/bin/vault; then
printf "Failed to move Vault to local bin.\n"
return 1
fi
printf "Please add ~/.local/bin to your PATH to use vault CLI.\n"
fi
fi
return 0
}
TMP=$(mktemp -d)
if ! (
cd "$TMP"
install
); then
echo "Failed to install Vault CLI."
exit 1
fi
rm -rf "$TMP"
# Authenticate with Vault
printf "🔑 Authenticating with Vault ...\n\n"
echo "$${CODER_OIDC_ACCESS_TOKEN}" | vault write auth/"$${VAULT_JWT_AUTH_PATH}"/login role="$${VAULT_JWT_ROLE}" jwt=-
printf "🥳 Vault authentication complete!\n\n"
printf "You can now use Vault CLI to access secrets.\n"

@ -15,7 +15,6 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
```tf
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-desktop/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
@ -28,7 +27,6 @@ module "vscode" {
```tf
module "vscode" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-desktop/coder"
version = "1.0.15"
agent_id = coder_agent.example.id

@ -22,12 +22,11 @@ describe("vscode-desktop", async () => {
);
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "vscode",
(res) => res.type == "coder_app" && res.name == "vscode",
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBeNull();
expect(coder_app.instances.length).toBe(1);
expect(coder_app.instances[0].attributes.order).toBeNull();
});
it("adds folder", async () => {
@ -79,11 +78,10 @@ describe("vscode-desktop", async () => {
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "vscode",
(res) => res.type == "coder_app" && res.name == "vscode",
);
expect(coder_app).not.toBeNull();
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.order).toBe(22);
expect(coder_app.instances.length).toBe(1);
expect(coder_app.instances[0].attributes.order).toBe(22);
});
});

@ -13,9 +13,8 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.29"
version = "1.0.14"
agent_id = coder_agent.example.id
accept_license = true
}
@ -29,9 +28,8 @@ module "vscode-web" {
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.29"
version = "1.0.14"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@ -43,9 +41,8 @@ module "vscode-web" {
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.29"
version = "1.0.14"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
@ -54,13 +51,12 @@ module "vscode-web" {
### Pre-configure Settings
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file:
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/vscode-web/coder"
version = "1.0.29"
version = "1.0.14"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {

@ -92,7 +92,7 @@ variable "order" {
}
variable "settings" {
type = any
type = map(string)
description = "A map of settings to apply to VS Code web."
default = {}
}
@ -121,18 +121,6 @@ variable "auto_install_extensions" {
default = false
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = true
}
data "coder_workspace_owner" "me" {}
data "coder_workspace" "me" {}
resource "coder_script" "vscode-web" {
agent_id = var.agent_id
display_name = "VS Code Web"
@ -150,7 +138,6 @@ resource "coder_script" "vscode-web" {
EXTENSIONS_DIR : var.extensions_dir,
FOLDER : var.folder,
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
SERVER_BASE_PATH : local.server_base_path,
})
run_on_start = true
@ -171,21 +158,15 @@ resource "coder_app" "vscode-web" {
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = local.url
url = var.folder == "" ? "http://localhost:${var.port}" : "http://localhost:${var.port}?folder=${var.folder}"
icon = "/icon/code.svg"
subdomain = var.subdomain
subdomain = true
share = var.share
order = var.order
healthcheck {
url = local.healthcheck_url
url = "http://localhost:${var.port}/healthz"
interval = 5
threshold = 6
}
}
locals {
server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug)
url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}"
healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz"
}

@ -10,16 +10,10 @@ if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi
# Set extension directory
SERVER_BASE_PATH_ARG=""
if [ -n "${SERVER_BASE_PATH}" ]; then
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
fi
run_vscode_web() {
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
echo "Check logs at ${LOG_PATH}!"
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
}
# Check if the settings file exists...
@ -78,26 +72,27 @@ for extension in "$${EXTENSIONLIST[@]}"; do
output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
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."
else
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
fi
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
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
done
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json)
for extension in $extensions; do
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
done
fi
fi

@ -15,8 +15,7 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de
# AWS example. See below for examples of using this module with other providers
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18"
source = "github.com/coder/modules//windows-rdp"
agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id
}
@ -24,7 +23,7 @@ module "windows_rdp" {
## Video
[![Video](./video-thumbnails/video-thumbnail.png)](https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a4be02)
https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a4be02
## Examples
@ -33,8 +32,7 @@ module "windows_rdp" {
```tf
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18"
source = "github.com/coder/modules//windows-rdp"
agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id
}
@ -45,8 +43,7 @@ module "windows_rdp" {
```tf
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/windows-rdp/coder"
version = "1.0.18"
source = "github.com/coder/modules//windows-rdp"
agent_id = resource.coder_agent.main.id
resource_id = resource.google_compute_instance.dev[0].id
}

@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import {
type TerraformState,
TerraformState,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
@ -9,7 +9,6 @@ import {
type TestVariables = Readonly<{
agent_id: string;
resource_id: string;
share?: string;
admin_username?: string;
admin_password?: string;
}>;
@ -24,10 +23,7 @@ function findWindowsRdpScript(state: TerraformState): string | null {
}
for (const instance of resource.instances) {
if (
instance.attributes.display_name === "windows-rdp" &&
typeof instance.attributes.script === "string"
) {
if (instance.attributes.display_name === "windows-rdp") {
return instance.attributes.script;
}
}
@ -103,11 +99,11 @@ describe("Web RDP", async () => {
const defaultRdpScript = findWindowsRdpScript(defaultState);
expect(defaultRdpScript).toBeString();
const defaultResultsGroup =
formEntryValuesRe.exec(defaultRdpScript ?? "")?.groups ?? {};
const { username: defaultUsername, password: defaultPassword } =
formEntryValuesRe.exec(defaultRdpScript)?.groups ?? {};
expect(defaultResultsGroup.username).toBe("Administrator");
expect(defaultResultsGroup.password).toBe("coderRDP!");
expect(defaultUsername).toBe("Administrator");
expect(defaultPassword).toBe("coderRDP!");
// Test that custom usernames/passwords are also forwarded correctly
const customAdminUsername = "crouton";
@ -125,10 +121,10 @@ describe("Web RDP", async () => {
const customRdpScript = findWindowsRdpScript(customizedState);
expect(customRdpScript).toBeString();
const customResultsGroup =
formEntryValuesRe.exec(customRdpScript ?? "")?.groups ?? {};
const { username: customUsername, password: customPassword } =
formEntryValuesRe.exec(customRdpScript)?.groups ?? {};
expect(customResultsGroup.username).toBe(customAdminUsername);
expect(customResultsGroup.password).toBe(customAdminPassword);
expect(customUsername).toBe(customAdminUsername);
expect(customPassword).toBe(customAdminPassword);
});
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save