diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..600a98c2a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + 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 960cd0359..63c6062bb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,18 +16,31 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v1 + - 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: - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v1 + with: + fetch-depth: 0 # Needed to get tags + - uses: coder/coder/.github/actions/setup-tf@main + - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Setup @@ -35,6 +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 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/.github/workflows/update-readme.yaml b/.github/workflows/update-readme.yaml deleted file mode 100644 index 0d0e2264c..000000000 --- a/.github/workflows/update-readme.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: Update README on Tag - -on: - workflow_dispatch: - push: - tags: - - 'v*' - -jobs: - update-readme: - permissions: - contents: write - pull-requests: write - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Get the latest tag - id: get-latest-tag - run: echo "TAG=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_OUTPUT - - - name: Run update script - run: ./update-version.sh - - - name: Create Pull Request - id: create-pr - uses: peter-evans/create-pull-request@v5 - with: - commit-message: 'chore: bump version to ${{ env.TAG }} in README.md files' - title: 'chore: bump version to ${{ env.TAG }} in README.md files' - body: 'This is an auto-generated PR to update README.md files of all modules with the new tag ${{ env.TAG }}' - branch: 'update-readme-branch' - base: 'main' - env: - TAG: ${{ steps.get-latest-tag.outputs.TAG }} - - - name: Auto-approve - uses: hmarr/auto-approve-action@v4 - if: github.ref == 'refs/heads/update-readme-branch' 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/cursor.svg b/.icons/cursor.svg new file mode 100644 index 000000000..c074bf274 --- /dev/null +++ b/.icons/cursor.svg @@ -0,0 +1 @@ + \ No newline at end of file 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 557171e02..2c7ba8bfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,30 +1,97 @@ # Contributing -To create a new module, clone this repository and run: +## Getting started + +This repo uses the [Bun runtime](https://bun.sh/) to to run all code and tests. To install Bun, you can run this command on Linux/MacOS: + +```shell +curl -fsSL https://bun.sh/install | bash +``` + +Or this command on Windows: ```shell -./new.sh MODULE_NAME +powershell -c "irm bun.sh/install.ps1 | iex" +``` + +Follow the instructions to ensure that Bun is available globally. Once Bun has been installed, clone this repository. From there, run this script to create a new module: + +```shell +./new.sh NAME_OF_NEW_MODULE ``` ## Testing a Module +> [!NOTE] +> It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR. + A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers. -The testing suite must be able to run docker containers with the `--network=host` flag, which typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS and Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop. +The testing suite must be able to run docker containers with the `--network=host` flag. This typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS and Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop. -Reference existing `*.test.ts` files for implementation. +Reference the existing `*.test.ts` files to get an idea for how to set up tests. + +You can run all tests in a specific file with this command: ```shell -# Run tests for a specific module! $ bun test -t '' ``` +Or run all tests by running this command: + +```shell +$ bun test +``` + You can test a module locally by updating the source as follows ```tf module "example" { source = "git::https://github.com//.git//?ref=" + # You may need to remove the 'version' field, it is incompatible with some sources. } ``` -> **Note:** This is the responsibility of the module author to implement tests for their module. and test the module locally before submitting a PR. +## Releases + +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) + +- Create and push an annotated tag: + + ```shell + # Fetch latest changes + git fetch origin + + # Create and push tag + ./release.sh module-name 1.2.3 --push + ``` + + The tag format will be: `release/module-name/v1.2.3` + +## 3. Publishing to Registry + +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 [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 4b675946c..81d8d3807 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,26 @@ +> [!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

-[Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise) +[Module Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise) [![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)
-Modules extend Templates to create reusable components for your development environment. +Modules extend Coder Templates to create reusable components for your development environment. e.g. ```tf module "code-server" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/code-server/coder" version = "1.0.2" agent_id = coder_agent.main.id 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/aws-region/main.test.ts b/aws-region/main.test.ts index 0693e65a9..06f8e56e6 100644 --- a/aws-region/main.test.ts +++ b/aws-region/main.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "bun:test"; import { - executeScriptInContainer, runTerraformApply, runTerraformInit, testRequiredVariables, 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/azure-region/main.test.ts b/azure-region/main.test.ts index bebc0c9f8..8adbb48b1 100644 --- a/azure-region/main.test.ts +++ b/azure-region/main.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "bun:test"; import { - executeScriptInContainer, runTerraformApply, runTerraformInit, testRequiredVariables, diff --git a/bun.lockb b/bun.lockb index d3e22141e..7576953c8 100755 Binary files a/bun.lockb and b/bun.lockb differ 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 8132307d9..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.16" + 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.16" + 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.16" + 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.16" + 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.16" + 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.16" + 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.16" + 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 8e068b8d9..99b30c0ea 100755 --- a/code-server/run.sh +++ b/code-server/run.sh @@ -10,6 +10,7 @@ CODE_SERVER="${INSTALL_PREFIX}/bin/code-server" EXTENSION_ARG="" if [ -n "${EXTENSIONS_DIR}" ]; then EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" + mkdir -p "${EXTENSIONS_DIR}" fi function run_code_server() { @@ -41,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}" @@ -57,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" @@ -103,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/coder-login/main.test.ts b/coder-login/main.test.ts index d8fba354a..aca432163 100644 --- a/coder-login/main.test.ts +++ b/coder-login/main.test.ts @@ -1,10 +1,5 @@ -import { describe, expect, it } from "bun:test"; -import { - executeScriptInContainer, - runTerraformApply, - runTerraformInit, - testRequiredVariables, -} from "../test"; +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "../test"; describe("coder-login", async () => { await runTerraformInit(import.meta.dir); diff --git a/cursor/README.md b/cursor/README.md new file mode 100644 index 000000000..d9a2e17f9 --- /dev/null +++ b/cursor/README.md @@ -0,0 +1,37 @@ +--- +display_name: Cursor IDE +description: Add a one-click button to launch Cursor IDE +icon: ../.icons/cursor.svg +maintainer_github: coder +verified: true +tags: [ide, cursor, helper] +--- + +# Cursor IDE + +Add a button to open any workspace with a single click in Cursor IDE. + +Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder). + +```tf +module "cursor" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/cursor/coder" + version = "1.0.19" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "cursor" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/cursor/coder" + version = "1.0.19" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` diff --git a/cursor/main.test.ts b/cursor/main.test.ts new file mode 100644 index 000000000..3c1646986 --- /dev/null +++ b/cursor/main.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("cursor", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "cursor", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("adds folder", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder and open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + open_recent: "true", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder but not open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + openRecent: "false", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + open_recent: "true", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("expect order to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "cursor", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); +}); diff --git a/cursor/main.tf b/cursor/main.tf new file mode 100644 index 000000000..f350f9428 --- /dev/null +++ b/cursor/main.tf @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "folder" { + type = string + description = "The folder to open in Cursor IDE." + default = "" +} + +variable "open_recent" { + type = bool + description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." + default = false +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "cursor" { + agent_id = var.agent_id + external = true + icon = "/icon/cursor.svg" + slug = "cursor" + display_name = "Cursor Desktop" + order = var.order + url = join("", [ + "cursor://coder.coder-remote/open", + "?owner=", + data.coder_workspace_owner.me.name, + "&workspace=", + data.coder_workspace.me.name, + var.folder != "" ? join("", ["&folder=", var.folder]) : "", + var.open_recent ? "&openRecent" : "", + "&url=", + data.coder_workspace.me.access_url, + "&token=$SESSION_TOKEN", + ]) +} + +output "cursor_url" { + value = coder_app.cursor.url + description = "Cursor IDE Desktop URL." +} diff --git a/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 41371ab10..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.15" + 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.15" + 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.15" + 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.15" + 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.15" + 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.15" + version = "1.0.29" agent_id = coder_agent.example.id default_dotfiles_uri = "https://github.com/coder/dotfiles" } diff --git a/dotfiles/main.tf b/dotfiles/main.tf index bfb67e447..9bc3735e0 100644 --- a/dotfiles/main.tf +++ b/dotfiles/main.tf @@ -39,9 +39,14 @@ variable "coder_parameter_order" { default = null } -data "coder_parameter" "dotfiles_uri" { - count = var.dotfiles_uri == null ? 1 : 0 +variable "manual_update" { + type = bool + description = "If true, this adds a button to workspace page to refresh dotfiles on demand." + default = false +} +data "coder_parameter" "dotfiles_uri" { + count = var.dotfiles_uri == null ? 1 : 0 type = "string" name = "dotfiles_uri" display_name = "Dotfiles URL" @@ -68,6 +73,18 @@ resource "coder_script" "dotfiles" { run_on_start = true } +resource "coder_app" "dotfiles" { + count = var.manual_update ? 1 : 0 + agent_id = var.agent_id + display_name = "Refresh Dotfiles" + slug = "dotfiles" + icon = "/icon/dotfiles.svg" + command = templatefile("${path.module}/run.sh", { + DOTFILES_URI : local.dotfiles_uri, + DOTFILES_USER : local.user + }) +} + output "dotfiles_uri" { description = "Dotfiles URI" value = local.dotfiles_uri 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/exoscale-zone/main.test.ts b/exoscale-zone/main.test.ts index ca8eeb727..1751cb148 100644 --- a/exoscale-zone/main.test.ts +++ b/exoscale-zone/main.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "bun:test"; import { - executeScriptInContainer, runTerraformApply, runTerraformInit, testRequiredVariables, diff --git a/filebrowser/README.md b/filebrowser/README.md index 288137641..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.8" + 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.8" + version = "1.0.31" agent_id = coder_agent.example.id folder = "/home/coder/project" } @@ -38,9 +40,23 @@ module "filebrowser" { ```tf module "filebrowser" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/filebrowser/coder" - version = "1.0.8" + version = "1.0.31" agent_id = coder_agent.example.id database_path = ".config/filebrowser.db" } ``` + +### Serve from the same domain (no subdomain) + +```tf +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + agent_name = "main" + subdomain = false +} +``` diff --git a/filebrowser/main.test.ts b/filebrowser/main.test.ts index 79dd99d06..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,20 +78,28 @@ 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 () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_name: "main", + subdomain: false, + }); + + 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 a07072b96..ba83844b0 100644 --- a/filebrowser/main.tf +++ b/filebrowser/main.tf @@ -14,6 +14,16 @@ variable "agent_id" { description = "The ID of a Coder agent." } +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "agent_name" { + type = string + description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)" + default = null +} + variable "database_path" { type = string description = "The path to the filebrowser database." @@ -58,27 +68,56 @@ variable "order" { default = null } +variable "slug" { + type = string + description = "The slug of the coder_app resource." + default = "filebrowser" +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = true +} + resource "coder_script" "filebrowser" { agent_id = var.agent_id display_name = "File Browser" - icon = "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, FOLDER : var.folder, LOG_PATH : var.log_path, - DB_PATH : var.database_path + DB_PATH : var.database_path, + SUBDOMAIN : var.subdomain, + SERVER_BASE_PATH : local.server_base_path }) run_on_start = true } resource "coder_app" "filebrowser" { agent_id = var.agent_id - slug = "filebrowser" + slug = var.slug display_name = "File Browser" - url = "http://localhost:${var.port}" - icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg" - subdomain = true + 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 8744edba4..62f04edf6 100644 --- a/filebrowser/run.sh +++ b/filebrowser/run.sh @@ -1,26 +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 -printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n" +filebrowser config set --baseurl=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH} -printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG}' \n\n" +printf "👷 Starting filebrowser in background... \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 255b3f1ee..0647f7f93 100644 --- a/git-clone/README.md +++ b/git-clone/README.md @@ -13,8 +13,9 @@ 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.12" + version = "1.0.18" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -26,8 +27,9 @@ 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.12" + version = "1.0.18" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" base_dir = "~/projects/coder" @@ -40,8 +42,9 @@ 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.12" + version = "1.0.18" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -65,31 +68,33 @@ 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.12" + version = "1.0.18" agent_id = coder_agent.example.id url = data.coder_parameter.git_repo.value } # Create a code-server instance for the cloned repository module "code-server" { + count = data.coder_workspace.me.start_count source = "registry.coder.com/modules/code-server/coder" - version = "1.0.12" + 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,8 +102,9 @@ 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.12" + version = "1.0.18" agent_id = coder_agent.example.id url = "https://github.example.com/coder/coder/tree/feat/example" git_providers = { @@ -115,8 +121,9 @@ 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.12" + version = "1.0.18" agent_id = coder_agent.example.id url = "https://gitlab.com/coder/coder/-/tree/feat/example" } @@ -126,8 +133,9 @@ 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.12" + version = "1.0.18" agent_id = coder_agent.example.id url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" git_providers = { @@ -146,10 +154,29 @@ 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.12" + version = "1.0.18" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" branch_name = "feat/example" } ``` + +## Git clone with different destination folder + +By default, the repository will be cloned into a folder matching the repository name. You can use the `folder_name` attribute to change the name of the destination folder to something else. + +For example, this will clone into the `~/projects/coder/coder-dev` folder: + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" + folder_name = "coder-dev" + base_dir = "~/projects/coder" +} +``` diff --git a/git-clone/main.test.ts b/git-clone/main.test.ts index 87b0e4a6e..9fbd2022f 100644 --- a/git-clone/main.test.ts +++ b/git-clone/main.test.ts @@ -79,6 +79,22 @@ describe("git-clone", async () => { expect(state.outputs.branch_name.value).toEqual(""); }); + it("repo_dir should match base_dir/folder_name", async () => { + const url = "git@github.com:coder/coder.git"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + folder_name: "foo", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/foo"); + expect(state.outputs.folder_name.value).toEqual("foo"); + expect(state.outputs.clone_url.value).toEqual(url); + const https_url = "https://github.com/coder/coder.git"; + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + it("branch_name should not include query string", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", diff --git a/git-clone/main.tf b/git-clone/main.tf index 4af5000e5..0295444d8 100644 --- a/git-clone/main.tf +++ b/git-clone/main.tf @@ -50,6 +50,12 @@ variable "branch_name" { default = "" } +variable "folder_name" { + description = "The destination folder to clone the repository into." + type = string + default = "" +} + locals { # Remove query parameters and fragments from the URL url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "") @@ -64,7 +70,7 @@ locals { # Extract the branch name from the URL branch_name = var.branch_name == "" && local.tree_path != "" ? replace(replace(local.url, local.clone_url, ""), "/.*${local.tree_path}/", "") : var.branch_name # Extract the folder name from the URL - folder_name = replace(basename(local.clone_url), ".git", "") + folder_name = var.folder_name == "" ? replace(basename(local.clone_url), ".git", "") : var.folder_name # Construct the path to clone the repository clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name]) # Construct the web URL diff --git a/git-commit-signing/README.md b/git-commit-signing/README.md index 37633a250..1f71cbb3b 100644 --- a/git-commit-signing/README.md +++ b/git-commit-signing/README.md @@ -2,13 +2,16 @@ display_name: Git commit signing description: Configures Git to sign commits using your Coder SSH key icon: ../.icons/git.svg -maintainer_github: phorcys420 -verified: false +maintainer_github: coder +verified: true tags: [helper, git] --- # git-commit-signing +> [!IMPORTANT] +> This module will only work with Git versions >=2.34, prior versions [do not support signing commits via SSH keys](https://lore.kernel.org/git/xmqq8rxpgwki.fsf@gitster.g/). + This module downloads your SSH key from Coder and uses it to sign commits with Git. It requires `curl` and `jq` to be installed inside your workspace. @@ -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/github-upload-public-key/main.test.ts b/github-upload-public-key/main.test.ts index fb1b97767..6ce16d820 100644 --- a/github-upload-public-key/main.test.ts +++ b/github-upload-public-key/main.test.ts @@ -1,3 +1,4 @@ +import { type Server, serve } from "bun"; import { describe, expect, it } from "bun:test"; import { createJSONResponse, @@ -9,7 +10,6 @@ import { testRequiredVariables, writeCoder, } from "../test"; -import { Server, serve } from "bun"; describe("github-upload-public-key", async () => { await runTerraformInit(import.meta.dir); @@ -21,10 +21,12 @@ describe("github-upload-public-key", async () => { it("creates new key if one does not exist", async () => { const { instance, id, server } = await setupContainer(); await writeCoder(id, "echo foo"); - let exec = await execContainer(id, [ + + const url = server.url.toString().slice(0, -1); + const exec = await execContainer(id, [ "env", - "CODER_ACCESS_URL=" + server.url.toString().slice(0, -1), - "GITHUB_API_URL=" + server.url.toString().slice(0, -1), + `CODER_ACCESS_URL=${url}`, + `GITHUB_API_URL=${url}`, "CODER_OWNER_SESSION_TOKEN=foo", "CODER_EXTERNAL_AUTH_ID=github", "bash", @@ -42,10 +44,12 @@ describe("github-upload-public-key", async () => { const { instance, id, server } = await setupContainer(); // use keyword to make server return a existing key await writeCoder(id, "echo findkey"); - let exec = await execContainer(id, [ + + const url = server.url.toString().slice(0, -1); + const exec = await execContainer(id, [ "env", - "CODER_ACCESS_URL=" + server.url.toString().slice(0, -1), - "GITHUB_API_URL=" + server.url.toString().slice(0, -1), + `CODER_ACCESS_URL=${url}`, + `GITHUB_API_URL=${url}`, "CODER_OWNER_SESSION_TOKEN=foo", "CODER_EXTERNAL_AUTH_ID=github", "bash", @@ -95,7 +99,7 @@ const setupServer = async (): Promise => { } // case: key already exists - if (req.headers.get("Authorization") == "Bearer findkey") { + if (req.headers.get("Authorization") === "Bearer findkey") { return createJSONResponse([ { key: "foo", 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 b2c0e0f95..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.13" + 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.13" + 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.13" + 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.13" + 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 b327e41a7..ea04a77db 100644 --- a/jetbrains-gateway/main.test.ts +++ b/jetbrains-gateway/main.test.ts @@ -10,14 +10,31 @@ describe("jetbrains-gateway", async () => { await testRequiredVariables(import.meta.dir, { agent_id: "foo", - agent_name: "foo", folder: "/home/foo", }); + it("should create a link with the default values", async () => { + const state = await runTerraformApply(import.meta.dir, { + // These are all required. + agent_id: "foo", + folder: "/home/coder", + }); + expect(state.outputs.url.value).toBe( + "jetbrains-gateway://connect#type=coder&workspace=default&owner=default&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=243.21565.193&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.3.tar.gz", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "gateway", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + it("default to first ide", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", - agent_name: "foo", folder: "/home/foo", jetbrains_ides: '["IU", "GO", "PY"]', }); diff --git a/jetbrains-gateway/main.tf b/jetbrains-gateway/main.tf index c96098c0e..502469f29 100644 --- a/jetbrains-gateway/main.tf +++ b/jetbrains-gateway/main.tf @@ -13,14 +13,32 @@ 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." } +variable "slug" { + type = string + description = "The slug for the coder_app. Allows resuing the module with the same template." + default = "gateway" +} + variable "agent_name" { type = string - description = "Agent name." + description = "Agent name. (unused). Will be removed in a future version" + + default = "" } variable "folder" { @@ -74,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 { @@ -140,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 } } @@ -218,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 } @@ -243,10 +315,11 @@ data "coder_parameter" "jetbrains_ide" { } data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} resource "coder_app" "gateway" { agent_id = var.agent_id - slug = "gateway" + slug = var.slug display_name = local.display_name icon = local.icon external = true @@ -254,8 +327,8 @@ resource "coder_app" "gateway" { url = join("", [ "jetbrains-gateway://connect#type=coder&workspace=", data.coder_workspace.me.name, - "&agent=", - var.agent_name, + "&owner=", + data.coder_workspace_owner.me.name, "&folder=", var.folder, "&url=", diff --git a/jfrog-oauth/.npmrc.tftpl b/jfrog-oauth/.npmrc.tftpl new file mode 100644 index 000000000..8bb9fb8f2 --- /dev/null +++ b/jfrog-oauth/.npmrc.tftpl @@ -0,0 +1,5 @@ +email=${ARTIFACTORY_EMAIL} +%{ for REPO in REPOS ~} +${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME} +//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN} +%{ endfor ~} diff --git a/jfrog-oauth/README.md b/jfrog-oauth/README.md index b7f9d58f9..894e1f325 100644 --- a/jfrog-oauth/README.md +++ b/jfrog-oauth/README.md @@ -16,16 +16,18 @@ 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.15" + version = "1.0.19" agent_id = coder_agent.example.id jfrog_url = "https://example.jfrog.io" username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username" package_managers = { - "npm" : "npm", - "go" : "go", - "pypi" : "pypi" + npm = ["npm", "@scoped:npm-scoped"] + go = ["go", "another-go-repo"] + pypi = ["pypi", "extra-index-pypi"] + docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"] } } ``` @@ -43,14 +45,15 @@ 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.15" + version = "1.0.19" agent_id = coder_agent.example.id jfrog_url = "https://example.jfrog.io" username_field = "email" package_managers = { - "pypi" : "pypi" + pypi = ["pypi"] } } ``` @@ -71,16 +74,17 @@ 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.15" + version = "1.0.19" agent_id = coder_agent.example.id jfrog_url = "https://example.jfrog.io" username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username" configure_code_server = true # Add JFrog extension configuration for code-server package_managers = { - "npm" : "npm", - "go" : "go", - "pypi" : "pypi" + npm = ["npm"] + go = ["go"] + pypi = ["pypi"] } } ``` @@ -94,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-oauth/main.test.ts b/jfrog-oauth/main.test.ts index 3397eebba..7b0c1a5f3 100644 --- a/jfrog-oauth/main.test.ts +++ b/jfrog-oauth/main.test.ts @@ -1,19 +1,129 @@ -import { serve } from "bun"; -import { describe } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { - createJSONResponse, + findResourceInstance, runTerraformInit, + runTerraformApply, testRequiredVariables, } from "../test"; describe("jfrog-oauth", async () => { + type TestVariables = { + agent_id: string; + jfrog_url: string; + package_managers: string; + + username_field?: string; + jfrog_server_id?: string; + external_auth_id?: string; + configure_code_server?: boolean; + }; + await runTerraformInit(import.meta.dir); - testRequiredVariables(import.meta.dir, { - agent_id: "some-agent-id", - jfrog_url: "http://localhost:8081", - package_managers: "{}", + const fakeFrogApi = "localhost:8081/artifactory/api"; + const fakeFrogUrl = "http://localhost:8081"; + const user = "default"; + + it("can run apply with required variables", async () => { + testRequiredVariables(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: "{}", + }); }); -}); -//TODO add more tests + it("generates an npmrc with scoped repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + npm: ["global", "@foo:foo", "@bar:bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const npmrcStanza = `cat << EOF > ~/.npmrc +email=${user}@example.com +registry=http://${fakeFrogApi}/npm/global +//${fakeFrogApi}/npm/global/:_authToken= +@foo:registry=http://${fakeFrogApi}/npm/foo +//${fakeFrogApi}/npm/foo/:_authToken= +@bar:registry=http://${fakeFrogApi}/npm/bar +//${fakeFrogApi}/npm/bar/:_authToken= + +EOF`; + expect(coderScript.script).toContain(npmrcStanza); + expect(coderScript.script).toContain( + 'jf npmc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured npm', + ); + }); + + it("generates a pip config with extra-indexes", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + pypi: ["global", "foo", "bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const pipStanza = `cat << EOF > ~/.pip/pip.conf +[global] +index-url = https://${user}:@${fakeFrogApi}/pypi/global/simple +extra-index-url = + https://${user}:@${fakeFrogApi}/pypi/foo/simple + https://${user}:@${fakeFrogApi}/pypi/bar/simple + +EOF`; + expect(coderScript.script).toContain(pipStanza); + expect(coderScript.script).toContain( + 'jf pipc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured pypi', + ); + }); + + it("registers multiple docker repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const dockerStanza = ["foo", "bar", "baz"] + .map((r) => `register_docker "${r}.jfrog.io"`) + .join("\n"); + expect(coderScript.script).toContain(dockerStanza); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured docker', + ); + }); + + it("sets goproxy with multiple repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + go: ["foo", "bar", "baz"], + }), + }); + const proxyEnv = findResourceInstance(state, "coder_env", "goproxy"); + const proxies = ["foo", "bar", "baz"] + .map((r) => `https://${user}:@${fakeFrogApi}/go/${r}`) + .join(","); + expect(proxyEnv.value).toEqual(proxies); + + const coderScript = findResourceInstance(state, "coder_script"); + expect(coderScript.script).toContain( + 'jf goc --global --repo-resolve "foo"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured go', + ); + }); +}); diff --git a/jfrog-oauth/main.tf b/jfrog-oauth/main.tf index 767235a14..0bc22568b 100644 --- a/jfrog-oauth/main.tf +++ b/jfrog-oauth/main.tf @@ -53,23 +53,51 @@ variable "configure_code_server" { } variable "package_managers" { - type = map(string) - description = < /dev/null 2>&1; then echo "✅ JFrog CLI is already installed, skipping installation." @@ -20,52 +35,47 @@ echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFRO jf c use "${JFROG_SERVER_ID}" # Configure npm to use the Artifactory "npm" repository. -if [ -z "${REPOSITORY_NPM}" ]; then - echo "🤔 no npm repository is set, skipping npm configuration." - echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input." +if [ -z "${HAS_NPM}" ]; then + not_configured npm else echo "📦 Configuring npm..." jf npmc --global --repo-resolve "${REPOSITORY_NPM}" cat << EOF > ~/.npmrc -email=${ARTIFACTORY_EMAIL} -registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM} +${NPMRC} EOF - echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc + config_complete fi # Configure the `pip` to use the Artifactory "python" repository. -if [ -z "${REPOSITORY_PYPI}" ]; then - echo "🤔 no pypi repository is set, skipping pip configuration." - echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input." +if [ -z "${HAS_PYPI}" ]; then + not_configured pypi else - echo "📦 Configuring pip..." + echo "🐍 Configuring pip..." jf pipc --global --repo-resolve "${REPOSITORY_PYPI}" mkdir -p ~/.pip cat << EOF > ~/.pip/pip.conf -[global] -index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple +${PIP_CONF} EOF + config_complete fi # Configure Artifactory "go" repository. -if [ -z "${REPOSITORY_GO}" ]; then - echo "🤔 no go repository is set, skipping go configuration." - echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input." +if [ -z "${HAS_GO}" ]; then + not_configured go else echo "🐹 Configuring go..." jf goc --global --repo-resolve "${REPOSITORY_GO}" + config_complete fi -echo "🥳 Configuration complete!" # Configure the JFrog CLI to use the Artifactory "docker" repository. -if [ -z "${REPOSITORY_DOCKER}" ]; then - echo "🤔 no docker repository is set, skipping docker configuration." - echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input." +if [ -z "${HAS_DOCKER}" ]; then + not_configured docker else if command -v docker > /dev/null 2>&1; then echo "🔑 Configuring 🐳 docker credentials..." mkdir -p ~/.docker - echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin + ${REGISTER_DOCKER} else echo "🤔 no docker is installed, skipping docker configuration." fi @@ -96,20 +106,19 @@ echo "📦 Configuring JFrog CLI completion..." SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}') # Generate the completion script jf completion $SHELLNAME --install +begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" # Add the completion script to the user's shell profile if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then - if ! grep -q "# jf CLI shell completion" ~/.bashrc; then - echo "" >> ~/.bashrc - echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.bashrc + if ! grep -q "$begin_stanza" ~/.bashrc; then + printf "%s\n" "$begin_stanza" >> ~/.bashrc echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc echo "# END: jf CLI shell completion" >> ~/.bashrc else echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping." fi elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then - if ! grep -q "# jf CLI shell completion" ~/.zshrc; then - echo "" >> ~/.zshrc - echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.zshrc + if ! grep -q "$begin_stanza" ~/.zshrc; then + printf "\n%s\n" "$begin_stanza" >> ~/.zshrc echo "autoload -Uz compinit" >> ~/.zshrc echo "compinit" >> ~/.zshrc echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc diff --git a/jfrog-token/.npmrc.tftpl b/jfrog-token/.npmrc.tftpl new file mode 100644 index 000000000..8bb9fb8f2 --- /dev/null +++ b/jfrog-token/.npmrc.tftpl @@ -0,0 +1,5 @@ +email=${ARTIFACTORY_EMAIL} +%{ for REPO in REPOS ~} +${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME} +//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN} +%{ endfor ~} diff --git a/jfrog-token/README.md b/jfrog-token/README.md index f903f90d5..ce1652229 100644 --- a/jfrog-token/README.md +++ b/jfrog-token/README.md @@ -15,14 +15,15 @@ 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.15" + version = "1.0.30" agent_id = coder_agent.example.id jfrog_url = "https://XXXX.jfrog.io" artifactory_access_token = var.artifactory_access_token package_managers = { - "npm" : "npm", - "go" : "go", - "pypi" : "pypi" + npm = ["npm", "@scoped:npm-scoped"] + go = ["go", "another-go-repo"] + pypi = ["pypi", "extra-index-pypi"] + docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"] } } ``` @@ -41,14 +42,14 @@ For detailed instructions, please see this [guide](https://coder.com/docs/v2/lat ```tf module "jfrog" { source = "registry.coder.com/modules/jfrog-token/coder" - version = "1.0.15" + version = "1.0.30" agent_id = coder_agent.example.id jfrog_url = "https://YYYY.jfrog.io" artifactory_access_token = var.artifactory_access_token # An admin access token package_managers = { - "npm" : "npm-local", - "go" : "go-local", - "pypi" : "pypi-local" + npm = ["npm-local"] + go = ["go-local"] + pypi = ["pypi-local"] } } ``` @@ -74,15 +75,15 @@ The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extensio ```tf module "jfrog" { source = "registry.coder.com/modules/jfrog-token/coder" - version = "1.0.15" + version = "1.0.30" agent_id = coder_agent.example.id jfrog_url = "https://XXXX.jfrog.io" artifactory_access_token = var.artifactory_access_token configure_code_server = true # Add JFrog extension configuration for code-server package_managers = { - "npm" : "npm", - "go" : "go", - "pypi" : "pypi" + npm = ["npm"] + go = ["go"] + pypi = ["pypi"] } } ``` @@ -94,15 +95,13 @@ data "coder_workspace" "me" {} module "jfrog" { source = "registry.coder.com/modules/jfrog-token/coder" - version = "1.0.15" + version = "1.0.30" agent_id = coder_agent.example.id jfrog_url = "https://XXXX.jfrog.io" artifactory_access_token = var.artifactory_access_token token_description = "Token for Coder workspace: ${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}" package_managers = { - "npm" : "npm", - "go" : "go", - "pypi" : "pypi" + npm = ["npm"] } } ``` diff --git a/jfrog-token/main.test.ts b/jfrog-token/main.test.ts index b3b8df982..4ba2f52d3 100644 --- a/jfrog-token/main.test.ts +++ b/jfrog-token/main.test.ts @@ -1,12 +1,30 @@ import { serve } from "bun"; -import { describe } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { createJSONResponse, + findResourceInstance, runTerraformInit, + runTerraformApply, testRequiredVariables, } from "../test"; describe("jfrog-token", async () => { + type TestVariables = { + agent_id: string; + jfrog_url: string; + artifactory_access_token: string; + package_managers: string; + + token_description?: string; + check_license?: boolean; + refreshable?: boolean; + expires_in?: number; + username_field?: string; + username?: string; + jfrog_server_id?: string; + configure_code_server?: boolean; + }; + await runTerraformInit(import.meta.dir); // Run a fake JFrog server so the provider can initialize @@ -32,10 +50,116 @@ describe("jfrog-token", async () => { port: 0, }); - testRequiredVariables(import.meta.dir, { - agent_id: "some-agent-id", - jfrog_url: "http://" + fakeFrogHost.hostname + ":" + fakeFrogHost.port, - artifactory_access_token: "XXXX", - package_managers: "{}", + const fakeFrogApi = `${fakeFrogHost.hostname}:${fakeFrogHost.port}/artifactory/api`; + const fakeFrogUrl = `http://${fakeFrogHost.hostname}:${fakeFrogHost.port}`; + const user = "default"; + const token = "xxx"; + + it("can run apply with required variables", async () => { + testRequiredVariables(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: "{}", + }); + }); + + it("generates an npmrc with scoped repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + npm: ["global", "@foo:foo", "@bar:bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const npmrcStanza = `cat << EOF > ~/.npmrc +email=${user}@example.com +registry=http://${fakeFrogApi}/npm/global +//${fakeFrogApi}/npm/global/:_authToken=xxx +@foo:registry=http://${fakeFrogApi}/npm/foo +//${fakeFrogApi}/npm/foo/:_authToken=xxx +@bar:registry=http://${fakeFrogApi}/npm/bar +//${fakeFrogApi}/npm/bar/:_authToken=xxx + +EOF`; + expect(coderScript.script).toContain(npmrcStanza); + expect(coderScript.script).toContain( + 'jf npmc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured npm', + ); + }); + + it("generates a pip config with extra-indexes", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + pypi: ["global", "foo", "bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const pipStanza = `cat << EOF > ~/.pip/pip.conf +[global] +index-url = https://${user}:${token}@${fakeFrogApi}/pypi/global/simple +extra-index-url = + https://${user}:${token}@${fakeFrogApi}/pypi/foo/simple + https://${user}:${token}@${fakeFrogApi}/pypi/bar/simple + +EOF`; + expect(coderScript.script).toContain(pipStanza); + expect(coderScript.script).toContain( + 'jf pipc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured pypi', + ); + }); + + it("registers multiple docker repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const dockerStanza = ["foo", "bar", "baz"] + .map((r) => `register_docker "${r}.jfrog.io"`) + .join("\n"); + expect(coderScript.script).toContain(dockerStanza); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured docker', + ); + }); + + it("sets goproxy with multiple repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + go: ["foo", "bar", "baz"], + }), + }); + const proxyEnv = findResourceInstance(state, "coder_env", "goproxy"); + const proxies = ["foo", "bar", "baz"] + .map((r) => `https://${user}:${token}@${fakeFrogApi}/go/${r}`) + .join(","); + expect(proxyEnv.value).toEqual(proxies); + + const coderScript = findResourceInstance(state, "coder_script"); + expect(coderScript.script).toContain( + 'jf goc --global --repo-resolve "foo"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured go', + ); }); }); diff --git a/jfrog-token/main.tf b/jfrog-token/main.tf index 90dad6137..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." @@ -80,23 +86,51 @@ variable "configure_code_server" { } variable "package_managers" { - type = map(string) - description = < /dev/null 2>&1; then echo "✅ JFrog CLI is already installed, skipping installation." @@ -11,8 +26,7 @@ else sudo chmod 755 /usr/local/bin/jf fi -# The jf CLI checks $CI when determining whether to use interactive -# flows. +# The jf CLI checks $CI when determining whether to use interactive flows. export CI=true # Authenticate JFrog CLI with Artifactory. echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFROG_URL}" --overwrite "${JFROG_SERVER_ID}" @@ -20,52 +34,47 @@ echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFRO jf c use "${JFROG_SERVER_ID}" # Configure npm to use the Artifactory "npm" repository. -if [ -z "${REPOSITORY_NPM}" ]; then - echo "🤔 no npm repository is set, skipping npm configuration." - echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input." +if [ -z "${HAS_NPM}" ]; then + not_configured npm else echo "📦 Configuring npm..." jf npmc --global --repo-resolve "${REPOSITORY_NPM}" cat << EOF > ~/.npmrc -email=${ARTIFACTORY_EMAIL} -registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM} +${NPMRC} EOF - echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc + config_complete fi # Configure the `pip` to use the Artifactory "python" repository. -if [ -z "${REPOSITORY_PYPI}" ]; then - echo "🤔 no pypi repository is set, skipping pip configuration." - echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input." +if [ -z "${HAS_PYPI}" ]; then + not_configured pypi else echo "🐍 Configuring pip..." jf pipc --global --repo-resolve "${REPOSITORY_PYPI}" mkdir -p ~/.pip cat << EOF > ~/.pip/pip.conf -[global] -index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple +${PIP_CONF} EOF + config_complete fi # Configure Artifactory "go" repository. -if [ -z "${REPOSITORY_GO}" ]; then - echo "🤔 no go repository is set, skipping go configuration." - echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input." +if [ -z "${HAS_GO}" ]; then + not_configured go else echo "🐹 Configuring go..." jf goc --global --repo-resolve "${REPOSITORY_GO}" + config_complete fi -echo "🥳 Configuration complete!" # Configure the JFrog CLI to use the Artifactory "docker" repository. -if [ -z "${REPOSITORY_DOCKER}" ]; then - echo "🤔 no docker repository is set, skipping docker configuration." - echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input." +if [ -z "${HAS_DOCKER}" ]; then + not_configured docker else if command -v docker > /dev/null 2>&1; then echo "🔑 Configuring 🐳 docker credentials..." mkdir -p ~/.docker - echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin + ${REGISTER_DOCKER} else echo "🤔 no docker is installed, skipping docker configuration." fi @@ -96,20 +105,19 @@ echo "📦 Configuring JFrog CLI completion..." SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}') # Generate the completion script jf completion $SHELLNAME --install +begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" # Add the completion script to the user's shell profile if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then - if ! grep -q "# jf CLI shell completion" ~/.bashrc; then - echo "" >> ~/.bashrc - echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.bashrc + if ! grep -q "$begin_stanza" ~/.bashrc; then + printf "%s\n" "$begin_stanza" >> ~/.bashrc echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc echo "# END: jf CLI shell completion" >> ~/.bashrc else echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping." fi elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then - if ! grep -q "# jf CLI shell completion" ~/.zshrc; then - echo "" >> ~/.zshrc - echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.zshrc + if ! grep -q "$begin_stanza" ~/.zshrc; then + printf "\n%s\n" "$begin_stanza" >> ~/.zshrc echo "autoload -Uz compinit" >> ~/.zshrc echo "compinit" >> ~/.zshrc echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc diff --git a/jupyter-notebook/README.md b/jupyter-notebook/README.md index 6338f112a..56f7ff18a 100644 --- a/jupyter-notebook/README.md +++ b/jupyter-notebook/README.md @@ -15,8 +15,9 @@ 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.8" + version = "1.0.19" agent_id = coder_agent.example.id } ``` diff --git a/jupyter-notebook/run.sh b/jupyter-notebook/run.sh index 4f8c4a203..0c7a9b85f 100755 --- a/jupyter-notebook/run.sh +++ b/jupyter-notebook/run.sh @@ -7,14 +7,14 @@ printf "$${BOLD}Installing jupyter-notebook!\n" # check if jupyter-notebook is installed if ! command -v jupyter-notebook > /dev/null 2>&1; then # install jupyter-notebook - # check if python3 pip is installed - if ! command -v pip3 > /dev/null 2>&1; then - echo "pip3 is not installed" - echo "Please install pip3 in your Dockerfile/VM image before running this script" + # check if pipx is installed + if ! command -v pipx > /dev/null 2>&1; then + echo "pipx is not installed" + echo "Please install pipx in your Dockerfile/VM image before using this module" exit 1 fi - # install jupyter-notebook - pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyter + # install jupyter notebook + pipx install -q notebook echo "🥳 jupyter-notebook has been installed\n\n" else echo "🥳 jupyter-notebook is already installed\n\n" @@ -22,4 +22,4 @@ fi echo "👷 Starting jupyter-notebook in background..." echo "check logs at ${LOG_PATH}" -$HOME/.local/bin/jupyter notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 & +$HOME/.local/bin/jupyter-notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 & diff --git a/jupyterlab/README.md b/jupyterlab/README.md index 3d04cf36b..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.8" + version = "1.0.31" agent_id = coder_agent.example.id } ``` diff --git a/jupyterlab/main.test.ts b/jupyterlab/main.test.ts index 2597dc21b..a9789c391 100644 --- a/jupyterlab/main.test.ts +++ b/jupyterlab/main.test.ts @@ -1,20 +1,20 @@ import { describe, expect, it } from "bun:test"; import { + execContainer, executeScriptInContainer, + findResourceInstance, + runContainer, runTerraformApply, runTerraformInit, testRequiredVariables, - findResourceInstance, - runContainer, - TerraformState, - execContainer, + type TerraformState, } from "../test"; // executes the coder script after installing pip const executeScriptInContainerWithPip = async ( state: TerraformState, image: string, - shell: string = "sh", + shell = "sh", ): Promise<{ exitCode: number; stdout: string[]; @@ -22,7 +22,34 @@ const executeScriptInContainerWithPip = async ( }> => { const instance = findResourceInstance(state, "coder_script"); const id = await runContainer(image); - const respPip = await execContainer(id, [shell, "-c", "apk add py3-pip"]); + const respPipx = await execContainer(id, [shell, "-c", "apk add pipx"]); + const resp = await execContainer(id, [shell, "-c", instance.script]); + const stdout = resp.stdout.trim().split("\n"); + const stderr = resp.stderr.trim().split("\n"); + return { + exitCode: resp.exitCode, + stdout, + stderr, + }; +}; + +// executes the coder script after installing pip +const executeScriptInContainerWithUv = async ( + state: TerraformState, + image: string, + shell = "sh", +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + const respPipx = await execContainer(id, [ + shell, + "-c", + "apk --no-cache add uv gcc musl-dev linux-headers && uv venv", + ]); const resp = await execContainer(id, [shell, "-c", instance.script]); const stdout = resp.stdout.trim().split("\n"); const stderr = resp.stderr.trim().split("\n"); @@ -40,22 +67,39 @@ describe("jupyterlab", async () => { agent_id: "foo", }); - it("fails without pip3", 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!", - "pip3 is not installed", - "Please install pip3 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 pip3. + // 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 pip3", async () => { + // it("runs with pipx", async () => { // ... // const output = await executeScriptInContainerWithPip(state, "alpine"); // ... diff --git a/jupyterlab/main.tf b/jupyterlab/main.tf index d7928f0d6..d66edb1c1 100644 --- a/jupyterlab/main.tf +++ b/jupyterlab/main.tf @@ -9,6 +9,9 @@ terraform { } } +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + # Add required variables for your modules and remove any unneeded variables variable "agent_id" { type = string @@ -36,6 +39,12 @@ variable "share" { } } +variable "subdomain" { + type = bool + description = "Determines whether JupyterLab will be accessed via its own subdomain or whether it will be accessed via a path on Coder." + default = true +} + variable "order" { type = number description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." @@ -49,17 +58,18 @@ resource "coder_script" "jupyterlab" { script = templatefile("${path.module}/run.sh", { LOG_PATH : var.log_path, PORT : var.port + BASE_URL : var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab" }) run_on_start = true } resource "coder_app" "jupyterlab" { agent_id = var.agent_id - slug = "jupyterlab" + slug = "jupyterlab" # sync with the usage in URL display_name = "JupyterLab" - url = "http://localhost:${var.port}" + url = var.subdomain ? "http://localhost:${var.port}" : "http://localhost:${var.port}/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab" icon = "/icon/jupyter.svg" - subdomain = true + subdomain = var.subdomain share = var.share order = var.order } diff --git a/jupyterlab/run.sh b/jupyterlab/run.sh index b040cec18..be686e55f 100755 --- a/jupyterlab/run.sh +++ b/jupyterlab/run.sh @@ -1,25 +1,58 @@ #!/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 +} -BOLD='\033[0;1m' +if [ -n "${BASE_URL}" ]; then + BASE_URL_FLAG="--ServerApp.base_url=${BASE_URL}" +fi -printf "$${BOLD}Installing jupyterlab!\n" +BOLD='\033[0;1m' # check if jupyterlab is installed -if ! command -v jupyterlab > /dev/null 2>&1; then - # install jupyterlab - # check if python3 pip is installed - if ! command -v pip3 > /dev/null 2>&1; then - echo "pip3 is not installed" - echo "Please install pip3 in your Dockerfile/VM image before running this script" - exit 1 - fi +if ! command -v jupyter-lab > /dev/null 2>&1; then # install jupyterlab - pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyterlab - echo "🥳 jupyterlab has been installed\n\n" + 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 - echo "🥳 jupyterlab is already installed\n\n" + printf "%s\n\n" "🥳 jupyterlab is already installed" + JUPYTER=$(command -v jupyter-lab) fi -echo "👷 Starting jupyterlab in background..." -echo "check logs at ${LOG_PATH}" -$HOME/.local/bin/jupyter lab --ServerApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 & +printf "👷 Starting jupyterlab in background..." +printf "check logs at ${LOG_PATH}" +$JUPYTER --no-browser \ + "$BASE_URL_FLAG" \ + --ServerApp.ip='*' \ + --ServerApp.port="${PORT}" \ + --ServerApp.token='' \ + --ServerApp.password='' \ + > "${LOG_PATH}" 2>&1 & diff --git a/kasmvnc/README.md b/kasmvnc/README.md new file mode 100644 index 000000000..9c3b28dbf --- /dev/null +++ b/kasmvnc/README.md @@ -0,0 +1,24 @@ +--- +display_name: KasmVNC +description: A modern open source VNC server +icon: ../.icons/kasmvnc.svg +maintainer_github: coder +verified: true +tags: [helper, vnc, desktop] +--- + +# KasmVNC + +Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and create an app to access it via the dashboard. + +```tf +module "kasmvnc" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/kasmvnc/coder" + version = "1.0.23" + agent_id = coder_agent.example.id + desktop_environment = "xfce" +} +``` + +> **Note:** This module only works on workspaces with a pre-installed desktop environment. As an example base image you can use `codercom/enterprise-desktop` image. diff --git a/kasmvnc/main.test.ts b/kasmvnc/main.test.ts new file mode 100644 index 000000000..0116d0535 --- /dev/null +++ b/kasmvnc/main.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +const allowedDesktopEnvs = ["xfce", "kde", "gnome", "lxde", "lxqt"] as const; +type AllowedDesktopEnv = (typeof allowedDesktopEnvs)[number]; + +type TestVariables = Readonly<{ + agent_id: string; + desktop_environment: AllowedDesktopEnv; + port?: string; + kasm_version?: string; +}>; + +describe("Kasm VNC", async () => { + await runTerraformInit(import.meta.dir); + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + desktop_environment: "gnome", + }); + + it("Successfully installs for all expected Kasm desktop versions", async () => { + for (const v of allowedDesktopEnvs) { + const applyWithEnv = () => { + runTerraformApply(import.meta.dir, { + agent_id: "foo", + desktop_environment: v, + }); + }; + + expect(applyWithEnv).not.toThrow(); + } + }); +}); diff --git a/kasmvnc/main.tf b/kasmvnc/main.tf new file mode 100644 index 000000000..4265f3c7c --- /dev/null +++ b/kasmvnc/main.tf @@ -0,0 +1,63 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "port" { + type = number + description = "The port to run KasmVNC on." + default = 6800 +} + +variable "kasm_version" { + type = string + description = "Version of KasmVNC to install." + default = "1.3.2" +} + +variable "desktop_environment" { + type = string + description = "Specifies the desktop environment of the workspace. This should be pre-installed on the workspace." + validation { + condition = contains(["xfce", "kde", "gnome", "lxde", "lxqt"], var.desktop_environment) + error_message = "Invalid desktop environment. Please specify a valid desktop environment." + } +} + +resource "coder_script" "kasm_vnc" { + agent_id = var.agent_id + display_name = "KasmVNC" + icon = "/icon/kasmvnc.svg" + script = templatefile("${path.module}/run.sh", { + PORT : var.port, + DESKTOP_ENVIRONMENT : var.desktop_environment, + KASM_VERSION : var.kasm_version + }) + run_on_start = true +} + +resource "coder_app" "kasm_vnc" { + agent_id = var.agent_id + slug = "kasm-vnc" + display_name = "kasmVNC" + url = "http://localhost:${var.port}" + icon = "/icon/kasmvnc.svg" + subdomain = true + share = "owner" + healthcheck { + url = "http://localhost:${var.port}/app" + interval = 5 + threshold = 5 + } +} diff --git a/kasmvnc/run.sh b/kasmvnc/run.sh new file mode 100644 index 000000000..c285b0501 --- /dev/null +++ b/kasmvnc/run.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash + +# Exit on error, undefined variables, and pipe failures +set -euo pipefail + +# Function to check if vncserver is already installed +check_installed() { + if command -v vncserver &> /dev/null; then + echo "vncserver is already installed." + return 0 # Don't exit, just indicate it's installed + else + return 1 # Indicates not installed + fi +} + +# Function to download a file using wget, curl, or busybox as a fallback +download_file() { + local url="$1" + local output="$2" + local download_tool + + if command -v curl &> /dev/null; then + # shellcheck disable=SC2034 + download_tool=(curl -fsSL) + elif command -v wget &> /dev/null; then + # shellcheck disable=SC2034 + download_tool=(wget -q -O-) + elif command -v busybox &> /dev/null; then + # shellcheck disable=SC2034 + download_tool=(busybox wget -O-) + else + echo "ERROR: No download tool available (curl, wget, or busybox required)" + exit 1 + fi + + # shellcheck disable=SC2288 + "$${download_tool[@]}" "$url" > "$output" || { + echo "ERROR: Failed to download $url" + exit 1 + } +} + +# Function to install kasmvncserver for debian-based distros +install_deb() { + local url=$1 + local kasmdeb="/tmp/kasmvncserver.deb" + + download_file "$url" "$kasmdeb" + + CACHE_DIR="/var/lib/apt/lists/partial" + # Check if the directory exists and was modified in the last 60 minutes + if [[ ! -d "$CACHE_DIR" ]] || ! find "$CACHE_DIR" -mmin -60 -print -quit &> /dev/null; then + echo "Stale package cache, updating..." + # Update package cache with a 300-second timeout for dpkg lock + sudo apt-get -o DPkg::Lock::Timeout=300 -qq update + fi + + DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests "$kasmdeb" + rm "$kasmdeb" +} + +# Function to install kasmvncserver for rpm-based distros +install_rpm() { + local url=$1 + local kasmrpm="/tmp/kasmvncserver.rpm" + local package_manager + + if command -v dnf &> /dev/null; then + # shellcheck disable=SC2034 + package_manager=(dnf localinstall -y) + elif command -v zypper &> /dev/null; then + # shellcheck disable=SC2034 + package_manager=(zypper install -y) + elif command -v yum &> /dev/null; then + # shellcheck disable=SC2034 + package_manager=(yum localinstall -y) + elif command -v rpm &> /dev/null; then + # Do we need to manually handle missing dependencies? + # shellcheck disable=SC2034 + package_manager=(rpm -i) + else + echo "ERROR: No supported package manager available (dnf, zypper, yum, or rpm required)" + exit 1 + fi + + download_file "$url" "$kasmrpm" + + # shellcheck disable=SC2288 + sudo "$${package_manager[@]}" "$kasmrpm" || { + echo "ERROR: Failed to install $kasmrpm" + exit 1 + } + + rm "$kasmrpm" +} + +# Function to install kasmvncserver for Alpine Linux +install_alpine() { + local url=$1 + local kasmtgz="/tmp/kasmvncserver.tgz" + + download_file "$url" "$kasmtgz" + + tar -xzf "$kasmtgz" -C /usr/local/bin/ + rm "$kasmtgz" +} + +# Detect system information +if [[ ! -f /etc/os-release ]]; then + echo "ERROR: Cannot detect OS: /etc/os-release not found" + exit 1 +fi + +# shellcheck disable=SC1091 +source /etc/os-release +distro="$ID" +distro_version="$VERSION_ID" +codename="$VERSION_CODENAME" +arch="$(uname -m)" +if [[ "$ID" == "ol" ]]; then + distro="oracle" + distro_version="$${distro_version%%.*}" +elif [[ "$ID" == "fedora" ]]; then + distro_version="$(grep -oP '\(\K[\w ]+' /etc/fedora-release | tr '[:upper:]' '[:lower:]' | tr -d ' ')" +fi + +echo "Detected Distribution: $distro" +echo "Detected Version: $distro_version" +echo "Detected Codename: $codename" +echo "Detected Architecture: $arch" + +# Map arch to package arch +case "$arch" in + x86_64) + if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then + arch="amd64" + fi + ;; + aarch64) + if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then + arch="arm64" + fi + ;; + arm64) + : # This is effectively a noop + ;; + *) + echo "ERROR: Unsupported architecture: $arch" + exit 1 + ;; +esac + +# Check if vncserver is installed, and install if not +if ! check_installed; then + # Check for NOPASSWD sudo (required) + if ! command -v sudo &> /dev/null || ! sudo -n true 2> /dev/null; then + echo "ERROR: sudo NOPASSWD access required!" + exit 1 + fi + + base_url="https://github.com/kasmtech/KasmVNC/releases/download/v${KASM_VERSION}" + + echo "Installing KASM version: ${KASM_VERSION}" + case $distro in + ubuntu | debian | kali) + bin_name="kasmvncserver_$${codename}_${KASM_VERSION}_$${arch}.deb" + install_deb "$base_url/$bin_name" + ;; + oracle | fedora | opensuse) + bin_name="kasmvncserver_$${distro}_$${distro_version}_${KASM_VERSION}_$${arch}.rpm" + install_rpm "$base_url/$bin_name" + ;; + alpine) + bin_name="kasmvnc.alpine_$${distro_version//./}_$${arch}.tgz" + install_alpine "$base_url/$bin_name" + ;; + *) + echo "Unsupported distribution: $distro" + exit 1 + ;; + esac +else + echo "vncserver already installed. Skipping installation." +fi + +if command -v sudo &> /dev/null && sudo -n true 2> /dev/null; then + kasm_config_file="/etc/kasmvnc/kasmvnc.yaml" + SUDO=sudo +else + kasm_config_file="$HOME/.vnc/kasmvnc.yaml" + SUDO= + + echo "WARNING: Sudo access not available, using user config dir!" + + if [[ -f "$kasm_config_file" ]]; then + echo "WARNING: Custom user KasmVNC config exists, not overwriting!" + echo "WARNING: Ensure that you manually configure the appropriate settings." + kasm_config_file="/dev/stderr" + else + echo "WARNING: This may prevent custom user KasmVNC settings from applying!" + mkdir -p "$HOME/.vnc" + fi +fi + +echo "Writing KasmVNC config to $kasm_config_file" +$SUDO tee "$kasm_config_file" > /dev/null << EOF +network: + protocol: http + websocket_port: ${PORT} + ssl: + require_ssl: false + pem_certificate: + pem_key: + udp: + public_ip: 127.0.0.1 +EOF + +# This password is not used since we start the server without auth. +# The server is protected via the Coder session token / tunnel +# and does not listen publicly +echo -e "password\npassword\n" | vncpasswd -wo -u "$USER" + +# Start the server +printf "🚀 Starting KasmVNC server...\n" +vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > /tmp/kasmvncserver.log 2>&1 & +pid=$! + +# Wait for server to start +sleep 5 +grep -v '^[[:space:]]*$' /tmp/kasmvncserver.log | tail -n 10 +if ps -p $pid | grep -q "^$pid"; then + echo "ERROR: Failed to start KasmVNC server. Check full logs at /tmp/kasmvncserver.log" + exit 1 +fi +printf "🚀 KasmVNC server started successfully!\n" diff --git a/lint.ts b/lint.ts index db1ee9aa3..6652fcb8d 100644 --- a/lint.ts +++ b/lint.ts @@ -5,14 +5,15 @@ import grayMatter from "gray-matter"; const files = await readdir(".", { withFileTypes: true }); const dirs = files.filter( - (f) => f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules" + (f) => + f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules", ); let badExit = false; // error reports an error to the console and sets badExit to true // so that the process will exit with a non-zero exit code. -const error = (...data: any[]) => { +const error = (...data: unknown[]) => { console.error(...data); badExit = true; }; @@ -22,7 +23,7 @@ const verifyCodeBlocks = ( res = { codeIsTF: false, codeIsHCL: false, - } + }, ) => { for (const token of tokens) { // Check in-depth. @@ -30,7 +31,12 @@ const verifyCodeBlocks = ( verifyCodeBlocks(token.items, res); continue; } + if (token.type === "list_item") { + if (token.tokens === undefined) { + throw new Error("Tokens are missing for type list_item"); + } + verifyCodeBlocks(token.tokens, res); continue; } @@ -80,8 +86,9 @@ for (const dir of dirs) { if (!data.maintainer_github) { error(dir.name, "missing maintainer_github"); } + try { - await stat(path.join(".", dir.name, data.icon)); + await stat(path.join(".", dir.name, data.icon ?? "")); } catch (ex) { error(dir.name, "icon does not exist", data.icon); } 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/nodejs/main.test.ts b/nodejs/main.test.ts index 07fc7a540..39e48f49b 100644 --- a/nodejs/main.test.ts +++ b/nodejs/main.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { describe } from "bun:test"; import { runTerraformInit, testRequiredVariables } from "../test"; describe("nodejs", async () => { diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 101094229..000000000 --- a/package-lock.json +++ /dev/null @@ -1,264 +0,0 @@ -{ - "name": "modules", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "modules", - "devDependencies": { - "bun-types": "^1.0.18", - "gray-matter": "^4.0.3", - "marked": "^12.0.0", - "prettier": "^3.2.5", - "prettier-plugin-sh": "^0.13.1", - "prettier-plugin-terraform-formatter": "^1.2.1" - }, - "peerDependencies": { - "typescript": "^5.3.3" - } - }, - "node_modules/@types/node": { - "version": "20.12.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz", - "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/ws": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", - "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/bun-types": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.16.tgz", - "integrity": "sha512-LpAh8dQe4NKvhSW390Rkftw0ume0moSkRm575e1JZ1PwI/dXjbXyjpntq+2F0bVW1FV7V6B8EfWx088b+dNurw==", - "dev": true, - "dependencies": { - "@types/node": "~20.12.8", - "@types/ws": "~8.5.10" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "dev": true, - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/marked": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", - "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", - "dev": true, - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mvdan-sh": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz", - "integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==", - "dev": true - }, - "node_modules/prettier": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", - "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", - "dev": true, - "peer": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-plugin-sh": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.13.1.tgz", - "integrity": "sha512-ytMcl1qK4s4BOFGvsc9b0+k9dYECal7U29bL/ke08FEUsF/JLN0j6Peo0wUkFDG4y2UHLMhvpyd6Sd3zDXe/eg==", - "dev": true, - "dependencies": { - "mvdan-sh": "^0.10.1", - "sh-syntax": "^0.4.1" - }, - "engines": { - "node": ">=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - }, - "peerDependencies": { - "prettier": "^3.0.0" - } - }, - "node_modules/prettier-plugin-terraform-formatter": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-terraform-formatter/-/prettier-plugin-terraform-formatter-1.2.1.tgz", - "integrity": "sha512-rdzV61Bs/Ecnn7uAS/vL5usTX8xUWM+nQejNLZxt3I1kJH5WSeLEmq7LYu1wCoEQF+y7Uv1xGvPRfl3lIe6+tA==", - "dev": true, - "peerDependencies": { - "prettier": ">= 1.16.0" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } - } - }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "dev": true, - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/sh-syntax": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.2.tgz", - "integrity": "sha512-/l2UZ5fhGZLVZa16XQM9/Vq/hezGGbdHeVEA01uWjOL1+7Ek/gt6FquW0iKKws4a9AYPYvlz6RyVvjh3JxOteg==", - "dev": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true - }, - "node_modules/typescript": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", - "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - } - } -} diff --git a/package.json b/package.json index f3136b15e..a122f4f2c 100644 --- a/package.json +++ b/package.json @@ -2,21 +2,20 @@ "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.0.18", + "bun-types": "^1.1.23", "gray-matter": "^4.0.3", - "marked": "^12.0.0", - "prettier": "^3.2.5", + "marked": "^12.0.2", + "prettier": "^3.3.3", "prettier-plugin-sh": "^0.13.1", "prettier-plugin-terraform-formatter": "^1.2.1" }, "peerDependencies": { - "typescript": "^5.3.3" + "typescript": "^5.5.4" }, "prettier": { "plugins": [ 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/personalize/main.test.ts b/personalize/main.test.ts index 9c8134ea2..b499a0b7e 100644 --- a/personalize/main.test.ts +++ b/personalize/main.test.ts @@ -1,13 +1,9 @@ -import { readableStreamToText, spawn } from "bun"; import { describe, expect, it } from "bun:test"; import { executeScriptInContainer, runTerraformApply, runTerraformInit, testRequiredVariables, - runContainer, - execContainer, - findResourceInstance, } from "../test"; describe("personalize", async () => { 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/slackme/main.test.ts b/slackme/main.test.ts index 402a6907f..d8d062428 100644 --- a/slackme/main.test.ts +++ b/slackme/main.test.ts @@ -72,7 +72,7 @@ executed`, it("formats execution with milliseconds", async () => { await assertSlackMessage({ command: "echo test", - format: `$COMMAND took $DURATION`, + format: "$COMMAND took $DURATION", durationMS: 150, output: "echo test took 150ms", }); @@ -81,7 +81,7 @@ executed`, it("formats execution with seconds", async () => { await assertSlackMessage({ command: "echo test", - format: `$COMMAND took $DURATION`, + format: "$COMMAND took $DURATION", durationMS: 15000, output: "echo test took 15.0s", }); @@ -90,7 +90,7 @@ executed`, it("formats execution with minutes", async () => { await assertSlackMessage({ command: "echo test", - format: `$COMMAND took $DURATION`, + format: "$COMMAND took $DURATION", durationMS: 120000, output: "echo test took 2m 0.0s", }); @@ -99,7 +99,7 @@ executed`, it("formats execution with hours", async () => { await assertSlackMessage({ command: "echo test", - format: `$COMMAND took $DURATION`, + format: "$COMMAND took $DURATION", durationMS: 60000 * 60, output: "echo test took 1hr 0m 0.0s", }); @@ -126,7 +126,10 @@ const assertSlackMessage = async (opts: { durationMS?: number; output: string; }) => { - let url: URL; + // Have to use non-null assertion because TS can't tell when the fetch + // function will run + let url!: URL; + const fakeSlackHost = serve({ fetch: (req) => { url = new URL(req.url); @@ -138,15 +141,16 @@ const assertSlackMessage = async (opts: { }, port: 0, }); + const { instance, id } = await setupContainer( "alpine/curl", - opts.format && { - slack_message: opts.format, - }, + opts.format ? { slack_message: opts.format } : undefined, ); + await writeCoder(id, "echo 'token'"); let exec = await execContainer(id, ["sh", "-c", instance.script]); expect(exec.exitCode).toBe(0); + exec = await execContainer(id, [ "sh", "-c", @@ -154,6 +158,7 @@ const assertSlackMessage = async (opts: { fakeSlackHost.hostname }:${fakeSlackHost.port}" slackme ${opts.command}`, ]); + expect(exec.stderr.trim()).toBe(""); expect(url.pathname).toEqual("/api/chat.postMessage"); expect(url.searchParams.get("channel")).toEqual("token"); diff --git a/terraform_validate.sh b/terraform_validate.sh index 292c94c7c..492e65aec 100755 --- a/terraform_validate.sh +++ b/terraform_validate.sh @@ -4,25 +4,25 @@ set -euo pipefail # Function to run terraform init and validate in a directory run_terraform() { - local dir="$1" - echo "Running terraform init and validate in $dir" - pushd "$dir" - terraform init -upgrade - terraform validate - popd + local dir="$1" + echo "Running terraform init and validate in $dir" + pushd "$dir" + terraform init -upgrade + terraform validate + popd } # Main script main() { - # Get the directory of the script - script_dir=$(dirname "$(readlink -f "$0")") + # Get the directory of the script + script_dir=$(dirname "$(readlink -f "$0")") - # Get all subdirectories in the repository - subdirs=$(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -not -name ".*" | sort) + # Get all subdirectories in the repository + subdirs=$(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -not -name ".*" | sort) - for dir in $subdirs; do - run_terraform "$dir" - done + for dir in $subdirs; do + run_terraform "$dir" + done } # Run the main script diff --git a/test.ts b/test.ts index b338205a3..e466cb127 100644 --- a/test.ts +++ b/test.ts @@ -1,6 +1,6 @@ import { readableStreamToText, spawn } from "bun"; -import { afterEach, expect, it } from "bun:test"; -import { readFile, unlink } from "fs/promises"; +import { expect, it } from "bun:test"; +import { readFile, unlink } from "node:fs/promises"; export const runContainer = async ( image: string, @@ -21,7 +21,8 @@ export const runContainer = async ( "-c", init, ]); - let containerID = await readableStreamToText(proc.stdout); + + const containerID = await readableStreamToText(proc.stdout); const exitCode = await proc.exited; if (exitCode !== 0) { throw new Error(containerID); @@ -29,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. @@ -36,14 +43,16 @@ export const runContainer = async ( export const executeScriptInContainer = async ( state: TerraformState, image: string, - shell: string = "sh", -): Promise<{ - exitCode: number; - stdout: string[]; - stderr: string[]; -}> => { + shell = "sh", + 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"); @@ -57,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", }); @@ -90,26 +100,35 @@ type TerraformStateResource = { type: string; name: string; provider: string; - instances: [{ attributes: Record }]; + + instances: [ + { + attributes: Record; + }, + ]; }; -export interface TerraformState { - outputs: { - [key: string]: { - type: string; - value: any; - }; - }; +type TerraformOutput = { + type: string; + value: JsonValue; +}; +export interface TerraformState { + outputs: Record; resources: [TerraformStateResource, ...TerraformStateResource[]]; } +type TerraformVariables = Record; + export interface CoderScriptAttributes { script: string; agent_id: string; url: string; } +export type ResourceInstance = + T extends "coder_script" ? CoderScriptAttributes : Record; + /** * finds the first instance of the given resource type in the given state. If * name is specified, it will only find the instance with the given name. @@ -118,10 +137,7 @@ export const findResourceInstance = ( state: TerraformState, type: T, name?: string, - // if type is "coder_script" return CoderScriptAttributes -): T extends "coder_script" - ? CoderScriptAttributes - : Record => { +): ResourceInstance => { const resource = state.resources.find( (resource) => resource.type === type && (name ? resource.name === name : true), @@ -134,34 +150,41 @@ export const findResourceInstance = ( `Resource ${type} has ${resource.instances.length} instances`, ); } - return resource.instances[0].attributes as any; + + return resource.instances[0].attributes as ResourceInstance; }; /** * Creates a test-case for each variable provided and ensures that the apply * fails without it. */ -export const testRequiredVariables = >( +export const testRequiredVariables = ( dir: string, - vars: TVars, + vars: Readonly, ) => { // Ensures that all required variables are provided. it("required variables", async () => { await runTerraformApply(dir, vars); }); + const varNames = Object.keys(vars); - varNames.forEach((varName) => { + for (const varName of varNames) { // Ensures that every variable provided is required! - it("missing variable " + varName, async () => { - const localVars = {}; - varNames.forEach((otherVarName) => { + it(`missing variable: ${varName}`, async () => { + const localVars: TerraformVariables = {}; + for (const otherVarName of varNames) { if (otherVarName !== varName) { localVars[otherVarName] = vars[otherVarName]; } - }); + } + try { await runTerraformApply(dir, localVars); } catch (ex) { + if (!(ex instanceof Error)) { + throw new Error("Unknown error generated"); + } + expect(ex.message).toContain( `input variable \"${varName}\" is not set`, ); @@ -169,7 +192,7 @@ export const testRequiredVariables = >( } throw new Error(`${varName} is not a required variable!`); }); - }); + } }; /** @@ -177,18 +200,21 @@ export const testRequiredVariables = >( * fine to run in parallel with other instances of this function, as it uses a * random state file. */ -export const runTerraformApply = async < - TVars extends Readonly>, ->( +export const runTerraformApply = async ( dir: string, - vars: TVars, - env?: Record, + vars: Readonly, + 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( @@ -204,7 +230,7 @@ export const runTerraformApply = async < ], { cwd: dir, - env: combinedEnv, + env: childEnv, stderr: "pipe", stdout: "pipe", }, diff --git a/tsconfig.json b/tsconfig.json index e7b89cdeb..c7a5d26e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,14 @@ { "compilerOptions": { - "target": "esnext", - "module": "esnext", + // If we were just compiling for the tests, we could safely target ESNext at + // all times, but just because we've been starting to add more runtime logic + // files to some of the modules, erring on the side of caution by having a + // older compilation target + "target": "ES6", + "module": "ESNext", + "strict": true, "allowSyntheticDefaultImports": true, - "moduleResolution": "nodenext", + "moduleResolution": "node", "types": ["bun-types"] } } diff --git a/update-version.sh b/update-version.sh deleted file mode 100755 index 5deb63b21..000000000 --- a/update-version.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash - -# This script updates the version number in the README.md files of all modules -# to the latest tag in the repository. It is intended to be run from the root -# of the repository or by using the `bun update-version` command. - -set -euo pipefail - -current_tag=$(git describe --tags --abbrev=0) -previous_tag=$(git describe --tags --abbrev=0 $current_tag^) -mapfile -t changed_dirs < <(git diff --name-only "$previous_tag"..."$current_tag" -- ':!**/README.md' ':!**/*.test.ts' | xargs dirname | grep -v '^\.' | sort -u) - -LATEST_TAG=$(git describe --abbrev=0 --tags | sed 's/^v//') || exit $? - -for dir in "${changed_dirs[@]}"; do - if [[ -f "$dir/README.md" ]]; then - echo "Bumping version in $dir/README.md" - file="$dir/README.md" - tmpfile=$(mktemp /tmp/tempfile.XXXXXX) - awk -v tag="$LATEST_TAG" '{ - if ($1 == "version" && $2 == "=") { - sub(/"[^"]*"/, "\"" tag "\"") - print - } else { - print - } - }' "$file" > "$tmpfile" && mv "$tmpfile" "$file" - 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 new file mode 100644 index 000000000..1907dbf0a --- /dev/null +++ b/vault-jwt/README.md @@ -0,0 +1,185 @@ +--- +display_name: Hashicorp Vault Integration (JWT) +description: Authenticates with Vault using a JWT from Coder's OIDC provider +icon: ../.icons/vault.svg +maintainer_github: coder +partner_github: hashicorp +verified: true +tags: [helper, integration, vault, jwt, oidc] +--- + +# Hashicorp Vault Integration (JWT) + +This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces by reusing the [OIDC](https://coder.com/docs/admin/users/oidc-auth) access token from Coder's OIDC authentication method 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" { + 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 +} +``` + +Then you can use the Vault CLI in your workspaces to fetch secrets from Vault: + +```shell +vault kv get -namespace=coder -mount=secrets coder +``` + +or using the Vault API: + +```shell +curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder" +``` + +## Examples + +### Configure Vault integration with a non standard auth path (default is "jwt") + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_auth_path = "oidc" + vault_jwt_role = "coder" # The Vault role to use for authentication +} +``` + +### Map workspace owner's group to a Vault role + +```tf +data "coder_workspace_owner" "me" {} + +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_role = data.coder_workspace_owner.me.groups[0] +} +``` + +### Install a specific version of the Vault CLI + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.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.test.ts b/vault-jwt/main.test.ts new file mode 100644 index 000000000..2fda3d7cf --- /dev/null +++ b/vault-jwt/main.test.ts @@ -0,0 +1,12 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "../test"; + +describe("vault-jwt", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + vault_addr: "foo", + vault_jwt_role: "foo", + }); +}); diff --git a/vault-jwt/main.tf b/vault-jwt/main.tf new file mode 100644 index 000000000..17288e008 --- /dev/null +++ b/vault-jwt/main.tf @@ -0,0 +1,71 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12.4" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "vault_addr" { + type = string + description = "The address of the Vault server." +} + +variable "vault_jwt_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." + default = "jwt" +} + +variable "vault_jwt_role" { + type = string + description = "The name of the Vault role to use for authentication." +} + +variable "vault_cli_version" { + type = string + description = "The version of Vault to install." + default = "latest" + validation { + condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version)) + error_message = "Vault version must be in the format 0.0.0 or latest" + } +} + +resource "coder_script" "vault" { + agent_id = var.agent_id + display_name = "Vault (GitHub)" + icon = "/icon/vault.svg" + script = templatefile("${path.module}/run.sh", { + CODER_OIDC_ACCESS_TOKEN : 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, + }) + run_on_start = true + start_blocks_login = true +} + +resource "coder_env" "vault_addr" { + agent_id = var.agent_id + name = "VAULT_ADDR" + value = var.vault_addr +} + +data "coder_workspace_owner" "me" {} diff --git a/vault-jwt/run.sh b/vault-jwt/run.sh new file mode 100644 index 000000000..d95b45a27 --- /dev/null +++ b/vault-jwt/run.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +# Convert all templated variables to shell variables +VAULT_CLI_VERSION=${VAULT_CLI_VERSION} +VAULT_JWT_AUTH_PATH=${VAULT_JWT_AUTH_PATH} +VAULT_JWT_ROLE=${VAULT_JWT_ROLE} +CODER_OIDC_ACCESS_TOKEN=${CODER_OIDC_ACCESS_TOKEN} + +fetch() { + dest="$1" + url="$2" + if command -v curl > /dev/null 2>&1; then + curl -sSL --fail "$${url}" -o "$${dest}" + elif command -v wget > /dev/null 2>&1; then + wget -O "$${dest}" "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -O "$${dest}" "$${url}" + else + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + exit 1 + fi +} + +unzip_safe() { + if command -v unzip > /dev/null 2>&1; then + command unzip "$@" + elif command -v busybox > /dev/null 2>&1; then + busybox unzip "$@" + else + printf "unzip or busybox is not installed. Please install unzip in your image.\n" + exit 1 + fi +} + +install() { + # Get the architecture of the system + ARCH=$(uname -m) + if [ "$${ARCH}" = "x86_64" ]; then + ARCH="amd64" + elif [ "$${ARCH}" = "aarch64" ]; then + ARCH="arm64" + else + printf "Unsupported architecture: $${ARCH}\n" + return 1 + fi + # Fetch the latest version of Vault if VAULT_CLI_VERSION is 'latest' + if [ "$${VAULT_CLI_VERSION}" = "latest" ]; then + LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1) + printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}" + if [ -z "$${LATEST_VERSION}" ]; then + printf "Failed to determine the latest Vault version.\n" + return 1 + fi + VAULT_CLI_VERSION=$${LATEST_VERSION} + fi + + # Check if the vault CLI is installed and has the correct version + installation_needed=1 + if command -v vault > /dev/null 2>&1; then + CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ "$${CURRENT_VERSION}" = "$${VAULT_CLI_VERSION}" ]; then + printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}" + installation_needed=0 + fi + fi + + if [ $${installation_needed} -eq 1 ]; then + # Download and install Vault + if [ -z "$${CURRENT_VERSION}" ]; then + printf "Installing Vault CLI ...\n\n" + else + printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${VAULT_CLI_VERSION}" + fi + fetch vault.zip "https://releases.hashicorp.com/vault/$${VAULT_CLI_VERSION}/vault_$${VAULT_CLI_VERSION}_linux_$${ARCH}.zip" + if [ $? -ne 0 ]; then + printf "Failed to download Vault.\n" + return 1 + fi + if ! unzip_safe vault.zip; then + printf "Failed to unzip Vault.\n" + return 1 + fi + rm vault.zip + if sudo mv vault /usr/local/bin/vault 2> /dev/null; then + printf "Vault installed successfully!\n\n" + else + mkdir -p ~/.local/bin + if ! mv vault ~/.local/bin/vault; then + printf "Failed to move Vault to local bin.\n" + return 1 + fi + printf "Please add ~/.local/bin to your PATH to use vault CLI.\n" + fi + fi + return 0 +} + +TMP=$(mktemp -d) +if ! ( + cd "$TMP" + install +); then + echo "Failed to install Vault CLI." + exit 1 +fi +rm -rf "$TMP" + +# Authenticate with Vault +printf "🔑 Authenticating with Vault ...\n\n" +echo "$${CODER_OIDC_ACCESS_TOKEN}" | vault write -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-desktop/main.test.ts b/vscode-desktop/main.test.ts index 74c4ffbd1..7aa144ec0 100644 --- a/vscode-desktop/main.test.ts +++ b/vscode-desktop/main.test.ts @@ -22,11 +22,12 @@ describe("vscode-desktop", async () => { ); const coder_app = state.resources.find( - (res) => res.type == "coder_app" && res.name == "vscode", + (res) => res.type === "coder_app" && res.name === "vscode", ); + expect(coder_app).not.toBeNull(); - expect(coder_app.instances.length).toBe(1); - expect(coder_app.instances[0].attributes.order).toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); }); it("adds folder", async () => { @@ -78,10 +79,11 @@ describe("vscode-desktop", async () => { }); const coder_app = state.resources.find( - (res) => res.type == "coder_app" && res.name == "vscode", + (res) => res.type === "coder_app" && res.name === "vscode", ); + expect(coder_app).not.toBeNull(); - expect(coder_app.instances.length).toBe(1); - expect(coder_app.instances[0].attributes.order).toBe(22); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); }); }); diff --git a/vscode-web/README.md b/vscode-web/README.md index ba395d0e5..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.14" + 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.14" + 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.14" + 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.14" + 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 084f8306b..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 = {} } @@ -121,6 +127,18 @@ variable "auto_install_extensions" { default = false } +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = true +} + +data "coder_workspace_owner" "me" {} +data "coder_workspace" "me" {} + resource "coder_script" "vscode-web" { agent_id = var.agent_id display_name = "VS Code Web" @@ -138,6 +156,8 @@ resource "coder_script" "vscode-web" { EXTENSIONS_DIR : var.extensions_dir, FOLDER : var.folder, AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, + SERVER_BASE_PATH : local.server_base_path, + COMMIT_ID : var.commit_id, }) run_on_start = true @@ -158,15 +178,21 @@ resource "coder_app" "vscode-web" { agent_id = var.agent_id slug = var.slug display_name = var.display_name - url = var.folder == "" ? "http://localhost:${var.port}" : "http://localhost:${var.port}?folder=${var.folder}" + url = local.url icon = "/icon/code.svg" - subdomain = true + subdomain = var.subdomain share = var.share order = var.order healthcheck { - url = "http://localhost:${var.port}/healthz" + url = local.healthcheck_url interval = 5 threshold = 6 } } + +locals { + server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) + url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}" + healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz" +} diff --git a/vscode-web/run.sh b/vscode-web/run.sh index ce8782f50..588cec56d 100755 --- a/vscode-web/run.sh +++ b/vscode-web/run.sh @@ -10,10 +10,16 @@ if [ -n "${EXTENSIONS_DIR}" ]; then EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" fi +# Set extension directory +SERVER_BASE_PATH_ARG="" +if [ -n "${SERVER_BASE_PATH}" ]; then + SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}" +fi + run_vscode_web() { - echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..." + echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..." echo "Check logs at ${LOG_PATH}!" - "$VSCODE_WEB" serve-local "$EXTENSION_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 & + "$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 & } # Check if the settings file exists... @@ -53,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" @@ -72,27 +85,26 @@ for extension in "$${EXTENSIONLIST[@]}"; do output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force) if [ $? -ne 0 ]; then echo "Failed to install extension: $extension: $output" - exit 1 fi done if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then if ! command -v jq > /dev/null; then echo "jq is required to install extensions from a workspace file." - exit 0 - fi - - WORKSPACE_DIR="$HOME" - if [ -n "${FOLDER}" ]; then - WORKSPACE_DIR="${FOLDER}" - fi + else + WORKSPACE_DIR="$HOME" + if [ -n "${FOLDER}" ]; then + WORKSPACE_DIR="${FOLDER}" + fi - if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then - printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" - extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json) - for extension in $extensions; do - $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force - done + if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then + printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" + # Use sed to remove single-line comments before parsing with jq + extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]') + for extension in $extensions; do + $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force + done + fi fi fi diff --git a/windows-rdp/README.md b/windows-rdp/README.md index a124eb0bc..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" { - source = "registry.coder.com/coder/module/windows-rdp" - version = "1.0.16" count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windows-rdp/coder" + version = "1.0.18" agent_id = resource.coder_agent.main.id resource_id = resource.aws_instance.dev.id } @@ -32,9 +32,9 @@ module "windows_rdp" { ```tf module "windows_rdp" { - source = "registry.coder.com/coder/module/windows-rdp" - version = "1.0.16" count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windows-rdp/coder" + version = "1.0.18" agent_id = resource.coder_agent.main.id resource_id = resource.aws_instance.dev.id } @@ -44,9 +44,9 @@ module "windows_rdp" { ```tf module "windows_rdp" { - source = "registry.coder.com/coder/module/windows-rdp" - version = "1.0.16" count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windows-rdp/coder" + version = "1.0.18" agent_id = resource.coder_agent.main.id resource_id = resource.google_compute_instance.dev[0].id } diff --git a/windows-rdp/main.test.ts b/windows-rdp/main.test.ts index 24ce1049f..ba5e21a5a 100644 --- a/windows-rdp/main.test.ts +++ b/windows-rdp/main.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { - TerraformState, + type TerraformState, runTerraformApply, runTerraformInit, testRequiredVariables, @@ -9,6 +9,7 @@ import { type TestVariables = Readonly<{ agent_id: string; resource_id: string; + share?: string; admin_username?: string; admin_password?: string; }>; @@ -23,7 +24,10 @@ function findWindowsRdpScript(state: TerraformState): string | null { } for (const instance of resource.instances) { - if (instance.attributes.display_name === "windows-rdp") { + if ( + instance.attributes.display_name === "windows-rdp" && + typeof instance.attributes.script === "string" + ) { return instance.attributes.script; } } @@ -99,11 +103,11 @@ describe("Web RDP", async () => { const defaultRdpScript = findWindowsRdpScript(defaultState); expect(defaultRdpScript).toBeString(); - const { username: defaultUsername, password: defaultPassword } = - formEntryValuesRe.exec(defaultRdpScript)?.groups ?? {}; + const defaultResultsGroup = + formEntryValuesRe.exec(defaultRdpScript ?? "")?.groups ?? {}; - expect(defaultUsername).toBe("Administrator"); - expect(defaultPassword).toBe("coderRDP!"); + expect(defaultResultsGroup.username).toBe("Administrator"); + expect(defaultResultsGroup.password).toBe("coderRDP!"); // Test that custom usernames/passwords are also forwarded correctly const customAdminUsername = "crouton"; @@ -121,10 +125,10 @@ describe("Web RDP", async () => { const customRdpScript = findWindowsRdpScript(customizedState); expect(customRdpScript).toBeString(); - const { username: customUsername, password: customPassword } = - formEntryValuesRe.exec(customRdpScript)?.groups ?? {}; + const customResultsGroup = + formEntryValuesRe.exec(customRdpScript ?? "")?.groups ?? {}; - expect(customUsername).toBe(customAdminUsername); - expect(customPassword).toBe(customAdminPassword); + expect(customResultsGroup.username).toBe(customAdminUsername); + expect(customResultsGroup.password).toBe(customAdminPassword); }); }); diff --git a/windows-rdp/main.tf b/windows-rdp/main.tf index fb09c48f9..10ece09c4 100644 --- a/windows-rdp/main.tf +++ b/windows-rdp/main.tf @@ -9,6 +9,15 @@ terraform { } } +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + variable "agent_id" { type = string description = "The ID of a Coder agent." @@ -53,6 +62,7 @@ resource "coder_script" "windows-rdp" { resource "coder_app" "windows-rdp" { agent_id = var.agent_id + share = var.share slug = "web-rdp" display_name = "Web RDP" url = "http://localhost:7171" 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." +}