diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5ace4600a..600a98c2a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,5 @@ updates: directory: "/" schedule: interval: "weekly" + commit-message: + prefix: "chore" diff --git a/.github/scripts/check.sh b/.github/scripts/check.sh new file mode 100755 index 000000000..49c817aa9 --- /dev/null +++ b/.github/scripts/check.sh @@ -0,0 +1,203 @@ +#!/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}" diff --git a/.github/typos.toml b/.github/typos.toml new file mode 100644 index 000000000..ec620a44a --- /dev/null +++ b/.github/typos.toml @@ -0,0 +1,2 @@ +[default.extend-words] +muc = "muc" # For Munich location code diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 000000000..02422ff26 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,23 @@ +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 }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c5d9c73dc..63c6062bb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,14 +16,23 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: coder/coder/.github/actions/setup-tf@main - - uses: oven-sh/setup-bun@v2 + - 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 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: Setup + - name: Install dependencies run: bun install - - run: bun test + - name: Run tests + run: bun test pretty: runs-on: ubuntu-latest steps: @@ -39,19 +48,8 @@ jobs: - name: Format run: bun fmt:ci - name: typos-action - uses: crate-ci/typos@v1.17.2 + uses: crate-ci/typos@v1.32.0 + with: + config: .github/typos.toml - 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 diff --git a/.github/workflows/deploy-registry.yaml b/.github/workflows/deploy-registry.yaml new file mode 100644 index 000000000..bc60c06be --- /dev/null +++ b/.github/workflows/deploy-registry.yaml @@ -0,0 +1,43 @@ +name: deploy-registry + +on: + push: + branches: + - main + tags: + - "release/*/v*" # Matches tags like release/module-name/v1.0.0 + +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@ba79af03959ebeac9769e648f473a284504d9193 + 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 + + - name: Deploy to registry.coder.com + run: | + gcloud builds triggers run 106610ff-41fb-4bd0-90a2-7643583fb9c0 --branch main + diff --git a/.gitignore b/.gitignore index 6d6f5a22c..a2e63be85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .terraform* node_modules *.tfstate -*.tfstate.lock.info \ No newline at end of file +*.tfstate.lock.info + +# Ignore generated credentials from google-github-actions/auth +gha-creds-*.json \ No newline at end of file diff --git a/.icons/claude.svg b/.icons/claude.svg new file mode 100644 index 000000000..998fb0d52 --- /dev/null +++ b/.icons/claude.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/dcv.svg b/.icons/dcv.svg new file mode 100644 index 000000000..6a73c7b91 --- /dev/null +++ b/.icons/dcv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/devcontainers.svg b/.icons/devcontainers.svg new file mode 100644 index 000000000..fb0443bd1 --- /dev/null +++ b/.icons/devcontainers.svg @@ -0,0 +1,2 @@ + +file_type_devcontainer \ No newline at end of file diff --git a/.icons/goose.svg b/.icons/goose.svg new file mode 100644 index 000000000..cbbe8419a --- /dev/null +++ b/.icons/goose.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/windsurf.svg b/.icons/windsurf.svg new file mode 100644 index 000000000..a7684d4cb --- /dev/null +++ b/.icons/windsurf.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.images/amazon-dcv-windows.png b/.images/amazon-dcv-windows.png new file mode 100644 index 000000000..5dd2deef0 Binary files /dev/null and b/.images/amazon-dcv-windows.png differ diff --git a/.sample/README.md b/.sample/README.md index e8754f193..e2fb41516 100644 --- a/.sample/README.md +++ b/.sample/README.md @@ -13,6 +13,7 @@ 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" } @@ -28,6 +29,7 @@ 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 @@ -45,6 +47,7 @@ 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60b1260bb..2c7ba8bfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ Follow the instructions to ensure that Bun is available globally. Once Bun has b ## 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. +> [!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. @@ -53,23 +54,44 @@ module "example" { ## 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. +The release process is automated with these steps: + +## 1. Create and Merge PR + +- Create a PR with your module changes +- Get your PR reviewed, approved, and merged to `main` + +## 2. Prepare Release (Maintainer Task) + +After merging to `main`, a maintainer will: + +- View all modules and their current versions: + + ```shell + ./release.sh --list + ``` + +- Determine the next version number based on changes: + + - **Patch version** (1.2.3 → 1.2.4): Bug fixes + - **Minor version** (1.2.3 → 1.3.0): New features, adding inputs, deprecating inputs + - **Major version** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing input types) -Much of our release process is automated. To cut a new release: +- Create and push an annotated tag: -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..` (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. + ```shell + # Fetch latest changes + git fetch origin + + # Create and push tag + ./release.sh module-name 1.2.3 --push + ``` -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. + The tag format will be: `release/module-name/v1.2.3` -Following that, our automated processes will handle publishing new data for [`registry.coder.com`](https://github.com/coder/registry.coder.com/): +## 3. Publishing to Registry -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) +Our automated processes will handle publishing new data to [registry.coder.com](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. +> Some data in registry.coder.com is fetched on demand from the [coder/modules](https://github.com/coder/modules) repo's `main` branch. This data should update almost immediately after a release, while other changes will take some time to propagate. diff --git a/README.md b/README.md index 48a96a3ae..81d8d3807 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +> [!CAUTION] +> We are no longer accepting new contributions to this repo. We have moved all modules to https://github.com/coder/registry repo. Please see https://github.com/coder/modules/discussions/469 for more details. +

Modules @@ -7,6 +10,7 @@ [![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)

@@ -16,6 +20,7 @@ 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 diff --git a/amazon-dcv-windows/README.md b/amazon-dcv-windows/README.md new file mode 100644 index 000000000..91fdc9ef3 --- /dev/null +++ b/amazon-dcv-windows/README.md @@ -0,0 +1,49 @@ +--- +display_name: Amazon DCV Windows +description: Amazon DCV Server and Web Client for Windows +icon: ../.icons/dcv.svg +maintainer_github: coder +verified: true +tags: [windows, amazon, dcv, web, desktop] +--- + +# Amazon DCV Windows + +Amazon DCV is high performance remote display protocol that provides a secure way to deliver remote desktop and application streaming from any cloud or data center to any device, over varying network conditions. + +![Amazon DCV on a Windows workspace](../.images/amazon-dcv-windows.png) + +Enable DCV Server and Web Client on Windows workspaces. + +```tf +module "dcv" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/amazon-dcv-windows/coder" + version = "1.0.24" + agent_id = resource.coder_agent.main.id +} + + +resource "coder_metadata" "dcv" { + count = data.coder_workspace.me.start_count + resource_id = aws_instance.dev.id # id of the instance resource + + item { + key = "DCV client instructions" + value = "Run `coder port-forward ${data.coder_workspace.me.name} -p ${module.dcv[count.index].port}` and connect to **localhost:${module.dcv[count.index].port}${module.dcv[count.index].web_url_path}**" + } + item { + key = "username" + value = module.dcv[count.index].username + } + item { + key = "password" + value = module.dcv[count.index].password + sensitive = true + } +} +``` + +## License + +Amazon DCV is free to use on AWS EC2 instances but requires a license for other cloud providers. Please see the instructions [here](https://docs.aws.amazon.com/dcv/latest/adminguide/setting-up-license.html#setting-up-license-ec2) for more information. diff --git a/amazon-dcv-windows/install-dcv.ps1 b/amazon-dcv-windows/install-dcv.ps1 new file mode 100644 index 000000000..2b1c9f4b2 --- /dev/null +++ b/amazon-dcv-windows/install-dcv.ps1 @@ -0,0 +1,170 @@ +# Terraform variables +$adminPassword = "${admin_password}" +$port = "${port}" +$webURLPath = "${web_url_path}" + +function Set-LocalAdminUser { + Write-Output "[INFO] Starting Set-LocalAdminUser function" + $securePassword = ConvertTo-SecureString $adminPassword -AsPlainText -Force + Write-Output "[DEBUG] Secure password created" + Get-LocalUser -Name Administrator | Set-LocalUser -Password $securePassword + Write-Output "[INFO] Administrator password set" + Get-LocalUser -Name Administrator | Enable-LocalUser + Write-Output "[INFO] User Administrator enabled successfully" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +function Get-VirtualDisplayDriverRequired { + Write-Output "[INFO] Starting Get-VirtualDisplayDriverRequired function" + $token = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token-ttl-seconds' = '21600'} -Method PUT -Uri http://169.254.169.254/latest/api/token + Write-Output "[DEBUG] Token acquired: $token" + $instanceType = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token' = $token} -Method GET -Uri http://169.254.169.254/latest/meta-data/instance-type + Write-Output "[DEBUG] Instance type: $instanceType" + $OSVersion = ((Get-ItemProperty -Path "Microsoft.PowerShell.Core\Registry::\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name ProductName).ProductName) -replace "[^0-9]", '' + Write-Output "[DEBUG] OS version: $OSVersion" + + # Force boolean result + $result = (($OSVersion -ne "2019") -and ($OSVersion -ne "2022") -and ($OSVersion -ne "2025")) -and (($instanceType[0] -ne 'g') -and ($instanceType[0] -ne 'p')) + Write-Output "[INFO] VirtualDisplayDriverRequired result: $result" + Read-Host "[DEBUG] Press Enter to proceed to the next step" + return [bool]$result +} + +function Download-DCV { + param ( + [bool]$VirtualDisplayDriverRequired + ) + Write-Output "[INFO] Starting Download-DCV function" + + $downloads = @( + @{ + Name = "DCV Display Driver" + Required = $VirtualDisplayDriverRequired + Path = "C:\Windows\Temp\DCVDisplayDriver.msi" + Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-virtual-display-x64-Release.msi" + }, + @{ + Name = "DCV Server" + Required = $true + Path = "C:\Windows\Temp\DCVServer.msi" + Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-server-x64-Release.msi" + } + ) + + foreach ($download in $downloads) { + if ($download.Required -and -not (Test-Path $download.Path)) { + try { + Write-Output "[INFO] Downloading $($download.Name)" + + # Display progress manually (no events) + $progressActivity = "Downloading $($download.Name)" + $progressStatus = "Starting download..." + Write-Progress -Activity $progressActivity -Status $progressStatus -PercentComplete 0 + + # Synchronously download the file + $webClient = New-Object System.Net.WebClient + $webClient.DownloadFile($download.Uri, $download.Path) + + # Update progress + Write-Progress -Activity $progressActivity -Status "Completed" -PercentComplete 100 + + Write-Output "[INFO] $($download.Name) downloaded successfully." + } catch { + Write-Output "[ERROR] Failed to download $($download.Name): $_" + throw + } + } else { + Write-Output "[INFO] $($download.Name) already exists. Skipping download." + } + } + + Write-Output "[INFO] All downloads completed" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +function Install-DCV { + param ( + [bool]$VirtualDisplayDriverRequired + ) + Write-Output "[INFO] Starting Install-DCV function" + + if (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue)) { + if ($VirtualDisplayDriverRequired) { + Write-Output "[INFO] Installing DCV Display Driver" + Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVDisplayDriver.msi /quiet /norestart" -Wait + } else { + Write-Output "[INFO] DCV Display Driver installation skipped (not required)." + } + Write-Output "[INFO] Installing DCV Server" + Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVServer.msi ADDLOCAL=ALL /quiet /norestart /l*v C:\Windows\Temp\dcv_install_msi.log" -Wait + } else { + Write-Output "[INFO] DCV Server already installed, skipping installation." + } + + # Wait for the service to appear with a timeout + $timeout = 10 # seconds + $elapsed = 0 + while (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) { + Start-Sleep -Seconds 1 + $elapsed++ + } + + if ($elapsed -ge $timeout) { + Write-Output "[WARNING] Timeout waiting for dcvserver service. A restart is required to complete installation." + Restart-SystemForDCV + } else { + Write-Output "[INFO] dcvserver service detected successfully." + } +} + +function Restart-SystemForDCV { + Write-Output "[INFO] The system will restart in 10 seconds to finalize DCV installation." + Start-Sleep -Seconds 10 + + # Initiate restart + Restart-Computer -Force + + # Exit the script after initiating restart + Write-Output "[INFO] Please wait for the system to restart..." + + Exit 1 +} + + +function Configure-DCV { + Write-Output "[INFO] Starting Configure-DCV function" + $dcvPath = "Microsoft.PowerShell.Core\Registry::\HKEY_USERS\S-1-5-18\Software\GSettings\com\nicesoftware\dcv" + + # Create the required paths + @("$dcvPath\connectivity", "$dcvPath\session-management", "$dcvPath\session-management\automatic-console-session", "$dcvPath\display") | ForEach-Object { + if (-not (Test-Path $_)) { + New-Item -Path $_ -Force | Out-Null + } + } + + # Set registry keys + New-ItemProperty -Path "$dcvPath\session-management" -Name create-session -PropertyType DWORD -Value 1 -Force + New-ItemProperty -Path "$dcvPath\session-management\automatic-console-session" -Name owner -Value Administrator -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name quic-port -PropertyType DWORD -Value $port -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name web-port -PropertyType DWORD -Value $port -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name web-url-path -PropertyType String -Value $webURLPath -Force + + # Attempt to restart service + if (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) { + Restart-Service -Name "dcvserver" + } else { + Write-Output "[WARNING] dcvserver service not found. Ensure the system was restarted properly." + } + + Write-Output "[INFO] DCV configuration completed" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +# Main Script Execution +Write-Output "[INFO] Starting script" +$VirtualDisplayDriverRequired = [bool](Get-VirtualDisplayDriverRequired) +Set-LocalAdminUser +Download-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired +Install-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired +Configure-DCV +Write-Output "[INFO] Script completed" diff --git a/amazon-dcv-windows/main.tf b/amazon-dcv-windows/main.tf new file mode 100644 index 000000000..90058af3a --- /dev/null +++ b/amazon-dcv-windows/main.tf @@ -0,0 +1,85 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "admin_password" { + type = string + default = "coderDCV!" + sensitive = true +} + +variable "port" { + type = number + description = "The port number for the DCV server." + default = 8443 +} + +variable "subdomain" { + type = bool + description = "Whether to use a subdomain for the DCV server." + default = true +} + +variable "slug" { + type = string + description = "The slug of the web-dcv coder_app resource." + default = "web-dcv" +} + +resource "coder_app" "web-dcv" { + agent_id = var.agent_id + slug = var.slug + display_name = "Web DCV" + url = "https://localhost:${var.port}${local.web_url_path}?username=${local.admin_username}&password=${var.admin_password}" + icon = "/icon/dcv.svg" + subdomain = var.subdomain +} + +resource "coder_script" "install-dcv" { + agent_id = var.agent_id + display_name = "Install DCV" + icon = "/icon/dcv.svg" + run_on_start = true + script = templatefile("${path.module}/install-dcv.ps1", { + admin_password : var.admin_password, + port : var.port, + web_url_path : local.web_url_path + }) +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + web_url_path = var.subdomain ? "/" : format("/@%s/%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) + admin_username = "Administrator" +} + +output "web_url_path" { + value = local.web_url_path +} + +output "username" { + value = local.admin_username +} + +output "password" { + value = var.admin_password + sensitive = true +} + +output "port" { + value = var.port +} diff --git a/apache-airflow/README.md b/apache-airflow/README.md index 194cceb8e..72361a0bc 100644 --- a/apache-airflow/README.md +++ b/apache-airflow/README.md @@ -14,6 +14,7 @@ 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 diff --git a/aws-region/README.md b/aws-region/README.md index 4d363c3e8..c190ffd81 100644 --- a/aws-region/README.md +++ b/aws-region/README.md @@ -16,6 +16,7 @@ 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" @@ -36,6 +37,7 @@ 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" @@ -62,6 +64,7 @@ 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"] diff --git a/azure-region/README.md b/azure-region/README.md index cd0efd332..2ac9597e1 100644 --- a/azure-region/README.md +++ b/azure-region/README.md @@ -13,6 +13,7 @@ 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" @@ -33,6 +34,7 @@ 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 = { @@ -56,6 +58,7 @@ 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 = [ diff --git a/claude-code/README.md b/claude-code/README.md new file mode 100644 index 000000000..7ff2ae873 --- /dev/null +++ b/claude-code/README.md @@ -0,0 +1,114 @@ +--- +display_name: Claude Code +description: Run Claude Code in your workspace +icon: ../.icons/claude.svg +maintainer_github: coder +verified: true +tags: [agent, claude-code] +--- + +# Claude Code + +Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. + +```tf +module "claude-code" { + source = "registry.coder.com/modules/claude-code/coder" + version = "1.2.1" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_claude_code = true + claude_code_version = "latest" +} +``` + +### Prerequisites + +- Node.js and npm must be installed in your workspace to install Claude Code +- Either `screen` or `tmux` must be installed in your workspace to run Claude Code in the background +- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template + +The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. + +## Examples + +### Run in the background and report tasks (Experimental) + +> This functionality is in early access as of Coder v2.21 and is still evolving. +> For now, we recommend testing it in a demo or staging environment, +> rather than deploying to production +> +> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents) +> +> Join our [Discord channel](https://discord.gg/coder) or +> [contact us](https://coder.com/contact) to get help or share feedback. + +Your workspace must have either `screen` or `tmux` installed to use this. + +```tf +variable "anthropic_api_key" { + type = string + description = "The Anthropic API key" + sensitive = true +} + +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder-login/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} + +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Write a prompt for Claude Code" + mutable = true +} + +# Set the prompt and system prompt for Claude Code via environment variables +resource "coder_agent" "main" { + # ... + env = { + CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter + CODER_MCP_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value + CODER_MCP_APP_STATUS_SLUG = "claude-code" + CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT + You are a helpful assistant that can help with code. + EOT + } +} + +module "claude-code" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/claude-code/coder" + version = "1.1.0" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_claude_code = true + claude_code_version = "0.2.57" + + # Enable experimental features + experiment_use_screen = true # Or use experiment_use_tmux = true to use tmux instead + experiment_report_tasks = true +} +``` + +## Run standalone + +Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it directly without using screen or any task reporting to the Coder UI. + +```tf +module "claude-code" { + source = "registry.coder.com/modules/claude-code/coder" + version = "1.2.1" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_claude_code = true + claude_code_version = "latest" + + # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL + icon = "https://registry.npmmirror.com/@lobehub/icons-static-png/1.24.0/files/dark/claude-color.png" +} +``` diff --git a/claude-code/main.tf b/claude-code/main.tf new file mode 100644 index 000000000..cc7b27e07 --- /dev/null +++ b/claude-code/main.tf @@ -0,0 +1,249 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/claude.svg" +} + +variable "folder" { + type = string + description = "The folder to run Claude Code in." + default = "/home/coder" +} + +variable "install_claude_code" { + type = bool + description = "Whether to install Claude Code." + default = true +} + +variable "claude_code_version" { + type = string + description = "The version of Claude Code to install." + default = "latest" +} + +variable "experiment_use_screen" { + type = bool + description = "Whether to use screen for running Claude Code in the background." + default = false +} + +variable "experiment_use_tmux" { + type = bool + description = "Whether to use tmux instead of screen for running Claude Code in the background." + default = false +} + +variable "experiment_report_tasks" { + type = bool + description = "Whether to enable task reporting." + default = false +} + +variable "experiment_pre_install_script" { + type = string + description = "Custom script to run before installing Claude Code." + default = null +} + +variable "experiment_post_install_script" { + type = string + description = "Custom script to run after installing Claude Code." + default = null +} + +locals { + encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" + encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" +} + +# Install and Initialize Claude Code +resource "coder_script" "claude_code" { + agent_id = var.agent_id + display_name = "Claude Code" + icon = var.icon + script = <<-EOT + #!/bin/bash + set -e + + # Function to check if a command exists + command_exists() { + command -v "$1" >/dev/null 2>&1 + } + + # Run pre-install script if provided + if [ -n "${local.encoded_pre_install_script}" ]; then + echo "Running pre-install script..." + echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh + chmod +x /tmp/pre_install.sh + /tmp/pre_install.sh + fi + + # Install Claude Code if enabled + if [ "${var.install_claude_code}" = "true" ]; then + if ! command_exists npm; then + echo "Error: npm is not installed. Please install Node.js and npm first." + exit 1 + fi + echo "Installing Claude Code..." + npm install -g @anthropic-ai/claude-code@${var.claude_code_version} + fi + + # Run post-install script if provided + if [ -n "${local.encoded_post_install_script}" ]; then + echo "Running post-install script..." + echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh + chmod +x /tmp/post_install.sh + /tmp/post_install.sh + fi + + if [ "${var.experiment_report_tasks}" = "true" ]; then + echo "Configuring Claude Code to report tasks via Coder MCP..." + coder exp mcp configure claude-code ${var.folder} + fi + + # Handle terminal multiplexer selection (tmux or screen) + if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then + echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously." + echo "Please set only one of them to true." + exit 1 + fi + + # Run with tmux if enabled + if [ "${var.experiment_use_tmux}" = "true" ]; then + echo "Running Claude Code in the background with tmux..." + + # Check if tmux is installed + if ! command_exists tmux; then + echo "Error: tmux is not installed. Please install tmux manually." + exit 1 + fi + + touch "$HOME/.claude-code.log" + + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + # Create a new tmux session in detached mode + tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions" + + # Send the prompt to the tmux session if needed + if [ -n "$CODER_MCP_CLAUDE_TASK_PROMPT" ]; then + tmux send-keys -t claude-code "$CODER_MCP_CLAUDE_TASK_PROMPT" + sleep 5 + tmux send-keys -t claude-code Enter + fi + fi + + # Run with screen if enabled + if [ "${var.experiment_use_screen}" = "true" ]; then + echo "Running Claude Code in the background..." + + # Check if screen is installed + if ! command_exists screen; then + echo "Error: screen is not installed. Please install screen manually." + exit 1 + fi + + touch "$HOME/.claude-code.log" + + # Ensure the screenrc exists + if [ ! -f "$HOME/.screenrc" ]; then + echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log" + echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" + fi + + if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then + echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" + echo "multiuser on" >> "$HOME/.screenrc" + fi + + if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then + echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" + echo "acladd $(whoami)" >> "$HOME/.screenrc" + fi + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + screen -U -dmS claude-code bash -c ' + cd ${var.folder} + claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log" + exec bash + ' + # Extremely hacky way to send the prompt to the screen session + # This will be fixed in the future, but `claude` was not sending MCP + # tasks when an initial prompt is provided. + screen -S claude-code -X stuff "$CODER_MCP_CLAUDE_TASK_PROMPT" + sleep 5 + screen -S claude-code -X stuff "^M" + else + # Check if claude is installed before running + if ! command_exists claude; then + echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually." + exit 1 + fi + fi + EOT + run_on_start = true +} + +resource "coder_app" "claude_code" { + slug = "claude-code" + display_name = "Claude Code" + agent_id = var.agent_id + command = <<-EOT + #!/bin/bash + set -e + + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + if [ "${var.experiment_use_tmux}" = "true" ]; then + if tmux has-session -t claude-code 2>/dev/null; then + echo "Attaching to existing Claude Code tmux session." | tee -a "$HOME/.claude-code.log" + tmux attach-session -t claude-code + else + echo "Starting a new Claude Code tmux session." | tee -a "$HOME/.claude-code.log" + tmux new-session -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions | tee -a \"$HOME/.claude-code.log\"; exec bash" + fi + elif [ "${var.experiment_use_screen}" = "true" ]; then + if screen -list | grep -q "claude-code"; then + echo "Attaching to existing Claude Code screen session." | tee -a "$HOME/.claude-code.log" + screen -xRR claude-code + else + echo "Starting a new Claude Code screen session." | tee -a "$HOME/.claude-code.log" + screen -S claude-code bash -c 'claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash' + fi + else + cd ${var.folder} + claude + fi + EOT + icon = var.icon +} diff --git a/code-server/README.md b/code-server/README.md index 330661c26..dc44237f4 100644 --- a/code-server/README.md +++ b/code-server/README.md @@ -13,8 +13,9 @@ 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.18" + version = "1.1.0" agent_id = coder_agent.example.id } ``` @@ -27,8 +28,9 @@ 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.18" + version = "1.1.0" agent_id = coder_agent.example.id install_version = "4.8.3" } @@ -40,8 +42,9 @@ 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.18" + version = "1.1.0" agent_id = coder_agent.example.id extensions = [ "dracula-theme.theme-dracula" @@ -53,12 +56,13 @@ Enter the `.` 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#_settingsjson) file: +Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file: ```tf module "code-server" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/code-server/coder" - version = "1.0.18" + version = "1.1.0" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -73,8 +77,9 @@ 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.18" + version = "1.1.0" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] } @@ -88,8 +93,9 @@ 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.18" + version = "1.1.0" agent_id = coder_agent.example.id use_cached = true extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] @@ -100,8 +106,9 @@ 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.18" + version = "1.1.0" agent_id = coder_agent.example.id offline = true } diff --git a/code-server/main.tf b/code-server/main.tf index 996169340..ca4ff3afd 100644 --- a/code-server/main.tf +++ b/code-server/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.1" } } } @@ -39,7 +39,7 @@ variable "slug" { } variable "settings" { - type = map(string) + type = any description = "A map of settings to apply to code-server." default = {} } @@ -122,6 +122,20 @@ variable "subdomain" { default = false } +variable "open_in" { + type = string + description = <<-EOT + Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`. + `"tab"` opens in a new tab in the same browser window. + `"slim-window"` opens a new browser window without navigation controls. + EOT + default = "slim-window" + validation { + condition = contains(["tab", "slim-window"], var.open_in) + error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'." + } +} + resource "coder_script" "code-server" { agent_id = var.agent_id display_name = "code-server" @@ -166,6 +180,7 @@ resource "coder_app" "code-server" { subdomain = var.subdomain share = var.share order = var.order + open_in = var.open_in healthcheck { url = "http://localhost:${var.port}/healthz" diff --git a/code-server/run.sh b/code-server/run.sh index 9af391e04..99b30c0ea 100755 --- a/code-server/run.sh +++ b/code-server/run.sh @@ -42,6 +42,11 @@ fi if [ ! -f "$CODE_SERVER" ] || [ "${USE_CACHED}" != true ]; then printf "$${BOLD}Installing code-server!\n" + # Clean up from other install (in case install prefix changed). + if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/code-server" ]; then + rm "$CODER_SCRIPT_BIN_DIR/code-server" + fi + ARGS=( "--method=standalone" "--prefix=${INSTALL_PREFIX}" @@ -58,6 +63,11 @@ if [ ! -f "$CODE_SERVER" ] || [ "${USE_CACHED}" != true ]; then printf "🥳 code-server has been installed in ${INSTALL_PREFIX}\n\n" fi +# Make the code-server available in PATH. +if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/code-server" ]; then + ln -s "$CODE_SERVER" "$CODER_SCRIPT_BIN_DIR/code-server" +fi + # Get the list of installed extensions... LIST_EXTENSIONS=$($CODE_SERVER --list-extensions $EXTENSION_ARG) readarray -t EXTENSIONS_ARRAY <<< "$LIST_EXTENSIONS" @@ -104,7 +114,8 @@ 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" - extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json) + # Use sed to remove single-line comments before parsing with jq + extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]') for extension in $extensions; do if extension_installed "$extension"; then continue diff --git a/coder-login/README.md b/coder-login/README.md index c9bb333f3..589266bfb 100644 --- a/coder-login/README.md +++ b/coder-login/README.md @@ -13,6 +13,7 @@ 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 diff --git a/cursor/README.md b/cursor/README.md index c2997bee9..d9a2e17f9 100644 --- a/cursor/README.md +++ b/cursor/README.md @@ -11,10 +11,11 @@ tags: [ide, cursor, helper] 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). +Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder). ```tf module "cursor" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/cursor/coder" version = "1.0.19" agent_id = coder_agent.example.id @@ -27,6 +28,7 @@ module "cursor" { ```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 diff --git a/devcontainers-cli/README.md b/devcontainers-cli/README.md new file mode 100644 index 000000000..4b4450730 --- /dev/null +++ b/devcontainers-cli/README.md @@ -0,0 +1,22 @@ +--- +display_name: devcontainers-cli +description: devcontainers-cli module provides an easy way to install @devcontainers/cli into a workspace +icon: ../.icons/devcontainers.svg +verified: true +maintainer_github: coder +tags: [devcontainers] +--- + +# devcontainers-cli + +The devcontainers-cli module provides an easy way to install [`@devcontainers/cli`](https://github.com/devcontainers/cli) into a workspace. It can be used within any workspace as it runs only if +@devcontainers/cli is not installed yet. +`npm` is required and should be pre-installed in order for the module to work. + +```tf +module "devcontainers-cli" { + source = "registry.coder.com/modules/devcontainers-cli/coder" + version = "1.0.3" + agent_id = coder_agent.example.id +} +``` diff --git a/devcontainers-cli/main.test.ts b/devcontainers-cli/main.test.ts new file mode 100644 index 000000000..892d6430b --- /dev/null +++ b/devcontainers-cli/main.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "bun:test"; +import { + execContainer, + executeScriptInContainer, + findResourceInstance, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + type TerraformState, +} from "../test"; + +const executeScriptInContainerWithPackageManager = async ( + state: TerraformState, + image: string, + packageManager: string, + shell = "sh", +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + + // Install the specified package manager + if (packageManager === "npm") { + await execContainer(id, [shell, "-c", "apk add nodejs npm"]); + } else if (packageManager === "pnpm") { + await execContainer(id, [ + shell, + "-c", + `wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh -`, + ]); + } else if (packageManager === "yarn") { + await execContainer(id, [ + shell, + "-c", + "apk add nodejs npm && npm install -g yarn", + ]); + } + + const pathResp = await execContainer(id, [shell, "-c", "echo $PATH"]); + const path = pathResp.stdout.trim(); + + console.log(path); + + const resp = await execContainer( + id, + [shell, "-c", instance.script], + [ + "--env", + "CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin", + "--env", + `PATH=${path}:/tmp/coder-script-data/bin`, + ], + ); + const stdout = resp.stdout.trim().split("\n"); + const stderr = resp.stderr.trim().split("\n"); + return { + exitCode: resp.exitCode, + stdout, + stderr, + }; +}; + +describe("devcontainers-cli", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "some-agent-id", + }); + + it("misses all package managers", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + }); + const output = await executeScriptInContainer(state, "docker:dind"); + expect(output.exitCode).toBe(1); + expect(output.stderr).toEqual([ + "ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first.", + ]); + }, 15000); + + it("installs devcontainers-cli with npm", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + }); + + const output = await executeScriptInContainerWithPackageManager( + state, + "docker:dind", + "npm", + ); + expect(output.exitCode).toBe(0); + + expect(output.stdout[0]).toEqual( + "Installing @devcontainers/cli using npm...", + ); + expect(output.stdout[output.stdout.length - 1]).toEqual( + "🥳 @devcontainers/cli has been installed into /usr/local/bin/devcontainer!", + ); + }, 15000); + + it("installs devcontainers-cli with yarn", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + }); + + const output = await executeScriptInContainerWithPackageManager( + state, + "docker:dind", + "yarn", + ); + expect(output.exitCode).toBe(0); + + expect(output.stdout[0]).toEqual( + "Installing @devcontainers/cli using yarn...", + ); + expect(output.stdout[output.stdout.length - 1]).toEqual( + "🥳 @devcontainers/cli has been installed into /tmp/coder-script-data/bin/devcontainer!", + ); + }, 15000); + + it("displays warning if docker is not installed", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + }); + + const output = await executeScriptInContainerWithPackageManager( + state, + "alpine", + "npm", + ); + expect(output.exitCode).toBe(0); + + expect(output.stdout[0]).toEqual( + "WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available.", + ); + expect(output.stdout[output.stdout.length - 1]).toEqual( + "🥳 @devcontainers/cli has been installed into /usr/local/bin/devcontainer!", + ); + }, 15000); +}); diff --git a/devcontainers-cli/main.tf b/devcontainers-cli/main.tf new file mode 100644 index 000000000..a2aee348b --- /dev/null +++ b/devcontainers-cli/main.tf @@ -0,0 +1,23 @@ +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." +} + +resource "coder_script" "devcontainers-cli" { + agent_id = var.agent_id + display_name = "devcontainers-cli" + icon = "/icon/devcontainers.svg" + script = templatefile("${path.module}/run.sh", {}) + run_on_start = true +} diff --git a/devcontainers-cli/run.sh b/devcontainers-cli/run.sh new file mode 100755 index 000000000..bd3c1b1dc --- /dev/null +++ b/devcontainers-cli/run.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env sh + +# If @devcontainers/cli is already installed, we can skip +if command -v devcontainer > /dev/null 2>&1; then + echo "🥳 @devcontainers/cli is already installed into $(which devcontainer)!" + exit 0 +fi + +# Check if docker is installed +if ! command -v docker > /dev/null 2>&1; then + echo "WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available." +fi + +# Determine the package manager to use: npm, pnpm, or yarn +if command -v yarn > /dev/null 2>&1; then + PACKAGE_MANAGER="yarn" +elif command -v npm > /dev/null 2>&1; then + PACKAGE_MANAGER="npm" +elif command -v pnpm > /dev/null 2>&1; then + PACKAGE_MANAGER="pnpm" +else + echo "ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first." 1>&2 + exit 1 +fi + +install() { + echo "Installing @devcontainers/cli using $PACKAGE_MANAGER..." + if [ "$PACKAGE_MANAGER" = "npm" ]; then + npm install -g @devcontainers/cli + elif [ "$PACKAGE_MANAGER" = "pnpm" ]; then + # Check if PNPM_HOME is set, if not, set it to the script's bin directory + # pnpm needs this to be set to install binaries + # coder agent ensures this part is part of the PATH + # so that the devcontainer command is available + if [ -z "$PNPM_HOME" ]; then + PNPM_HOME="$CODER_SCRIPT_BIN_DIR" + export M_HOME + fi + pnpm add -g @devcontainers/cli + elif [ "$PACKAGE_MANAGER" = "yarn" ]; then + yarn global add @devcontainers/cli --prefix "$(dirname "$CODER_SCRIPT_BIN_DIR")" + fi +} + +if ! install; then + echo "Failed to install @devcontainers/cli" >&2 + exit 1 +fi + +if ! command -v devcontainer > /dev/null 2>&1; then + echo "Installation completed but 'devcontainer' command not found in PATH" >&2 + exit 1 +fi + +echo "🥳 @devcontainers/cli has been installed into $(which devcontainer)!" +exit 0 diff --git a/dotfiles/README.md b/dotfiles/README.md index 6d7673cc8..4a911f87d 100644 --- a/dotfiles/README.md +++ b/dotfiles/README.md @@ -17,8 +17,9 @@ 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.18" + version = "1.0.29" agent_id = coder_agent.example.id } ``` @@ -29,8 +30,9 @@ module "dotfiles" { ```tf module "dotfiles" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/dotfiles/coder" - version = "1.0.18" + version = "1.0.29" agent_id = coder_agent.example.id } ``` @@ -39,8 +41,9 @@ module "dotfiles" { ```tf module "dotfiles" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/dotfiles/coder" - version = "1.0.18" + version = "1.0.29" agent_id = coder_agent.example.id user = "root" } @@ -50,14 +53,16 @@ module "dotfiles" { ```tf module "dotfiles" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/dotfiles/coder" - version = "1.0.18" + version = "1.0.29" agent_id = coder_agent.example.id } module "dotfiles-root" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/dotfiles/coder" - version = "1.0.18" + version = "1.0.29" agent_id = coder_agent.example.id user = "root" dotfiles_uri = module.dotfiles.dotfiles_uri @@ -70,8 +75,9 @@ 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.18" + version = "1.0.29" agent_id = coder_agent.example.id default_dotfiles_uri = "https://github.com/coder/dotfiles" } diff --git a/dotfiles/run.sh b/dotfiles/run.sh index 946343920..e0599418c 100644 --- a/dotfiles/run.sh +++ b/dotfiles/run.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash + +set -euo pipefail + DOTFILES_URI="${DOTFILES_URI}" DOTFILES_USER="${DOTFILES_USER}" diff --git a/exoscale-instance-type/README.md b/exoscale-instance-type/README.md index 4296121c4..19083c3a0 100644 --- a/exoscale-instance-type/README.md +++ b/exoscale-instance-type/README.md @@ -16,6 +16,7 @@ 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" @@ -44,6 +45,7 @@ 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" @@ -78,6 +80,7 @@ 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" diff --git a/exoscale-zone/README.md b/exoscale-zone/README.md index 0f4353e52..611aee5b0 100644 --- a/exoscale-zone/README.md +++ b/exoscale-zone/README.md @@ -16,6 +16,7 @@ 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" @@ -43,6 +44,7 @@ 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" diff --git a/filebrowser/README.md b/filebrowser/README.md index 1b53ffaa6..3a0e56bda 100644 --- a/filebrowser/README.md +++ b/filebrowser/README.md @@ -13,8 +13,9 @@ 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.22" + version = "1.0.31" agent_id = coder_agent.example.id } ``` @@ -27,8 +28,9 @@ module "filebrowser" { ```tf module "filebrowser" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/filebrowser/coder" - version = "1.0.22" + version = "1.0.31" agent_id = coder_agent.example.id folder = "/home/coder/project" } @@ -38,8 +40,9 @@ module "filebrowser" { ```tf module "filebrowser" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/filebrowser/coder" - version = "1.0.22" + version = "1.0.31" agent_id = coder_agent.example.id database_path = ".config/filebrowser.db" } @@ -49,7 +52,9 @@ module "filebrowser" { ```tf module "filebrowser" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" agent_id = coder_agent.example.id agent_name = "main" subdomain = false diff --git a/filebrowser/main.test.ts b/filebrowser/main.test.ts index 7dd49724e..346808808 100644 --- a/filebrowser/main.test.ts +++ b/filebrowser/main.test.ts @@ -3,9 +3,27 @@ import { executeScriptInContainer, runTerraformApply, runTerraformInit, + type scriptOutput, testRequiredVariables, } from "../test"; +function testBaseLine(output: scriptOutput) { + expect(output.exitCode).toBe(0); + + const expectedLines = [ + "\u001b[[0;1mInstalling filebrowser ", + "🥳 Installation complete! ", + "👷 Starting filebrowser in background... ", + "📂 Serving /root at http://localhost:13339 ", + "📝 Logs at /tmp/filebrowser.log", + ]; + + // we could use expect(output.stdout).toEqual(expect.arrayContaining(expectedLines)), but when it errors, it doesn't say which line is wrong + for (const line of expectedLines) { + expect(output.stdout).toContain(line); + } +} + describe("filebrowser", async () => { await runTerraformInit(import.meta.dir); @@ -28,21 +46,15 @@ describe("filebrowser", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", }); - const output = await executeScriptInContainer(state, "alpine"); - expect(output.exitCode).toBe(0); - expect(output.stdout).toEqual([ - "\u001b[0;1mInstalling filebrowser ", - "", - "🥳 Installation complete! ", - "", - "👷 Starting filebrowser in background... ", - "", - "📂 Serving /root at http://localhost:13339 ", - "", - "Running 'filebrowser --noauth --root /root --port 13339' ", - "", - "📝 Logs at /tmp/filebrowser.log", - ]); + + const output = await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); }); it("runs with database_path var", async () => { @@ -50,21 +62,15 @@ describe("filebrowser", async () => { agent_id: "foo", database_path: ".config/filebrowser.db", }); - 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 /root at http://localhost:13339 ", - "", - "Running 'filebrowser --noauth --root /root --port 13339 -d .config/filebrowser.db' ", - "", - "📝 Logs at /tmp/filebrowser.log", - ]); + + const output = await await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); }); it("runs with folder var", async () => { @@ -72,21 +78,12 @@ describe("filebrowser", async () => { agent_id: "foo", folder: "/home/coder/project", }); - 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' ", - "", - "📝 Logs at /tmp/filebrowser.log", - ]); + const output = await await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); }); it("runs with subdomain=false", async () => { @@ -95,20 +92,14 @@ describe("filebrowser", async () => { agent_name: "main", subdomain: false, }); - 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 /root at http://localhost:13339 ", - "", - "Running 'filebrowser --noauth --root /root --port 13339' ", - "", - "📝 Logs at /tmp/filebrowser.log", - ]); + + const output = await await executeScriptInContainer( + state, + "alpine/curl", + "sh", + "apk add bash", + ); + + testBaseLine(output); }); }); diff --git a/filebrowser/main.tf b/filebrowser/main.tf index 4fd74598b..ba83844b0 100644 --- a/filebrowser/main.tf +++ b/filebrowser/main.tf @@ -20,13 +20,8 @@ data "coder_workspace_owner" "me" {} variable "agent_name" { type = string - description = "The name of the main deployment. (Used to build the subpath for coder_app.)" - default = "" - validation { - # If subdomain is false, then agent_name must be set. - condition = var.subdomain || var.agent_name != "" - error_message = "The agent_name must be set." - } + 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" { @@ -73,6 +68,12 @@ variable "order" { default = null } +variable "slug" { + type = string + description = "The slug of the coder_app resource." + default = "filebrowser" +} + variable "subdomain" { type = bool description = <<-EOT @@ -85,7 +86,7 @@ variable "subdomain" { resource "coder_script" "filebrowser" { agent_id = var.agent_id display_name = "File Browser" - icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg" + icon = "/icon/filebrowser.svg" script = templatefile("${path.module}/run.sh", { LOG_PATH : var.log_path, PORT : var.port, @@ -93,18 +94,30 @@ resource "coder_script" "filebrowser" { LOG_PATH : var.log_path, DB_PATH : var.database_path, SUBDOMAIN : var.subdomain, - SERVER_BASE_PATH : var.subdomain ? "" : format("/@%s/%s.%s/apps/filebrowser", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name), + SERVER_BASE_PATH : local.server_base_path }) run_on_start = true } resource "coder_app" "filebrowser" { agent_id = var.agent_id - slug = "filebrowser" + slug = var.slug display_name = "File Browser" - url = "http://localhost:${var.port}" - icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg" + url = local.url + icon = "/icon/filebrowser.svg" subdomain = var.subdomain share = var.share order = var.order + + healthcheck { + url = local.healthcheck_url + interval = 5 + threshold = 6 + } } + +locals { + server_base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug) + url = "http://localhost:${var.port}${local.server_base_path}" + healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/health" +} \ No newline at end of file diff --git a/filebrowser/run.sh b/filebrowser/run.sh index 8a31d4dbd..62f04edf6 100644 --- a/filebrowser/run.sh +++ b/filebrowser/run.sh @@ -1,29 +1,39 @@ #!/usr/bin/env bash -BOLD='\033[0;1m' +set -euo pipefail + +BOLD='\033[[0;1m' + printf "$${BOLD}Installing filebrowser \n\n" -curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash +# Check if filebrowser is installed +if ! command -v filebrowser &> /dev/null; then + curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash +fi printf "🥳 Installation complete! \n\n" -printf "👷 Starting filebrowser in background... \n\n" +printf "🛠️ Configuring filebrowser \n\n" ROOT_DIR=${FOLDER} ROOT_DIR=$${ROOT_DIR/\~/$HOME} -DB_FLAG="" -if [ "${DB_PATH}" != "filebrowser.db" ]; then - DB_FLAG=" -d ${DB_PATH}" +echo "DB_PATH: ${DB_PATH}" + +export FB_DATABASE="${DB_PATH}" + +# Check if filebrowser db exists +if [[ ! -f "${DB_PATH}" ]]; then + filebrowser config init 2>&1 | tee -a ${LOG_PATH} + filebrowser users add admin "" --perm.admin=true --viewMode=mosaic 2>&1 | tee -a ${LOG_PATH} fi -# set baseurl to be able to run if sudomain=false; if subdomain=true the SERVER_BASE_PATH value will be "" -filebrowser config set --baseurl "${SERVER_BASE_PATH}"$${DB_FLAG} > ${LOG_PATH} 2>&1 +filebrowser config set --baseurl=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH} -printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n" +printf "👷 Starting filebrowser in background... \n\n" -printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG}' \n\n" +printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n" -filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG} > ${LOG_PATH} 2>&1 & +filebrowser >> ${LOG_PATH} 2>&1 & printf "📝 Logs at ${LOG_PATH} \n\n" diff --git a/fly-region/README.md b/fly-region/README.md index e5f446ef4..30bcb136a 100644 --- a/fly-region/README.md +++ b/fly-region/README.md @@ -15,6 +15,7 @@ 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" @@ -31,6 +32,7 @@ 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" @@ -46,6 +48,7 @@ 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" diff --git a/gcp-region/README.md b/gcp-region/README.md index 776d638cd..a74807f39 100644 --- a/gcp-region/README.md +++ b/gcp-region/README.md @@ -13,6 +13,7 @@ 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"] @@ -33,6 +34,7 @@ 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"] @@ -49,6 +51,7 @@ 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"] @@ -64,6 +67,7 @@ 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"] diff --git a/git-clone/README.md b/git-clone/README.md index 6b8871eb2..0647f7f93 100644 --- a/git-clone/README.md +++ b/git-clone/README.md @@ -13,6 +13,7 @@ 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" agent_id = coder_agent.example.id @@ -26,6 +27,7 @@ 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" agent_id = coder_agent.example.id @@ -40,6 +42,7 @@ 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" agent_id = coder_agent.example.id @@ -65,6 +68,7 @@ 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" agent_id = coder_agent.example.id @@ -73,23 +77,24 @@ module "git_clone" { # Create a code-server instance for the cloned repository module "code-server" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/code-server/coder" version = "1.0.18" agent_id = coder_agent.example.id order = 1 - folder = "/home/${local.username}/${module.git_clone.folder_name}" + folder = "/home/${local.username}/${module.git_clone[count.index].folder_name}" } # Create a Coder app for the website resource "coder_app" "website" { + count = data.coder_workspace.me.start_count agent_id = coder_agent.example.id order = 2 slug = "website" external = true - display_name = module.git_clone.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 + 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" } ``` @@ -97,6 +102,7 @@ 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" agent_id = coder_agent.example.id @@ -115,6 +121,7 @@ To GitLab clone with a specific branch like `feat/example` ```tf module "git-clone" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/git-clone/coder" version = "1.0.18" agent_id = coder_agent.example.id @@ -126,6 +133,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com` ```tf module "git-clone" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/git-clone/coder" version = "1.0.18" agent_id = coder_agent.example.id @@ -146,6 +154,7 @@ For example, to clone the `feat/example` branch: ```tf module "git-clone" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/git-clone/coder" version = "1.0.18" agent_id = coder_agent.example.id @@ -162,6 +171,7 @@ 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 diff --git a/git-commit-signing/README.md b/git-commit-signing/README.md index 942f2f35e..1f71cbb3b 100644 --- a/git-commit-signing/README.md +++ b/git-commit-signing/README.md @@ -9,6 +9,9 @@ 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. @@ -18,6 +21,7 @@ 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 diff --git a/git-config/README.md b/git-config/README.md index 90e8442c2..5ba0806be 100644 --- a/git-config/README.md +++ b/git-config/README.md @@ -13,6 +13,7 @@ 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 @@ -27,6 +28,7 @@ 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 @@ -40,6 +42,7 @@ 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 diff --git a/github-upload-public-key/README.md b/github-upload-public-key/README.md index 17464f35d..192db7ebb 100644 --- a/github-upload-public-key/README.md +++ b/github-upload-public-key/README.md @@ -13,6 +13,7 @@ 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 @@ -45,6 +46,7 @@ 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 diff --git a/goose/README.md b/goose/README.md new file mode 100644 index 000000000..89014891d --- /dev/null +++ b/goose/README.md @@ -0,0 +1,160 @@ +--- +display_name: Goose +description: Run Goose in your workspace +icon: ../.icons/goose.svg +maintainer_github: coder +verified: true +tags: [agent, goose] +--- + +# Goose + +Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks. + +```tf +module "goose" { + source = "registry.coder.com/modules/goose/coder" + version = "1.1.0" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" +} +``` + +### Prerequisites + +- `screen` must be installed in your workspace to run Goose in the background +- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template + +The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. + +## Examples + +Your workspace must have `screen` installed to use this. + +### Run in the background and report tasks (Experimental) + +> This functionality is in early access as of Coder v2.21 and is still evolving. +> For now, we recommend testing it in a demo or staging environment, +> rather than deploying to production +> +> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents) +> +> Join our [Discord channel](https://discord.gg/coder) or +> [contact us](https://coder.com/contact) to get help or share feedback. + +```tf +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder-login/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} + +variable "anthropic_api_key" { + type = string + description = "The Anthropic API key" + sensitive = true +} + +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Write a prompt for Goose" + mutable = true +} + +# Set the prompt and system prompt for Goose via environment variables +resource "coder_agent" "main" { + # ... + env = { + GOOSE_SYSTEM_PROMPT = <<-EOT + You are a helpful assistant that can help write code. + + Run all long running tasks (e.g. npm run dev) in the background and not in the foreground. + + Periodically check in on background tasks. + + Notify Coder of the status of the task before and after your steps. + EOT + GOOSE_TASK_PROMPT = data.coder_parameter.ai_prompt.value + + # An API key is required for experiment_auto_configure + # See https://block.github.io/goose/docs/getting-started/providers + ANTHROPIC_API_KEY = var.anthropic_api_key # or use a coder_parameter + } +} + +module "goose" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/goose/coder" + version = "1.1.0" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" + + # Enable experimental features + experiment_report_tasks = true + + # Run Goose in the background + experiment_use_screen = true + + # Avoid configuring Goose manually + experiment_auto_configure = true + + # Required for experiment_auto_configure + experiment_goose_provider = "anthropic" + experiment_goose_model = "claude-3-5-sonnet-latest" +} +``` + +### Adding Custom Extensions (MCP) + +You can extend Goose's capabilities by adding custom extensions. For example, to add the desktop-commander extension: + +```tf +module "goose" { + # ... other configuration ... + + experiment_pre_install_script = <<-EOT + npm i -g @wonderwhy-er/desktop-commander@latest + EOT + + experiment_additional_extensions = <<-EOT + desktop-commander: + args: [] + cmd: desktop-commander + description: Ideal for background tasks + enabled: true + envs: {} + name: desktop-commander + timeout: 300 + type: stdio + EOT +} +``` + +This will add the desktop-commander extension to Goose, allowing it to run commands in the background. The extension will be available in the Goose interface and can be used to run long-running processes like development servers. + +Note: The indentation in the heredoc is preserved, so you can write the YAML naturally. + +## Run standalone + +Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or any task reporting to the Coder UI. + +```tf +module "goose" { + source = "registry.coder.com/modules/goose/coder" + version = "1.1.0" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" + + # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL + icon = "https://raw.githubusercontent.com/block/goose/refs/heads/main/ui/desktop/src/images/icon.svg" +} +``` diff --git a/goose/main.tf b/goose/main.tf new file mode 100644 index 000000000..0043000ec --- /dev/null +++ b/goose/main.tf @@ -0,0 +1,289 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/goose.svg" +} + +variable "folder" { + type = string + description = "The folder to run Goose in." + default = "/home/coder" +} + +variable "install_goose" { + type = bool + description = "Whether to install Goose." + default = true +} + +variable "goose_version" { + type = string + description = "The version of Goose to install." + default = "stable" +} + +variable "experiment_use_screen" { + type = bool + description = "Whether to use screen for running Goose in the background." + default = false +} + +variable "experiment_report_tasks" { + type = bool + description = "Whether to enable task reporting." + default = false +} + +variable "experiment_auto_configure" { + type = bool + description = "Whether to automatically configure Goose." + default = false +} + +variable "experiment_goose_provider" { + type = string + description = "The provider to use for Goose (e.g., anthropic)." + default = null +} + +variable "experiment_goose_model" { + type = string + description = "The model to use for Goose (e.g., claude-3-5-sonnet-latest)." + default = null +} + +variable "experiment_pre_install_script" { + type = string + description = "Custom script to run before installing Goose." + default = null +} + +variable "experiment_post_install_script" { + type = string + description = "Custom script to run after installing Goose." + default = null +} + +variable "experiment_additional_extensions" { + type = string + description = "Additional extensions configuration in YAML format to append to the config." + default = null +} + +locals { + base_extensions = <<-EOT +coder: + args: + - exp + - mcp + - server + cmd: coder + description: Report ALL tasks and statuses (in progress, done, failed) you are working on. + enabled: true + envs: + CODER_MCP_APP_STATUS_SLUG: goose + name: Coder + timeout: 3000 + type: stdio +developer: + display_name: Developer + enabled: true + name: developer + timeout: 300 + type: builtin +EOT + + # Add two spaces to each line of extensions to match YAML structure + formatted_base = " ${replace(trimspace(local.base_extensions), "\n", "\n ")}" + additional_extensions = var.experiment_additional_extensions != null ? "\n ${replace(trimspace(var.experiment_additional_extensions), "\n", "\n ")}" : "" + + combined_extensions = <<-EOT +extensions: +${local.formatted_base}${local.additional_extensions} +EOT + + encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" + encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" +} + +# Install and Initialize Goose +resource "coder_script" "goose" { + agent_id = var.agent_id + display_name = "Goose" + icon = var.icon + script = <<-EOT + #!/bin/bash + set -e + + # Function to check if a command exists + command_exists() { + command -v "$1" >/dev/null 2>&1 + } + + # Run pre-install script if provided + if [ -n "${local.encoded_pre_install_script}" ]; then + echo "Running pre-install script..." + echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh + chmod +x /tmp/pre_install.sh + /tmp/pre_install.sh + fi + + # Install Goose if enabled + if [ "${var.install_goose}" = "true" ]; then + if ! command_exists npm; then + echo "Error: npm is not installed. Please install Node.js and npm first." + exit 1 + fi + echo "Installing Goose..." + RELEASE_TAG=v${var.goose_version} curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash + fi + + # Run post-install script if provided + if [ -n "${local.encoded_post_install_script}" ]; then + echo "Running post-install script..." + echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh + chmod +x /tmp/post_install.sh + /tmp/post_install.sh + fi + + # Configure Goose if auto-configure is enabled + if [ "${var.experiment_auto_configure}" = "true" ]; then + echo "Configuring Goose..." + mkdir -p "$HOME/.config/goose" + cat > "$HOME/.config/goose/config.yaml" << EOL +GOOSE_PROVIDER: ${var.experiment_goose_provider} +GOOSE_MODEL: ${var.experiment_goose_model} +${trimspace(local.combined_extensions)} +EOL + fi + + # Write system prompt to config + mkdir -p "$HOME/.config/goose" + echo "$GOOSE_SYSTEM_PROMPT" > "$HOME/.config/goose/.goosehints" + + # Run with screen if enabled + if [ "${var.experiment_use_screen}" = "true" ]; then + echo "Running Goose in the background..." + + # Check if screen is installed + if ! command_exists screen; then + echo "Error: screen is not installed. Please install screen manually." + exit 1 + fi + + touch "$HOME/.goose.log" + + # Ensure the screenrc exists + if [ ! -f "$HOME/.screenrc" ]; then + echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.goose.log" + echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" + fi + + if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then + echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.goose.log" + echo "multiuser on" >> "$HOME/.screenrc" + fi + + if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then + echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.goose.log" + echo "acladd $(whoami)" >> "$HOME/.screenrc" + fi + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + # Determine goose command + if command_exists goose; then + GOOSE_CMD=goose + elif [ -f "$HOME/.local/bin/goose" ]; then + GOOSE_CMD="$HOME/.local/bin/goose" + else + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 + fi + + screen -U -dmS goose bash -c " + cd ${var.folder} + \"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\" + /bin/bash + " + else + # Check if goose is installed before running + if command_exists goose; then + GOOSE_CMD=goose + elif [ -f "$HOME/.local/bin/goose" ]; then + GOOSE_CMD="$HOME/.local/bin/goose" + else + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 + fi + fi + EOT + run_on_start = true +} + +resource "coder_app" "goose" { + slug = "goose" + display_name = "Goose" + agent_id = var.agent_id + command = <<-EOT + #!/bin/bash + set -e + + # Function to check if a command exists + command_exists() { + command -v "$1" >/dev/null 2>&1 + } + + # Determine goose command + if command_exists goose; then + GOOSE_CMD=goose + elif [ -f "$HOME/.local/bin/goose" ]; then + GOOSE_CMD="$HOME/.local/bin/goose" + else + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 + fi + + if [ "${var.experiment_use_screen}" = "true" ]; then + # Check if session exists first + if ! screen -list | grep -q "goose"; then + echo "Error: No existing Goose session found. Please wait for the script to start it." + exit 1 + fi + # Only attach to existing session + screen -xRR goose + else + cd ${var.folder} + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + "$GOOSE_CMD" run --text "Review goosehints. Your task: $GOOSE_TASK_PROMPT" --interactive + fi + EOT + icon = var.icon +} diff --git a/jetbrains-gateway/README.md b/jetbrains-gateway/README.md index 0745fa79c..f3fc33f77 100644 --- a/jetbrains-gateway/README.md +++ b/jetbrains-gateway/README.md @@ -11,12 +11,15 @@ 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.21" + version = "1.1.0" agent_id = coder_agent.example.id - agent_name = "example" folder = "/home/coder/example" jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"] default = "GO" @@ -31,39 +34,64 @@ 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.21" + version = "1.1.0" agent_id = coder_agent.example.id - agent_name = "example" folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] default = "GO" } ``` -### Use the latest release version +### Use the latest version of each IDE ```tf module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/jetbrains-gateway/coder" - version = "1.0.21" + version = "1.1.0" agent_id = coder_agent.example.id - agent_name = "example" folder = "/home/coder/example" - jetbrains_ides = ["GO", "WS"] - default = "GO" + jetbrains_ides = ["IU", "PY"] + default = "IU" latest = true } ``` +### Use fixed versions set by `jetbrains_ide_versions` + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.1.0" + 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.21" + version = "1.1.0" agent_id = coder_agent.example.id - agent_name = "example" folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] default = "GO" @@ -72,15 +100,34 @@ 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.1.0" + 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`) -- WebStorm (`WS`) -- IntelliJ IDEA Ultimate (`IU`) -- PyCharm Professional (`PY`) -- PhpStorm (`PS`) -- CLion (`CL`) -- RubyMine (`RM`) -- Rider (`RD`) +- [GoLand (`GO`)](https://www.jetbrains.com/go/) +- [WebStorm (`WS`)](https://www.jetbrains.com/webstorm/) +- [IntelliJ IDEA Ultimate (`IU`)](https://www.jetbrains.com/idea/) +- [PyCharm Professional (`PY`)](https://www.jetbrains.com/pycharm/) +- [PhpStorm (`PS`)](https://www.jetbrains.com/phpstorm/) +- [CLion (`CL`)](https://www.jetbrains.com/clion/) +- [RubyMine (`RM`)](https://www.jetbrains.com/ruby/) +- [Rider (`RD`)](https://www.jetbrains.com/rider/) +- [RustRover (`RR`)](https://www.jetbrains.com/rust/) diff --git a/jetbrains-gateway/main.test.ts b/jetbrains-gateway/main.test.ts index 0a5b3bc32..ea04a77db 100644 --- a/jetbrains-gateway/main.test.ts +++ b/jetbrains-gateway/main.test.ts @@ -10,7 +10,6 @@ describe("jetbrains-gateway", async () => { await testRequiredVariables(import.meta.dir, { agent_id: "foo", - agent_name: "foo", folder: "/home/foo", }); @@ -18,11 +17,10 @@ describe("jetbrains-gateway", async () => { const state = await runTerraformApply(import.meta.dir, { // These are all required. agent_id: "foo", - agent_name: "foo", folder: "/home/coder", }); expect(state.outputs.url.value).toBe( - "jetbrains-gateway://connect#type=coder&workspace=default&owner=default&agent=foo&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=241.14494.240&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.1.tar.gz", + "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( @@ -37,7 +35,6 @@ describe("jetbrains-gateway", async () => { 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"]', }); diff --git a/jetbrains-gateway/main.tf b/jetbrains-gateway/main.tf index 2bc00d397..502469f29 100644 --- a/jetbrains-gateway/main.tf +++ b/jetbrains-gateway/main.tf @@ -13,6 +13,16 @@ terraform { } } +variable "arch" { + type = string + description = "The target architecture of the workspace" + default = "amd64" + validation { + condition = contains(["amd64", "arm64"], var.arch) + error_message = "Architecture must be either 'amd64' or 'arm64'." + } +} + variable "agent_id" { type = string description = "The ID of a Coder agent." @@ -26,7 +36,9 @@ variable "slug" { variable "agent_name" { type = string - description = "Agent name." + description = "Agent name. (unused). Will be removed in a future version" + + default = "" } variable "folder" { @@ -80,59 +92,63 @@ variable "jetbrains_ide_versions" { description = "The set of versions for each jetbrains IDE" default = { "IU" = { - build_number = "241.14494.240" - version = "2024.1" + build_number = "243.21565.193" + version = "2024.3" } "PS" = { - build_number = "241.14494.237" - version = "2024.1" + build_number = "243.21565.202" + version = "2024.3" } "WS" = { - build_number = "241.14494.235" - version = "2024.1" + build_number = "243.21565.180" + version = "2024.3" } "PY" = { - build_number = "241.14494.241" - version = "2024.1" + build_number = "243.21565.199" + version = "2024.3" } "CL" = { - build_number = "241.14494.288" + build_number = "243.21565.238" version = "2024.1" } "GO" = { - build_number = "241.14494.238" - version = "2024.1" + build_number = "243.21565.208" + version = "2024.3" } "RM" = { - build_number = "241.14494.234" - version = "2024.1" + build_number = "243.21565.197" + version = "2024.3" } "RD" = { - build_number = "241.14494.307" - version = "2024.1" + build_number = "243.21565.191" + version = "2024.3" + } + "RR" = { + build_number = "243.22562.230" + version = "2024.3" } } validation { condition = ( alltrue([ - for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"], code) + for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code) ]) ) - error_message = "The jetbrains_ide_versions must contain a map of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"])}." + error_message = "The jetbrains_ide_versions must contain a map of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}." } } variable "jetbrains_ides" { type = list(string) description = "The list of IDE product codes." - default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"] + default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"] validation { condition = ( alltrue([ - for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"], code) + for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code) ]) ) - error_message = "The jetbrains_ides must be a list of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"])}." + error_message = "The jetbrains_ides must be a list of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}." } # check if the list is empty validation { @@ -146,76 +162,126 @@ 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 = "https://data.services.jetbrains.com/products/releases?code=${each.key}&latest=true&type=${var.channel}" + url = "${var.releases_base_link}/products/releases?code=${each.key}&latest=true&type=${var.channel}" } locals { + # AMD64 versions of the images just use the version string, while ARM64 + # versions append "-aarch64". Eg: + # + # https://download.jetbrains.com/idea/ideaIU-2025.1.tar.gz + # https://download.jetbrains.com/idea/ideaIU-2025.1.tar.gz + # + # We rewrite the data map above dynamically based on the user's architecture parameter. + # + effective_jetbrains_ide_versions = { + for k, v in var.jetbrains_ide_versions : k => { + build_number = v.build_number + version = var.arch == "arm64" ? "${v.version}-aarch64" : v.version + } + } + + # When downloading the latest IDE, the download link in the JSON is either: + # + # linux.download_link + # linuxARM64.download_link + # + download_key = var.arch == "arm64" ? "linuxARM64" : "linux" + jetbrains_ides = { "GO" = { icon = "/icon/goland.svg", name = "GoLand", identifier = "GO", - build_number = var.jetbrains_ide_versions["GO"].build_number, - download_link = "https://download.jetbrains.com/go/goland-${var.jetbrains_ide_versions["GO"].version}.tar.gz" - version = var.jetbrains_ide_versions["GO"].version + build_number = local.effective_jetbrains_ide_versions["GO"].build_number, + download_link = "${var.download_base_link}/go/goland-${local.effective_jetbrains_ide_versions["GO"].version}.tar.gz" + version = local.effective_jetbrains_ide_versions["GO"].version }, "WS" = { icon = "/icon/webstorm.svg", name = "WebStorm", identifier = "WS", - build_number = var.jetbrains_ide_versions["WS"].build_number, - download_link = "https://download.jetbrains.com/webstorm/WebStorm-${var.jetbrains_ide_versions["WS"].version}.tar.gz" - version = var.jetbrains_ide_versions["WS"].version + build_number = local.effective_jetbrains_ide_versions["WS"].build_number, + download_link = "${var.download_base_link}/webstorm/WebStorm-${local.effective_jetbrains_ide_versions["WS"].version}.tar.gz" + version = local.effective_jetbrains_ide_versions["WS"].version }, "IU" = { icon = "/icon/intellij.svg", name = "IntelliJ IDEA Ultimate", identifier = "IU", - build_number = var.jetbrains_ide_versions["IU"].build_number, - download_link = "https://download.jetbrains.com/idea/ideaIU-${var.jetbrains_ide_versions["IU"].version}.tar.gz" - version = var.jetbrains_ide_versions["IU"].version + build_number = local.effective_jetbrains_ide_versions["IU"].build_number, + download_link = "${var.download_base_link}/idea/ideaIU-${local.effective_jetbrains_ide_versions["IU"].version}.tar.gz" + version = local.effective_jetbrains_ide_versions["IU"].version }, "PY" = { icon = "/icon/pycharm.svg", name = "PyCharm Professional", identifier = "PY", - build_number = var.jetbrains_ide_versions["PY"].build_number, - download_link = "https://download.jetbrains.com/python/pycharm-professional-${var.jetbrains_ide_versions["PY"].version}.tar.gz" - version = var.jetbrains_ide_versions["PY"].version + build_number = local.effective_jetbrains_ide_versions["PY"].build_number, + download_link = "${var.download_base_link}/python/pycharm-professional-${local.effective_jetbrains_ide_versions["PY"].version}.tar.gz" + version = local.effective_jetbrains_ide_versions["PY"].version }, "CL" = { icon = "/icon/clion.svg", name = "CLion", identifier = "CL", - build_number = var.jetbrains_ide_versions["CL"].build_number, - download_link = "https://download.jetbrains.com/cpp/CLion-${var.jetbrains_ide_versions["CL"].version}.tar.gz" - version = var.jetbrains_ide_versions["CL"].version + build_number = local.effective_jetbrains_ide_versions["CL"].build_number, + download_link = "${var.download_base_link}/cpp/CLion-${local.effective_jetbrains_ide_versions["CL"].version}.tar.gz" + version = local.effective_jetbrains_ide_versions["CL"].version }, "PS" = { icon = "/icon/phpstorm.svg", name = "PhpStorm", identifier = "PS", - build_number = var.jetbrains_ide_versions["PS"].build_number, - download_link = "https://download.jetbrains.com/webide/PhpStorm-${var.jetbrains_ide_versions["PS"].version}.tar.gz" - version = var.jetbrains_ide_versions["PS"].version + build_number = local.effective_jetbrains_ide_versions["PS"].build_number, + download_link = "${var.download_base_link}/webide/PhpStorm-${local.effective_jetbrains_ide_versions["PS"].version}.tar.gz" + version = local.effective_jetbrains_ide_versions["PS"].version }, "RM" = { icon = "/icon/rubymine.svg", name = "RubyMine", identifier = "RM", - build_number = var.jetbrains_ide_versions["RM"].build_number, - download_link = "https://download.jetbrains.com/ruby/RubyMine-${var.jetbrains_ide_versions["RM"].version}.tar.gz" - version = var.jetbrains_ide_versions["RM"].version - } + build_number = local.effective_jetbrains_ide_versions["RM"].build_number, + download_link = "${var.download_base_link}/ruby/RubyMine-${local.effective_jetbrains_ide_versions["RM"].version}.tar.gz" + version = local.effective_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 = "https://download.jetbrains.com/rider/JetBrains.Rider-${var.jetbrains_ide_versions["RD"].version}.tar.gz" - version = var.jetbrains_ide_versions["RD"].version + build_number = local.effective_jetbrains_ide_versions["RD"].build_number, + download_link = "${var.download_base_link}/rider/JetBrains.Rider-${local.effective_jetbrains_ide_versions["RD"].version}.tar.gz" + version = local.effective_jetbrains_ide_versions["RD"].version + }, + "RR" = { + icon = "/icon/rustrover.svg", + name = "RustRover", + identifier = "RR", + build_number = local.effective_jetbrains_ide_versions["RR"].build_number, + download_link = "${var.download_base_link}/rustrover/RustRover-${local.effective_jetbrains_ide_versions["RR"].version}.tar.gz" + version = local.effective_jetbrains_ide_versions["RR"].version } } @@ -224,7 +290,7 @@ locals { key = var.latest ? keys(local.json_data)[0] : "" display_name = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].name identifier = data.coder_parameter.jetbrains_ide.value - download_link = var.latest ? local.json_data[local.key][0].downloads.linux.link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link + download_link = var.latest ? local.json_data[local.key][0].downloads[local.download_key].link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link build_number = var.latest ? local.json_data[local.key][0].build : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].build_number version = var.latest ? local.json_data[local.key][0].version : var.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].version } @@ -263,8 +329,6 @@ resource "coder_app" "gateway" { data.coder_workspace.me.name, "&owner=", data.coder_workspace_owner.me.name, - "&agent=", - var.agent_name, "&folder=", var.folder, "&url=", diff --git a/jfrog-oauth/README.md b/jfrog-oauth/README.md index 4423a740c..894e1f325 100644 --- a/jfrog-oauth/README.md +++ b/jfrog-oauth/README.md @@ -16,6 +16,7 @@ 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" agent_id = coder_agent.example.id @@ -44,6 +45,7 @@ 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" agent_id = coder_agent.example.id @@ -72,6 +74,7 @@ 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" agent_id = coder_agent.example.id @@ -95,8 +98,8 @@ provider "docker" { # ... registry_auth { address = "https://example.jfrog.io/artifactory/api/docker/REPO-KEY" - username = module.jfrog.username - password = module.jfrog.access_token + username = try(module.jfrog[0].username, "") + password = try(module.jfrog[0].access_token, "") } } ``` diff --git a/jfrog-token/README.md b/jfrog-token/README.md index 146dc7f7a..ce1652229 100644 --- a/jfrog-token/README.md +++ b/jfrog-token/README.md @@ -15,7 +15,7 @@ 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.30" agent_id = coder_agent.example.id jfrog_url = "https://XXXX.jfrog.io" artifactory_access_token = var.artifactory_access_token @@ -42,7 +42,7 @@ 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.30" agent_id = coder_agent.example.id jfrog_url = "https://YYYY.jfrog.io" artifactory_access_token = var.artifactory_access_token # An admin access token @@ -75,7 +75,7 @@ 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.30" agent_id = coder_agent.example.id jfrog_url = "https://XXXX.jfrog.io" artifactory_access_token = var.artifactory_access_token @@ -95,7 +95,7 @@ data "coder_workspace" "me" {} module "jfrog" { source = "registry.coder.com/modules/jfrog-token/coder" - version = "1.0.19" + version = "1.0.30" agent_id = coder_agent.example.id jfrog_url = "https://XXXX.jfrog.io" artifactory_access_token = var.artifactory_access_token diff --git a/jfrog-token/main.test.ts b/jfrog-token/main.test.ts index 2c856720c..4ba2f52d3 100644 --- a/jfrog-token/main.test.ts +++ b/jfrog-token/main.test.ts @@ -20,6 +20,7 @@ describe("jfrog-token", async () => { refreshable?: boolean; expires_in?: number; username_field?: string; + username?: string; jfrog_server_id?: string; configure_code_server?: boolean; }; diff --git a/jfrog-token/main.tf b/jfrog-token/main.tf index f6f5f5b08..720e2d8c1 100644 --- a/jfrog-token/main.tf +++ b/jfrog-token/main.tf @@ -68,6 +68,12 @@ variable "username_field" { } } +variable "username" { + type = string + description = "Username to use for Artifactory. Overrides the field specified in `username_field`" + default = null +} + variable "agent_id" { type = string description = "The ID of a Coder agent." @@ -99,8 +105,8 @@ variable "package_managers" { } 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 + # The username to use for artifactory + username = coalesce(var.username, var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name) jfrog_host = split("://", var.jfrog_url)[1] common_values = { JFROG_URL = var.jfrog_url diff --git a/jupyter-notebook/README.md b/jupyter-notebook/README.md index 83d36cb98..56f7ff18a 100644 --- a/jupyter-notebook/README.md +++ b/jupyter-notebook/README.md @@ -15,6 +15,7 @@ A module that adds Jupyter Notebook in your Coder template. ```tf module "jupyter-notebook" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/jupyter-notebook/coder" version = "1.0.19" agent_id = coder_agent.example.id diff --git a/jupyterlab/README.md b/jupyterlab/README.md index 52d5a502e..abebdc826 100644 --- a/jupyterlab/README.md +++ b/jupyterlab/README.md @@ -15,8 +15,9 @@ 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.22" + version = "1.0.31" agent_id = coder_agent.example.id } ``` diff --git a/jupyterlab/main.test.ts b/jupyterlab/main.test.ts index cf9ac1f0b..a9789c391 100644 --- a/jupyterlab/main.test.ts +++ b/jupyterlab/main.test.ts @@ -33,6 +33,33 @@ const executeScriptInContainerWithPip = async ( }; }; +// executes the coder script after installing pip +const executeScriptInContainerWithUv = async ( + state: TerraformState, + image: string, + shell = "sh", +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + const respPipx = await execContainer(id, [ + shell, + "-c", + "apk --no-cache add uv gcc musl-dev linux-headers && uv venv", + ]); + const resp = await execContainer(id, [shell, "-c", instance.script]); + const stdout = resp.stdout.trim().split("\n"); + const stderr = resp.stderr.trim().split("\n"); + return { + exitCode: resp.exitCode, + stdout, + stderr, + }; +}; + describe("jupyterlab", async () => { await runTerraformInit(import.meta.dir); @@ -40,19 +67,36 @@ describe("jupyterlab", async () => { agent_id: "foo", }); - it("fails without pipx", async () => { + it("fails without installers", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", }); const output = await executeScriptInContainer(state, "alpine"); expect(output.exitCode).toBe(1); expect(output.stdout).toEqual([ - "\u001B[0;1mInstalling jupyterlab!", - "pipx is not installed", - "Please install pipx in your Dockerfile/VM image before running this script", + "Checking for a supported installer", + "No valid installer is not installed", + "Please install pipx or uv in your Dockerfile/VM image before running this script", ]); }); + // TODO: Add faster test to run with uv. + // currently times out. + // it("runs with uv", async () => { + // const state = await runTerraformApply(import.meta.dir, { + // agent_id: "foo", + // }); + // const output = await executeScriptInContainerWithUv(state, "python:3-alpine"); + // expect(output.exitCode).toBe(0); + // expect(output.stdout).toEqual([ + // "Checking for a supported installer", + // "uv is installed", + // "\u001B[0;1mInstalling jupyterlab!", + // "🥳 jupyterlab has been installed", + // "👷 Starting jupyterlab in background...check logs at /tmp/jupyterlab.log", + // ]); + // }); + // TODO: Add faster test to run with pipx. // currently times out. // it("runs with pipx", async () => { diff --git a/jupyterlab/run.sh b/jupyterlab/run.sh index aff21b726..be686e55f 100755 --- a/jupyterlab/run.sh +++ b/jupyterlab/run.sh @@ -1,4 +1,23 @@ #!/usr/bin/env sh +INSTALLER="" +check_available_installer() { + # check if pipx is installed + echo "Checking for a supported installer" + if command -v pipx > /dev/null 2>&1; then + echo "pipx is installed" + INSTALLER="pipx" + return + fi + # check if uv is installed + if command -v uv > /dev/null 2>&1; then + echo "uv is installed" + INSTALLER="uv" + return + fi + echo "No valid installer is not installed" + echo "Please install pipx or uv in your Dockerfile/VM image before running this script" + exit 1 +} if [ -n "${BASE_URL}" ]; then BASE_URL_FLAG="--ServerApp.base_url=${BASE_URL}" @@ -6,27 +25,31 @@ fi BOLD='\033[0;1m' -printf "$${BOLD}Installing jupyterlab!\n" - # check if jupyterlab is installed -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" - exit 1 - fi +if ! command -v jupyter-lab > /dev/null 2>&1; then # install jupyterlab - pipx install -q jupyterlab - printf "%s\n\n" "🥳 jupyterlab has been installed" + check_available_installer + printf "$${BOLD}Installing jupyterlab!\n" + case $INSTALLER in + uv) + uv pip install -q jupyterlab \ + && printf "%s\n" "🥳 jupyterlab has been installed" + JUPYTER="$HOME/.venv/bin/jupyter-lab" + ;; + pipx) + pipx install jupyterlab \ + && printf "%s\n" "🥳 jupyterlab has been installed" + JUPYTER="$HOME/.local/bin/jupyter-lab" + ;; + esac else printf "%s\n\n" "🥳 jupyterlab is already installed" + JUPYTER=$(command -v jupyter-lab) fi printf "👷 Starting jupyterlab in background..." printf "check logs at ${LOG_PATH}" -$HOME/.local/bin/jupyter-lab --no-browser \ +$JUPYTER --no-browser \ "$BASE_URL_FLAG" \ --ServerApp.ip='*' \ --ServerApp.port="${PORT}" \ diff --git a/kasmvnc/README.md b/kasmvnc/README.md index 3b7fe5078..9c3b28dbf 100644 --- a/kasmvnc/README.md +++ b/kasmvnc/README.md @@ -13,8 +13,9 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and ```tf module "kasmvnc" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/kasmvnc/coder" - version = "1.0.22" + version = "1.0.23" agent_id = coder_agent.example.id desktop_environment = "xfce" } diff --git a/kasmvnc/main.tf b/kasmvnc/main.tf index 3a730ff58..4265f3c7c 100644 --- a/kasmvnc/main.tf +++ b/kasmvnc/main.tf @@ -42,7 +42,7 @@ resource "coder_script" "kasm_vnc" { script = templatefile("${path.module}/run.sh", { PORT : var.port, DESKTOP_ENVIRONMENT : var.desktop_environment, - VERSION : var.kasm_version + KASM_VERSION : var.kasm_version }) run_on_start = true } diff --git a/kasmvnc/run.sh b/kasmvnc/run.sh index b83153762..c285b0501 100644 --- a/kasmvnc/run.sh +++ b/kasmvnc/run.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -#!/bin/bash +# Exit on error, undefined variables, and pipe failures +set -euo pipefail # Function to check if vncserver is already installed check_installed() { @@ -14,143 +15,167 @@ check_installed() { # Function to download a file using wget, curl, or busybox as a fallback download_file() { - local url=$1 - local output=$2 - if command -v wget &> /dev/null; then - wget $url -O $output - elif command -v curl &> /dev/null; then - curl -fsSL $url -o $output + 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 - busybox wget -O $output $url + # shellcheck disable=SC2034 + download_tool=(busybox wget -O-) else - echo "Neither wget, curl, nor busybox is installed. Please install one of them to proceed." + 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 - download_file $url /tmp/kasmvncserver.deb - sudo apt-get update - DEBIAN_FRONTEND=noninteractive sudo apt-get install --yes -qq --no-install-recommends --no-install-suggests /tmp/kasmvncserver.deb - sudo adduser $USER ssl-cert - rm /tmp/kasmvncserver.deb -} + local kasmdeb="/tmp/kasmvncserver.deb" -# Function to install kasmvncserver for Oracle 8 -install_rpm_oracle8() { - local url=$1 - download_file $url /tmp/kasmvncserver.rpm - sudo dnf config-manager --set-enabled ol8_codeready_builder - sudo dnf install oracle-epel-release-el8 -y - sudo dnf localinstall /tmp/kasmvncserver.rpm -y - sudo usermod -aG kasmvnc-cert $USER - rm /tmp/kasmvncserver.rpm -} + download_file "$url" "$kasmdeb" -# Function to install kasmvncserver for CentOS 7 -install_rpm_centos7() { - local url=$1 - download_file $url /tmp/kasmvncserver.rpm - sudo yum install epel-release -y - sudo yum install /tmp/kasmvncserver.rpm -y - sudo usermod -aG kasmvnc-cert $USER - rm /tmp/kasmvncserver.rpm + CACHE_DIR="/var/lib/apt/lists/partial" + # Check if the directory exists and was modified in the last 60 minutes + if [[ ! -d "$CACHE_DIR" ]] || ! find "$CACHE_DIR" -mmin -60 -print -quit &> /dev/null; then + echo "Stale package cache, updating..." + # Update package cache with a 300-second timeout for dpkg lock + sudo apt-get -o DPkg::Lock::Timeout=300 -qq update + fi + + DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests "$kasmdeb" + rm "$kasmdeb" } # Function to install kasmvncserver for rpm-based distros install_rpm() { local url=$1 - download_file $url /tmp/kasmvncserver.rpm - sudo rpm -i /tmp/kasmvncserver.rpm - rm /tmp/kasmvncserver.rpm + 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 - download_file $url /tmp/kasmvncserver.tgz - tar -xzf /tmp/kasmvncserver.tgz -C /usr/local/bin/ - rm /tmp/kasmvncserver.tgz + local kasmtgz="/tmp/kasmvncserver.tgz" + + download_file "$url" "$kasmtgz" + + tar -xzf "$kasmtgz" -C /usr/local/bin/ + rm "$kasmtgz" } # Detect system information -distro=$(grep "^ID=" /etc/os-release | awk -F= '{print $2}') -version=$(grep "^VERSION_ID=" /etc/os-release | awk -F= '{print $2}' | tr -d '"') -arch=$(uname -m) +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: $version" +echo "Detected Version: $distro_version" +echo "Detected Codename: $codename" echo "Detected Architecture: $arch" # Map arch to package arch -if [[ "$arch" == "x86_64" ]]; then - if [[ "$distro" == "ubuntu" || "$distro" == "debian" || "$distro" == "kali" ]]; then - arch="amd64" - else - arch="x86_64" - fi -elif [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then - if [[ "$distro" == "ubuntu" || "$distro" == "debian" || "$distro" == "kali" ]]; then - arch="arm64" - else - arch="aarch64" - fi -else - echo "Unsupported architecture: $arch" - exit 1 -fi +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 - echo "Installing KASM version: ${VERSION}" + # Check for NOPASSWD sudo (required) + if ! command -v sudo &> /dev/null || ! sudo -n true 2> /dev/null; then + echo "ERROR: sudo NOPASSWD access required!" + exit 1 + fi + + base_url="https://github.com/kasmtech/KasmVNC/releases/download/v${KASM_VERSION}" + + echo "Installing KASM version: ${KASM_VERSION}" case $distro in ubuntu | debian | kali) - case $version in - "20.04") - install_deb "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_focal_${VERSION}_$${arch}.deb" - ;; - "22.04") - install_deb "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_jammy_${VERSION}_$${arch}.deb" - ;; - "24.04") - install_deb "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_noble_${VERSION}_$${arch}.deb" - ;; - *) - echo "Unsupported Ubuntu/Debian/Kali version: $${version}" - exit 1 - ;; - esac + bin_name="kasmvncserver_$${codename}_${KASM_VERSION}_$${arch}.deb" + install_deb "$base_url/$bin_name" ;; - oracle) - if [[ "$version" == "8" ]]; then - install_rpm_oracle8 "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_oracle_8_${VERSION}_$${arch}.rpm" - else - echo "Unsupported Oracle version: $${version}" - exit 1 - fi - ;; - centos) - if [[ "$version" == "7" ]]; then - install_rpm_centos7 "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_centos_core_${VERSION}_$${arch}.rpm" - else - install_rpm "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_centos_core_${VERSION}_$${arch}.rpm" - fi + oracle | fedora | opensuse) + bin_name="kasmvncserver_$${distro}_$${distro_version}_${KASM_VERSION}_$${arch}.rpm" + install_rpm "$base_url/$bin_name" ;; alpine) - if [[ "$version" == "3.17" || "$version" == "3.18" || "$version" == "3.19" || "$version" == "3.20" ]]; then - install_alpine "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvnc.alpine_$${version}_$${arch}.tgz" - else - echo "Unsupported Alpine version: $${version}" - exit 1 - fi - ;; - fedora | opensuse) - install_rpm "https://github.com/kasmtech/KasmVNC/releases/download/v${VERSION}/kasmvncserver_$${distro}_$${version}_${VERSION}_$${arch}.rpm" + bin_name="kasmvnc.alpine_$${distro_version//./}_$${arch}.tgz" + install_alpine "$base_url/$bin_name" ;; *) - echo "Unsupported distribution: $${distro}" + echo "Unsupported distribution: $distro" exit 1 ;; esac @@ -158,22 +183,53 @@ else echo "vncserver already installed. Skipping installation." fi -# Coder port-forwarding from dashboard only supports HTTP -sudo bash -c "cat > /etc/kasmvnc/kasmvnc.yaml < /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" +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 +echo -e "password\npassword\n" | vncpasswd -wo -u "$USER" # Start the server printf "🚀 Starting KasmVNC server...\n" -sudo -u $USER bash -c "vncserver -select-de ${DESKTOP_ENVIRONMENT} -disableBasicAuth" > /tmp/kasmvncserver.log 2>&1 & +vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > /tmp/kasmvncserver.log 2>&1 & +pid=$! + +# Wait for server to start +sleep 5 +grep -v '^[[:space:]]*$' /tmp/kasmvncserver.log | tail -n 10 +if ps -p $pid | grep -q "^$pid"; then + echo "ERROR: Failed to start KasmVNC server. Check full logs at /tmp/kasmvncserver.log" + exit 1 +fi +printf "🚀 KasmVNC server started successfully!\n" diff --git a/nodejs/README.md b/nodejs/README.md index 25714aadf..b4420c1da 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -13,6 +13,7 @@ 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 @@ -25,6 +26,7 @@ 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 @@ -43,6 +45,7 @@ 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 diff --git a/package.json b/package.json index eea421d81..a122f4f2c 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,9 @@ "name": "modules", "scripts": { "test": "bun test", - "fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf", + "fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh terraform_validate.sh release.sh update_version.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf", "fmt:ci": "bun x prettier --check **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt -check **/*.tf .sample/main.tf", - "lint": "bun run lint.ts && ./terraform_validate.sh", - "update-version": "./update-version.sh" + "lint": "bun run lint.ts && ./terraform_validate.sh" }, "devDependencies": { "bun-types": "^1.1.23", diff --git a/personalize/README.md b/personalize/README.md index 24d19a98c..af307f1b9 100644 --- a/personalize/README.md +++ b/personalize/README.md @@ -13,6 +13,7 @@ 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 diff --git a/release.sh b/release.sh new file mode 100755 index 000000000..b91639181 --- /dev/null +++ b/release.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat << EOF +Usage: $0 [OPTIONS] [ ] + +Create annotated git tags for module releases. + +This script is used by maintainers to create annotated tags for module +releases. When a tag is pushed, it triggers a GitHub workflow that +updates README versions. + +Options: + -l, --list List all modules with their versions + -n, --dry-run Show what would be done without making changes + -p, --push Push the created tag to the remote repository + -h, --help Show this help message + +Examples: + $0 --list + $0 nodejs 1.2.3 + $0 nodejs 1.2.3 --push + $0 --dry-run nodejs 1.2.3 +EOF + exit "${1:-0}" +} + +check_getopt() { + # Check if we have GNU or BSD getopt. + if getopt --test > /dev/null 2>&1; then + # Exit status 4 means GNU getopt is available. + if [[ $? -ne 4 ]]; then + echo "Error: GNU getopt is not available." >&2 + echo "On macOS, you can install GNU getopt and add it to your PATH:" >&2 + echo + echo $'\tbrew install gnu-getopt' >&2 + echo $'\texport PATH="$(brew --prefix gnu-getopt)/bin:$PATH"' >&2 + exit 1 + fi + fi +} + +maybe_dry_run() { + if [[ $dry_run == true ]]; then + echo "[DRY RUN] $*" + return 0 + fi + "$@" +} + +get_readme_version() { + grep -o 'version *= *"[0-9]\+\.[0-9]\+\.[0-9]\+"' "$1" \ + | head -1 \ + | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' \ + || echo "0.0.0" +} + +list_modules() { + printf "\nListing all modules and their latest versions:\n" + printf "%s\n" "--------------------------------------------------------------" + printf "%-30s %-15s %-15s\n" "MODULE" "README VERSION" "LATEST TAG" + printf "%s\n" "--------------------------------------------------------------" + + # Process each module directory. + for dir in */; do + # Skip non-module directories. + [[ ! -d $dir || ! -f ${dir}README.md || $dir == ".git/" ]] && continue + + module="${dir%/}" + readme_version=$(get_readme_version "${dir}README.md") + latest_tag=$(git tag -l "release/${module}/v*" | sort -V | tail -n 1) + tag_version="none" + if [[ -n $latest_tag ]]; then + tag_version="${latest_tag#"release/${module}/v"}" + fi + + printf "%-30s %-15s %-15s\n" "$module" "$readme_version" "$tag_version" + done + + printf "%s\n" "--------------------------------------------------------------" +} + +is_valid_version() { + if ! [[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in format X.Y.Z (e.g., 1.2.3)" >&2 + return 1 + fi +} + +get_tag_name() { + local module="$1" + local version="$2" + local tag_name="release/$module/v$version" + local readme_path="$module/README.md" + + if [[ ! -d $module || ! -f $readme_path ]]; then + echo "Error: Module '$module' not found or missing README.md" >&2 + return 1 + fi + + local readme_version + readme_version=$(get_readme_version "$readme_path") + + { + echo "Module: $module" + echo "Current README version: $readme_version" + echo "New tag version: $version" + echo "Tag name: $tag_name" + } >&2 + + echo "$tag_name" +} + +# Ensure getopt is available. +check_getopt + +# Set defaults. +list=false +dry_run=false +push=false +module= +version= + +# Parse command-line options. +if ! temp=$(getopt -o ldph --long list,dry-run,push,help -n "$0" -- "$@"); then + echo "Error: Failed to parse arguments" >&2 + usage 1 +fi +eval set -- "$temp" + +while true; do + case "$1" in + -l | --list) + list=true + shift + ;; + -d | --dry-run) + dry_run=true + shift + ;; + -p | --push) + push=true + shift + ;; + -h | --help) + usage + ;; + --) + shift + break + ;; + *) + echo "Error: Internal error!" >&2 + exit 1 + ;; + esac +done + +if [[ $list == true ]]; then + list_modules + exit 0 +fi + +if [[ $# -ne 2 ]]; then + echo "Error: MODULE and VERSION are required when not using --list" >&2 + usage 1 +fi + +module="$1" +version="$2" + +if ! is_valid_version "$version"; then + exit 1 +fi + +if ! tag_name=$(get_tag_name "$module" "$version"); then + exit 1 +fi + +if git rev-parse -q --verify "refs/tags/$tag_name" > /dev/null 2>&1; then + echo "Notice: Tag '$tag_name' already exists" >&2 +else + maybe_dry_run git tag -a "$tag_name" -m "Release $module v$version" + if [[ $push == true ]]; then + maybe_dry_run echo "Tag '$tag_name' created." + else + maybe_dry_run echo "Tag '$tag_name' created locally. Use --push to push it to remote." + maybe_dry_run "ℹ️ Note: Remember to push the tag when ready." + fi +fi + +if [[ $push == true ]]; then + maybe_dry_run git push origin "$tag_name" + maybe_dry_run echo "Success! Tag '$tag_name' pushed to remote." +fi diff --git a/setup.ts b/setup.ts index 3cfb871e8..a867c7581 100644 --- a/setup.ts +++ b/setup.ts @@ -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; diff --git a/slackme/README.md b/slackme/README.md index 0858c3dd2..f686b8667 100644 --- a/slackme/README.md +++ b/slackme/README.md @@ -56,6 +56,7 @@ 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,6 +73,7 @@ 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 diff --git a/test.ts b/test.ts index 5437374f9..e466cb127 100644 --- a/test.ts +++ b/test.ts @@ -30,6 +30,12 @@ export const runContainer = async ( return containerID.trim(); }; +export interface scriptOutput { + exitCode: number; + stdout: string[]; + stderr: string[]; +} + /** * Finds the only "coder_script" resource in the given state and runs it in a * container. @@ -38,13 +44,15 @@ export const executeScriptInContainer = async ( state: TerraformState, image: string, shell = "sh", -): Promise<{ - exitCode: number; - stdout: string[]; - stderr: string[]; -}> => { + before?: string, +): Promise => { const instance = findResourceInstance(state, "coder_script"); const id = await runContainer(image); + + if (before) { + const respBefore = await execContainer(id, [shell, "-c", before]); + } + const resp = await execContainer(id, [shell, "-c", instance.script]); const stdout = resp.stdout.trim().split("\n"); const stderr = resp.stderr.trim().split("\n"); @@ -58,12 +66,13 @@ export const executeScriptInContainer = async ( export const execContainer = async ( id: string, cmd: string[], + args?: string[], ): Promise<{ exitCode: number; stderr: string; stdout: string; }> => { - const proc = spawn(["docker", "exec", id, ...cmd], { + const proc = spawn(["docker", "exec", ...(args ?? []), id, ...cmd], { stderr: "pipe", stdout: "pipe", }); @@ -194,13 +203,18 @@ export const testRequiredVariables = ( export const runTerraformApply = async ( dir: string, vars: Readonly, - env?: Record, + customEnv?: Record, ): Promise => { const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`; - const combinedEnv = env === undefined ? {} : { ...env }; - for (const [key, value] of Object.entries(vars)) { - combinedEnv[`TF_VAR_${key}`] = String(value); + const childEnv: Record = { + ...process.env, + ...(customEnv ?? {}), + }; + for (const [key, value] of Object.entries(vars) as [string, JsonValue][]) { + if (value !== null) { + childEnv[`TF_VAR_${key}`] = String(value); + } } const proc = spawn( @@ -216,7 +230,7 @@ export const runTerraformApply = async ( ], { cwd: dir, - env: combinedEnv, + env: childEnv, stderr: "pipe", stdout: "pipe", }, diff --git a/update-version.sh b/update-version.sh deleted file mode 100755 index b062736f5..000000000 --- a/update-version.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/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 -# of the repository or by using the `bun update-version` command. - -set -euo pipefail - -current_tag=$(git describe --tags --abbrev=0) - -# Increment the patch version -LATEST_TAG=$(echo "$current_tag" | sed 's/^v//' | awk -F. '{print $1"."$2"."$3+1}') || 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 - file="$dir/README.md" - tmpfile=$(mktemp /tmp/tempfile.XXXXXX) - 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 - fi -done diff --git a/update_version.sh b/update_version.sh new file mode 100755 index 000000000..39430cddf --- /dev/null +++ b/update_version.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat << EOF +Usage: $0 [OPTIONS] + +Update or check the version in a module's README.md file. + +Options: + -c, --check Check if README.md version matches VERSION without updating + -h, --help Display this help message and exit + +Examples: + $0 code-server 1.2.3 # Update version in code-server/README.md + $0 --check code-server 1.2.3 # Check if version matches 1.2.3 +EOF + exit "${1:-0}" +} + +is_valid_version() { + if ! [[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in format X.Y.Z (e.g., 1.2.3)" >&2 + return 1 + fi +} + +update_version() { + local file="$1" current_tag="$2" latest_tag="$3" tmpfile + tmpfile=$(mktemp) + + echo "Updating version in $file from $current_tag to $latest_tag..." + + 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 "\"") + } + } + + print + } + ' "$file" > "$tmpfile" && mv "$tmpfile" "$file" +} + +get_readme_version() { + grep -o 'version *= *"[0-9]\+\.[0-9]\+\.[0-9]\+"' "$1" \ + | head -1 \ + | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' \ + || echo "0.0.0" +} + +# Set defaults. +check_only=false + +# Parse command-line options. +while [[ $# -gt 0 ]]; do + case "$1" in + -c | --check) + check_only=true + shift + ;; + -h | --help) + usage 0 + ;; + -*) + echo "Error: Unknown option: $1" >&2 + usage 1 + ;; + *) + break + ;; + esac +done + +if [[ $# -ne 2 ]]; then + echo "Error: MODULE and VERSION are required" >&2 + usage 1 +fi + +module_name="$1" +version="$2" + +if [[ ! -d $module_name ]]; then + echo "Error: Module directory '$module_name' not found" >&2 + echo >&2 + echo "Available modules:" >&2 + echo >&2 + find . -type d -mindepth 1 -maxdepth 1 -not -path "*/\.*" | sed 's|^./|\t|' | sort >&2 + exit 1 +fi + +if ! is_valid_version "$version"; then + exit 1 +fi + +readme_path="$module_name/README.md" +if [[ ! -f $readme_path ]]; then + echo "Error: README.md not found in '$module_name' directory" >&2 + exit 1 +fi + +readme_version=$(get_readme_version "$readme_path") + +# In check mode, just return success/failure based on version match. +if [[ $check_only == true ]]; then + if [[ $readme_version == "$version" ]]; then + echo "✅ Success: Version in $readme_path matches $version" + exit 0 + else + echo "❌ Error: Version mismatch in $readme_path" + echo "Expected: $version" + echo "Found: $readme_version" + exit 1 + fi +fi + +if [[ $readme_version != "$version" ]]; then + update_version "$readme_path" "$readme_version" "$version" + echo "✅ Version updated successfully to $version" +else + echo "ℹ️ Version in $readme_path already set to $version, no update needed" +fi diff --git a/vault-github/README.md b/vault-github/README.md index ac73972b2..f801c1935 100644 --- a/vault-github/README.md +++ b/vault-github/README.md @@ -14,6 +14,7 @@ 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 @@ -45,6 +46,7 @@ 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 @@ -57,6 +59,7 @@ 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 @@ -70,6 +73,7 @@ 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 diff --git a/vault-jwt/README.md b/vault-jwt/README.md index 939bed279..1907dbf0a 100644 --- a/vault-jwt/README.md +++ b/vault-jwt/README.md @@ -10,15 +10,17 @@ 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/auth#openid-connect) 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. +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 or another source of jwt token. This requires configuring the Vault [JWT/OIDC](https://developer.hashicorp.com/vault/docs/auth/jwt#configuration) auth method. ```tf module "vault" { - 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 + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.1.0" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_role = "coder" # The Vault role to use for authentication + vault_jwt_token = "eyJhbGciOiJIUzI1N..." # optional, if not present, defaults to user's oidc authentication token } ``` @@ -40,8 +42,9 @@ curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/d ```tf module "vault" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/vault-jwt/coder" - version = "1.0.20" + version = "1.0.31" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" vault_jwt_auth_path = "oidc" @@ -55,8 +58,9 @@ module "vault" { 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" + version = "1.0.31" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" vault_jwt_role = data.coder_workspace_owner.me.groups[0] @@ -67,11 +71,115 @@ module "vault" { ```tf module "vault" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/vault-jwt/coder" - version = "1.0.20" + version = "1.0.31" 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" } ``` + +### Use a custom JWT token + +```tf + +terraform { + required_providers { + jwt = { + source = "geektheripper/jwt" + version = "1.1.4" + } + time = { + source = "hashicorp/time" + version = "0.11.1" + } + } +} + + +resource "jwt_signed_token" "vault" { + count = data.coder_workspace.me.start_count + algorithm = "RS256" + # `openssl genrsa -out key.pem 4096` and `openssl rsa -in key.pem -pubout > pub.pem` to generate keys + key = file("key.pem") + claims_json = jsonencode({ + iss = "https://code.example.com" + sub = "${data.coder_workspace.me.id}" + aud = "https://vault.example.com" + iat = provider::time::rfc3339_parse(plantimestamp()).unix + # Uncomment to set an expiry on the JWT token(default 3600 seconds). + # workspace will need to be restarted to generate a new token if it expires + #exp = provider::time::rfc3339_parse(timeadd(timestamp(), 3600)).unix agent = coder_agent.main.id + provisioner = data.coder_provisioner.main.id + provisioner_arch = data.coder_provisioner.main.arch + provisioner_os = data.coder_provisioner.main.os + + workspace = data.coder_workspace.me.id + workspace_url = data.coder_workspace.me.access_url + workspace_port = data.coder_workspace.me.access_port + workspace_name = data.coder_workspace.me.name + template = data.coder_workspace.me.template_id + template_name = data.coder_workspace.me.template_name + template_version = data.coder_workspace.me.template_version + owner = data.coder_workspace_owner.me.id + owner_name = data.coder_workspace_owner.me.name + owner_email = data.coder_workspace_owner.me.email + owner_login_type = data.coder_workspace_owner.me.login_type + owner_groups = data.coder_workspace_owner.me.groups + }) +} + +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.1.0" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_role = "coder" # The Vault role to use for authentication + vault_jwt_token = jwt_signed_token.vault[0].token +} +``` + +#### Example Vault JWT role + +```shell +vault write auth/JWT_MOUNT/role/workspace - << EOF +{ + "user_claim": "sub", + "bound_audiences": "https://vault.example.com", + "role_type": "jwt", + "ttl": "1h", + "claim_mappings": { + "owner": "owner", + "owner_email": "owner_email", + "owner_login_type": "owner_login_type", + "owner_name": "owner_name", + "provisioner": "provisioner", + "provisioner_arch": "provisioner_arch", + "provisioner_os": "provisioner_os", + "sub": "sub", + "template": "template", + "template_name": "template_name", + "template_version": "template_version", + "workspace": "workspace", + "workspace_name": "workspace_name", + "workspace_id": "workspace_id" + } +} +EOF +``` + +#### Example workspace access Vault policy + +```tf +path "kv/data/app/coder/{{identity.entity.aliases..metadata.owner_name}}/{{identity.entity.aliases..metadata.workspace_name}}" { + capabilities = ["create", "read", "update", "delete", "list", "subscribe"] + subscribe_event_types = ["*"] +} +path "kv/metadata/app/coder/{{identity.entity.aliases..metadata.owner_name}}/{{identity.entity.aliases..metadata.workspace_name}}" { + capabilities = ["create", "read", "update", "delete", "list", "subscribe"] + subscribe_event_types = ["*"] +} +``` diff --git a/vault-jwt/main.tf b/vault-jwt/main.tf index adcc34d42..17288e008 100644 --- a/vault-jwt/main.tf +++ b/vault-jwt/main.tf @@ -20,6 +20,13 @@ variable "vault_addr" { description = "The address of the Vault server." } +variable "vault_jwt_token" { + type = string + description = "The JWT token used for authentication with Vault." + default = null + sensitive = true +} + variable "vault_jwt_auth_path" { type = string description = "The path to the Vault JWT auth method." @@ -46,7 +53,7 @@ resource "coder_script" "vault" { 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, + CODER_OIDC_ACCESS_TOKEN : var.vault_jwt_token != null ? var.vault_jwt_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, diff --git a/vault-jwt/run.sh b/vault-jwt/run.sh index ef45884d7..d95b45a27 100644 --- a/vault-jwt/run.sh +++ b/vault-jwt/run.sh @@ -107,6 +107,6 @@ 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=- +echo "$${CODER_OIDC_ACCESS_TOKEN}" | vault write -field=token auth/"$${VAULT_JWT_AUTH_PATH}"/login role="$${VAULT_JWT_ROLE}" jwt=- | vault login - printf "🥳 Vault authentication complete!\n\n" printf "You can now use Vault CLI to access secrets.\n" diff --git a/vscode-desktop/README.md b/vscode-desktop/README.md index bc8920d4b..e32fd9bf1 100644 --- a/vscode-desktop/README.md +++ b/vscode-desktop/README.md @@ -15,6 +15,7 @@ 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 @@ -27,6 +28,7 @@ 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 diff --git a/vscode-web/README.md b/vscode-web/README.md index 8b5fa6888..5846c04c7 100644 --- a/vscode-web/README.md +++ b/vscode-web/README.md @@ -13,8 +13,9 @@ 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.22" + version = "1.0.30" agent_id = coder_agent.example.id accept_license = true } @@ -28,8 +29,9 @@ 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.22" + version = "1.0.30" agent_id = coder_agent.example.id install_prefix = "/home/coder/.vscode-web" folder = "/home/coder" @@ -41,8 +43,9 @@ 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.22" + version = "1.0.30" agent_id = coder_agent.example.id extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] accept_license = true @@ -51,12 +54,13 @@ module "vscode-web" { ### Pre-configure Settings -Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file: +Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file: ```tf module "vscode-web" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/vscode-web/coder" - version = "1.0.22" + version = "1.0.30" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -65,3 +69,18 @@ module "vscode-web" { accept_license = true } ``` + +### Pin a specific VS Code Web version + +By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases). + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" + accept_license = true +} +``` diff --git a/vscode-web/main.tf b/vscode-web/main.tf index 207450e54..11e220cd2 100644 --- a/vscode-web/main.tf +++ b/vscode-web/main.tf @@ -59,6 +59,12 @@ variable "install_prefix" { default = "/tmp/vscode-web" } +variable "commit_id" { + type = string + description = "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used." + default = "" +} + variable "extensions" { type = list(string) description = "A list of extensions to install." @@ -92,7 +98,7 @@ variable "order" { } variable "settings" { - type = map(string) + type = any description = "A map of settings to apply to VS Code web." default = {} } @@ -151,6 +157,7 @@ resource "coder_script" "vscode-web" { FOLDER : var.folder, AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, SERVER_BASE_PATH : local.server_base_path, + COMMIT_ID : var.commit_id, }) run_on_start = true diff --git a/vscode-web/run.sh b/vscode-web/run.sh index 1738364eb..588cec56d 100755 --- a/vscode-web/run.sh +++ b/vscode-web/run.sh @@ -59,8 +59,15 @@ case "$ARCH" in ;; esac -HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-linux-$ARCH-web | cut -d '"' -f 2) -output=$(curl -fsSL https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-linux-$ARCH-web.tar.gz | tar -xz -C ${INSTALL_PREFIX} --strip-components 1) +# Check if a specific VS Code Web commit ID was provided +if [ -n "${COMMIT_ID}" ]; then + HASH="${COMMIT_ID}" +else + HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-linux-$ARCH-web | cut -d '"' -f 2) +fi +printf "$${BOLD}VS Code Web commit id version $HASH.\n" + +output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-linux-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1) if [ $? -ne 0 ]; then echo "Failed to install Microsoft Visual Studio Code Server: $output" @@ -92,7 +99,8 @@ 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" - extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json) + # 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 diff --git a/windows-rdp/README.md b/windows-rdp/README.md index c4d35fdf8..b069f5e31 100644 --- a/windows-rdp/README.md +++ b/windows-rdp/README.md @@ -14,9 +14,9 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de ```tf # AWS example. See below for examples of using this module with other providers module "windows_rdp" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/windows-rdp/coder" version = "1.0.18" - count = data.coder_workspace.me.start_count agent_id = resource.coder_agent.main.id resource_id = resource.aws_instance.dev.id } @@ -32,9 +32,9 @@ 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" - count = data.coder_workspace.me.start_count agent_id = resource.coder_agent.main.id resource_id = resource.aws_instance.dev.id } @@ -44,9 +44,9 @@ 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" - count = data.coder_workspace.me.start_count agent_id = resource.coder_agent.main.id resource_id = resource.google_compute_instance.dev[0].id } diff --git a/windsurf/README.md b/windsurf/README.md new file mode 100644 index 000000000..93f25ebb0 --- /dev/null +++ b/windsurf/README.md @@ -0,0 +1,37 @@ +--- +display_name: Windsurf Editor +description: Add a one-click button to launch Windsurf Editor +icon: ../.icons/windsurf.svg +maintainer_github: coder +verified: true +tags: [ide, windsurf, helper, ai] +--- + +# Windsurf Editor + +Add a button to open any workspace with a single click in Windsurf Editor. + +Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder). + +```tf +module "windsurf" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windsurf/coder" + version = "1.0.0" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "windsurf" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windsurf/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` diff --git a/windsurf/main.test.ts b/windsurf/main.test.ts new file mode 100644 index 000000000..a158962a7 --- /dev/null +++ b/windsurf/main.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("windsurf", 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.windsurf_url.value).toBe( + "windsurf://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 === "windsurf", + ); + + 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.windsurf_url.value).toBe( + "windsurf://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.windsurf_url.value).toBe( + "windsurf://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", + open_recent: false, + }); + expect(state.outputs.windsurf_url.value).toBe( + "windsurf://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.windsurf_url.value).toBe( + "windsurf://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 === "windsurf", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); +}); diff --git a/windsurf/main.tf b/windsurf/main.tf new file mode 100644 index 000000000..1d836d7e3 --- /dev/null +++ b/windsurf/main.tf @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "folder" { + type = string + description = "The folder to open in Cursor IDE." + default = "" +} + +variable "open_recent" { + type = bool + description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." + default = false +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "windsurf" { + agent_id = var.agent_id + external = true + icon = "/icon/windsurf.svg" + slug = "windsurf" + display_name = "Windsurf Editor" + order = var.order + url = join("", [ + "windsurf://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 "windsurf_url" { + value = coder_app.windsurf.url + description = "Windsurf Editor URL." +}