diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..5ace460 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/scripts/check_registry_site_health.sh b/.github/scripts/check_registry_site_health.sh new file mode 100644 index 0000000..d1bdea9 --- /dev/null +++ b/.github/scripts/check_registry_site_health.sh @@ -0,0 +1,204 @@ +#!/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/workflows/check_registry_site_health.yaml b/.github/workflows/check_registry_site_health.yaml new file mode 100644 index 0000000..df29ca2 --- /dev/null +++ b/.github/workflows/check_registry_site_health.yaml @@ -0,0 +1,23 @@ +# Check modules health on registry.coder.com +name: check-registry-site-health +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 c27a152..159e8c9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,7 +7,7 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: - validate-contributors: + validate-readme-files: runs-on: ubuntu-latest steps: - name: Check out code @@ -20,3 +20,23 @@ jobs: run: go build ./scripts/contributors && ./contributors - name: Remove build file artifact run: rm ./contributors + test-terraform: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Set up Terraform + uses: coder/coder/.github/actions/setup-tf@main + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + # We're using the latest version of Bun for now, but it might be worth + # reconsidering. They've pushed breaking changes in patch releases + # that have broken our CI. + # Our PR where issues started to pop up: https://github.com/coder/modules/pull/383 + # The Bun PR that broke things: https://github.com/oven-sh/bun/pull/16067 + bun-version: latest + - name: Install dependencies + run: bun install + - name: Run tests + run: bun test diff --git a/.github/workflows/deploy_registry.yaml b/.github/workflows/deploy_registry.yaml new file mode 100644 index 0000000..c3c344d --- /dev/null +++ b/.github/workflows/deploy_registry.yaml @@ -0,0 +1,36 @@ +name: deploy-registry + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + # Set id-token permission for gcloud + # Adding a comment because retriggering the build manually hung? I am the lord of devops and you will bend? + permissions: + contents: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 + with: + workload_identity_provider: projects/309789351055/locations/global/workloadIdentityPools/github-actions/providers/github + service_account: registry-v2-github@coder-registry-1.iam.gserviceaccount.com + + - name: Set up Google Cloud SDK + uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a + + # For the time being, let's have the first couple merges to main in modules deploy a new version + # to *dev*. Once we review and make sure everything's working, we can deploy a new version to *main*. + # Maybe in the future we could automate this based on the result of E2E tests. + - name: Deploy to dev.registry.coder.com + run: | + gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch dev diff --git a/.gitignore b/.gitignore index 5f109fd..6ee570e 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,11 @@ dist # Script output /contributors + +# Terraform files generated during testing +.terraform* +*.tfstate +*.tfstate.lock.info + +# Generated credentials from google-github-actions/auth +gha-creds-*.json diff --git a/.icons/airflow.svg b/.icons/airflow.svg new file mode 100644 index 0000000..46300fe --- /dev/null +++ b/.icons/airflow.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/.icons/aws.svg b/.icons/aws.svg new file mode 100644 index 0000000..3244c97 --- /dev/null +++ b/.icons/aws.svg @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.icons/azure.svg b/.icons/azure.svg new file mode 100644 index 0000000..645ac66 --- /dev/null +++ b/.icons/azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.icons/claude.svg b/.icons/claude.svg new file mode 100644 index 0000000..998fb0d --- /dev/null +++ b/.icons/claude.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/code.svg b/.icons/code.svg new file mode 100644 index 0000000..c6ee366 --- /dev/null +++ b/.icons/code.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.icons/coder-white.svg b/.icons/coder-white.svg new file mode 100644 index 0000000..3bb941d --- /dev/null +++ b/.icons/coder-white.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.icons/cursor.svg b/.icons/cursor.svg new file mode 100644 index 0000000..c074bf2 --- /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 0000000..6a73c7b --- /dev/null +++ b/.icons/dcv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/desktop.svg b/.icons/desktop.svg new file mode 100644 index 0000000..77d231c --- /dev/null +++ b/.icons/desktop.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/.icons/dotfiles.svg b/.icons/dotfiles.svg new file mode 100644 index 0000000..c57ef85 --- /dev/null +++ b/.icons/dotfiles.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/.icons/exoscale.svg b/.icons/exoscale.svg new file mode 100644 index 0000000..c56a615 --- /dev/null +++ b/.icons/exoscale.svg @@ -0,0 +1 @@ +Artboard 1 \ No newline at end of file diff --git a/.icons/filebrowser.svg b/.icons/filebrowser.svg new file mode 100644 index 0000000..5e78ecc --- /dev/null +++ b/.icons/filebrowser.svg @@ -0,0 +1,147 @@ + +image/svg+xml + + + + + \ No newline at end of file diff --git a/.icons/fly.svg b/.icons/fly.svg new file mode 100644 index 0000000..0d0086b --- /dev/null +++ b/.icons/fly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/gateway.svg b/.icons/gateway.svg new file mode 100644 index 0000000..b68e949 --- /dev/null +++ b/.icons/gateway.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.icons/gcp.svg b/.icons/gcp.svg new file mode 100644 index 0000000..0f234a4 --- /dev/null +++ b/.icons/gcp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/git.svg b/.icons/git.svg new file mode 100644 index 0000000..ceef116 --- /dev/null +++ b/.icons/git.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/.icons/github.svg b/.icons/github.svg new file mode 100644 index 0000000..d5e6491 --- /dev/null +++ b/.icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/goose.svg b/.icons/goose.svg new file mode 100644 index 0000000..cbbe841 --- /dev/null +++ b/.icons/goose.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/jfrog.svg b/.icons/jfrog.svg new file mode 100644 index 0000000..e137700 --- /dev/null +++ b/.icons/jfrog.svg @@ -0,0 +1,3 @@ + + + diff --git a/.icons/jupyter.svg b/.icons/jupyter.svg new file mode 100644 index 0000000..bc94e3d --- /dev/null +++ b/.icons/jupyter.svg @@ -0,0 +1,90 @@ + +Group.svg +Created using Figma 0.90 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.icons/kasmvnc.svg b/.icons/kasmvnc.svg new file mode 100644 index 0000000..958f283 --- /dev/null +++ b/.icons/kasmvnc.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.icons/node.svg b/.icons/node.svg new file mode 100644 index 0000000..e33a588 --- /dev/null +++ b/.icons/node.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/personalize.svg b/.icons/personalize.svg new file mode 100644 index 0000000..76bc678 --- /dev/null +++ b/.icons/personalize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/slack.svg b/.icons/slack.svg new file mode 100644 index 0000000..fb55f72 --- /dev/null +++ b/.icons/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/.icons/vault.svg b/.icons/vault.svg new file mode 100644 index 0000000..c90525c --- /dev/null +++ b/.icons/vault.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1c5485b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "files.exclude": { + "**/terraform.tfstate": true, + "**/.terraform": true + } +} diff --git a/README.md b/README.md index debc8c6..58621ba 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # hub + Publish Coder modules and templates for other developers to use. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..7f379c9 Binary files /dev/null and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..7bb903b --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./setup.ts"] \ No newline at end of file diff --git a/examples/modules/README.md b/examples/modules/README.md new file mode 100644 index 0000000..edf4fc3 --- /dev/null +++ b/examples/modules/README.md @@ -0,0 +1,72 @@ +--- +display_name: MODULE_NAME +description: Describe what this module does +icon: ../../../../.icons/.svg +maintainer_github: GITHUB_USERNAME +verified: false +tags: [helper] +--- + +# MODULE_NAME + + + +```tf +module "MODULE_NAME" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/MODULE_NAME/coder" + version = "1.0.2" +} +``` + + + +## Examples + +### Example 1 + +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 + extensions = [ + "dracula-theme.theme-dracula" + ] +} +``` + +Enter the `.` into the extensions array and code-server will automatically install on start. + +### Example 2 + +Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file: + +```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 + extensions = ["dracula-theme.theme-dracula"] + settings = { + "workbench.colorTheme" = "Dracula" + } +} +``` + +### Example 3 + +Run code-server in the background, don't fetch it from GitHub: + +```tf +module "MODULE_NAME" { + source = "registry.coder.com/modules/MODULE_NAME/coder" + version = "1.0.2" + agent_id = coder_agent.example.id + offline = true +} +``` diff --git a/examples/modules/main.tf b/examples/modules/main.tf new file mode 100644 index 0000000..910320e --- /dev/null +++ b/examples/modules/main.tf @@ -0,0 +1,108 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +locals { + # A built-in icon like "/icon/code.svg" or a full URL of icon + icon_url = "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/code.svg" + # a map of all possible values + options = { + "Option 1" = { + "name" = "Option 1", + "value" = "1" + "icon" = "/emojis/1.png" + } + "Option 2" = { + "name" = "Option 2", + "value" = "2" + "icon" = "/emojis/2.png" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "log_path" { + type = string + description = "The path to log MODULE_NAME to." + default = "/tmp/MODULE_NAME.log" +} + +variable "port" { + type = number + description = "The port to run MODULE_NAME on." + default = 19999 +} + +variable "mutable" { + type = bool + description = "Whether the parameter is mutable." + default = true +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} +# Add other variables here + + +resource "coder_script" "MODULE_NAME" { + agent_id = var.agent_id + display_name = "MODULE_NAME" + icon = local.icon_url + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + }) + run_on_start = true + run_on_stop = false +} + +resource "coder_app" "MODULE_NAME" { + agent_id = var.agent_id + slug = "MODULE_NAME" + display_name = "MODULE_NAME" + url = "http://localhost:${var.port}" + icon = local.icon_url + subdomain = false + share = "owner" + order = var.order + + # Remove if the app does not have a healthcheck endpoint + healthcheck { + url = "http://localhost:${var.port}/healthz" + interval = 5 + threshold = 6 + } +} + +data "coder_parameter" "MODULE_NAME" { + type = "list(string)" + name = "MODULE_NAME" + display_name = "MODULE_NAME" + icon = local.icon_url + mutable = var.mutable + default = local.options["Option 1"]["value"] + + dynamic "option" { + for_each = local.options + content { + icon = option.value.icon + name = option.value.name + value = option.value.value + } + } +} + diff --git a/examples/modules/run.sh b/examples/modules/run.sh new file mode 100644 index 0000000..f50f6ba --- /dev/null +++ b/examples/modules/run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env sh + +# Convert templated variables to shell variables +# shellcheck disable=SC2269 +LOG_PATH=${LOG_PATH} + +# shellcheck disable=SC2034 +BOLD='\033[0;1m' + +# shellcheck disable=SC2059 +printf "$${BOLD}Installing MODULE_NAME ...\n\n" + +# Add code here +# Use varibles from the templatefile function in main.tf +# e.g. LOG_PATH, PORT, etc. + +printf "🥳 Installation comlete!\n\n" + +printf "👷 Starting MODULE_NAME in background...\n\n" +# Start the app in here +# 1. Use & to run it in background +# 2. redirct stdout and stderr to log files + +./app > "$${LOG_PATH}" 2>&1 & + +printf "check logs at %s\n\n" "$${LOG_PATH}" diff --git a/package.json b/package.json new file mode 100644 index 0000000..aa3c7e2 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "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:ci": "bun x prettier --check **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt -check **/*.tf .sample/main.tf", + "update-version": "./update-version.sh" + }, + "devDependencies": { + "@types/bun": "^1.2.9", + "bun-types": "^1.1.23", + "gray-matter": "^4.0.3", + "marked": "^12.0.2", + "prettier": "^3.3.3", + "prettier-plugin-sh": "^0.13.1", + "prettier-plugin-terraform-formatter": "^1.2.1" + }, + "peerDependencies": { + "typescript": "^5.5.4" + }, + "prettier": { + "plugins": [ + "prettier-plugin-sh", + "prettier-plugin-terraform-formatter" + ] + } +} \ No newline at end of file diff --git a/registry/coder/.images/airflow.png b/registry/coder/.images/airflow.png new file mode 100644 index 0000000..bdd5798 Binary files /dev/null and b/registry/coder/.images/airflow.png differ diff --git a/registry/coder/.images/amazon-dcv-windows.png b/registry/coder/.images/amazon-dcv-windows.png new file mode 100644 index 0000000..5dd2dee Binary files /dev/null and b/registry/coder/.images/amazon-dcv-windows.png differ diff --git a/registry/coder/.images/aws-custom.png b/registry/coder/.images/aws-custom.png new file mode 100644 index 0000000..e43f616 Binary files /dev/null and b/registry/coder/.images/aws-custom.png differ diff --git a/registry/coder/.images/aws-exclude.png b/registry/coder/.images/aws-exclude.png new file mode 100644 index 0000000..d4c60f2 Binary files /dev/null and b/registry/coder/.images/aws-exclude.png differ diff --git a/registry/coder/.images/aws-regions.png b/registry/coder/.images/aws-regions.png new file mode 100644 index 0000000..4a79efe Binary files /dev/null and b/registry/coder/.images/aws-regions.png differ diff --git a/registry/coder/.images/azure-custom.png b/registry/coder/.images/azure-custom.png new file mode 100644 index 0000000..47be4fe Binary files /dev/null and b/registry/coder/.images/azure-custom.png differ diff --git a/registry/coder/.images/azure-default.png b/registry/coder/.images/azure-default.png new file mode 100644 index 0000000..963ce09 Binary files /dev/null and b/registry/coder/.images/azure-default.png differ diff --git a/registry/coder/.images/azure-exclude.png b/registry/coder/.images/azure-exclude.png new file mode 100644 index 0000000..8a4cd01 Binary files /dev/null and b/registry/coder/.images/azure-exclude.png differ diff --git a/registry/coder/.images/coder-login.png b/registry/coder/.images/coder-login.png new file mode 100644 index 0000000..a085450 Binary files /dev/null and b/registry/coder/.images/coder-login.png differ diff --git a/registry/coder/.images/filebrowser.png b/registry/coder/.images/filebrowser.png new file mode 100644 index 0000000..8a8bbf3 Binary files /dev/null and b/registry/coder/.images/filebrowser.png differ diff --git a/registry/coder/.images/flyio-basic.png b/registry/coder/.images/flyio-basic.png new file mode 100644 index 0000000..4cd21a2 Binary files /dev/null and b/registry/coder/.images/flyio-basic.png differ diff --git a/registry/coder/.images/flyio-custom.png b/registry/coder/.images/flyio-custom.png new file mode 100644 index 0000000..4ca25a4 Binary files /dev/null and b/registry/coder/.images/flyio-custom.png differ diff --git a/registry/coder/.images/flyio-filtered.png b/registry/coder/.images/flyio-filtered.png new file mode 100644 index 0000000..f7b0711 Binary files /dev/null and b/registry/coder/.images/flyio-filtered.png differ diff --git a/registry/coder/.images/gcp-regions.png b/registry/coder/.images/gcp-regions.png new file mode 100644 index 0000000..1e0c362 Binary files /dev/null and b/registry/coder/.images/gcp-regions.png differ diff --git a/registry/coder/.images/git-config-params.png b/registry/coder/.images/git-config-params.png new file mode 100644 index 0000000..55f24a7 Binary files /dev/null and b/registry/coder/.images/git-config-params.png differ diff --git a/registry/coder/.images/hcp-vault-secrets-credentials.png b/registry/coder/.images/hcp-vault-secrets-credentials.png new file mode 100644 index 0000000..e5e9cf2 Binary files /dev/null and b/registry/coder/.images/hcp-vault-secrets-credentials.png differ diff --git a/registry/coder/.images/jetbrains-gateway.png b/registry/coder/.images/jetbrains-gateway.png new file mode 100644 index 0000000..75807f6 Binary files /dev/null and b/registry/coder/.images/jetbrains-gateway.png differ diff --git a/registry/coder/.images/jfrog-oauth.png b/registry/coder/.images/jfrog-oauth.png new file mode 100644 index 0000000..cd897fc Binary files /dev/null and b/registry/coder/.images/jfrog-oauth.png differ diff --git a/registry/coder/.images/jfrog.png b/registry/coder/.images/jfrog.png new file mode 100644 index 0000000..330dad2 Binary files /dev/null and b/registry/coder/.images/jfrog.png differ diff --git a/registry/coder/.images/jupyter-notebook.png b/registry/coder/.images/jupyter-notebook.png new file mode 100644 index 0000000..dad85cc Binary files /dev/null and b/registry/coder/.images/jupyter-notebook.png differ diff --git a/registry/coder/.images/jupyterlab.png b/registry/coder/.images/jupyterlab.png new file mode 100644 index 0000000..3a0451c Binary files /dev/null and b/registry/coder/.images/jupyterlab.png differ diff --git a/registry/coder/.images/vault-login.png b/registry/coder/.images/vault-login.png new file mode 100644 index 0000000..f2814b3 Binary files /dev/null and b/registry/coder/.images/vault-login.png differ diff --git a/registry/coder/.images/vscode-desktop.png b/registry/coder/.images/vscode-desktop.png new file mode 100644 index 0000000..e3e40a4 Binary files /dev/null and b/registry/coder/.images/vscode-desktop.png differ diff --git a/registry/coder/.images/vscode-web.gif b/registry/coder/.images/vscode-web.gif new file mode 100644 index 0000000..dcc563c Binary files /dev/null and b/registry/coder/.images/vscode-web.gif differ diff --git a/registry/coder/modules/amazon-dcv-windows/README.md b/registry/coder/modules/amazon-dcv-windows/README.md new file mode 100644 index 0000000..e16b244 --- /dev/null +++ b/registry/coder/modules/amazon-dcv-windows/README.md @@ -0,0 +1,48 @@ +--- +display_name: Amazon DCV Windows +description: Amazon DCV Server and Web Client for Windows +icon: ../../../../.icons/dcv.svg +maintainer_github: coder +verified: true +tags: [windows, amazon, dcv, web, desktop] +--- + +# Amazon DCV Windows + +Amazon DCV is high performance remote display protocol that provides a secure way to deliver remote desktop and application streaming from any cloud or data center to any device, over varying network conditions. + +![Amazon DCV on a Windows workspace](../../.images/amazon-dcv-windows.png) + +Enable DCV Server and Web Client on Windows workspaces. + +```tf +module "dcv" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/amazon-dcv-windows/coder" + version = "1.0.24" + agent_id = resource.coder_agent.main.id +} + +resource "coder_metadata" "dcv" { + count = data.coder_workspace.me.start_count + resource_id = aws_instance.dev.id # id of the instance resource + + item { + key = "DCV client instructions" + value = "Run `coder port-forward ${data.coder_workspace.me.name} -p ${module.dcv[count.index].port}` and connect to **localhost:${module.dcv[count.index].port}${module.dcv[count.index].web_url_path}**" + } + item { + key = "username" + value = module.dcv[count.index].username + } + item { + key = "password" + value = module.dcv[count.index].password + sensitive = true + } +} +``` + +## License + +Amazon DCV is free to use on AWS EC2 instances but requires a license for other cloud providers. Please see the instructions [here](https://docs.aws.amazon.com/dcv/latest/adminguide/setting-up-license.html#setting-up-license-ec2) for more information. diff --git a/registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 b/registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 new file mode 100644 index 0000000..2b1c9f4 --- /dev/null +++ b/registry/coder/modules/amazon-dcv-windows/install-dcv.ps1 @@ -0,0 +1,170 @@ +# Terraform variables +$adminPassword = "${admin_password}" +$port = "${port}" +$webURLPath = "${web_url_path}" + +function Set-LocalAdminUser { + Write-Output "[INFO] Starting Set-LocalAdminUser function" + $securePassword = ConvertTo-SecureString $adminPassword -AsPlainText -Force + Write-Output "[DEBUG] Secure password created" + Get-LocalUser -Name Administrator | Set-LocalUser -Password $securePassword + Write-Output "[INFO] Administrator password set" + Get-LocalUser -Name Administrator | Enable-LocalUser + Write-Output "[INFO] User Administrator enabled successfully" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +function Get-VirtualDisplayDriverRequired { + Write-Output "[INFO] Starting Get-VirtualDisplayDriverRequired function" + $token = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token-ttl-seconds' = '21600'} -Method PUT -Uri http://169.254.169.254/latest/api/token + Write-Output "[DEBUG] Token acquired: $token" + $instanceType = Invoke-RestMethod -Headers @{'X-aws-ec2-metadata-token' = $token} -Method GET -Uri http://169.254.169.254/latest/meta-data/instance-type + Write-Output "[DEBUG] Instance type: $instanceType" + $OSVersion = ((Get-ItemProperty -Path "Microsoft.PowerShell.Core\Registry::\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name ProductName).ProductName) -replace "[^0-9]", '' + Write-Output "[DEBUG] OS version: $OSVersion" + + # Force boolean result + $result = (($OSVersion -ne "2019") -and ($OSVersion -ne "2022") -and ($OSVersion -ne "2025")) -and (($instanceType[0] -ne 'g') -and ($instanceType[0] -ne 'p')) + Write-Output "[INFO] VirtualDisplayDriverRequired result: $result" + Read-Host "[DEBUG] Press Enter to proceed to the next step" + return [bool]$result +} + +function Download-DCV { + param ( + [bool]$VirtualDisplayDriverRequired + ) + Write-Output "[INFO] Starting Download-DCV function" + + $downloads = @( + @{ + Name = "DCV Display Driver" + Required = $VirtualDisplayDriverRequired + Path = "C:\Windows\Temp\DCVDisplayDriver.msi" + Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-virtual-display-x64-Release.msi" + }, + @{ + Name = "DCV Server" + Required = $true + Path = "C:\Windows\Temp\DCVServer.msi" + Uri = "https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-server-x64-Release.msi" + } + ) + + foreach ($download in $downloads) { + if ($download.Required -and -not (Test-Path $download.Path)) { + try { + Write-Output "[INFO] Downloading $($download.Name)" + + # Display progress manually (no events) + $progressActivity = "Downloading $($download.Name)" + $progressStatus = "Starting download..." + Write-Progress -Activity $progressActivity -Status $progressStatus -PercentComplete 0 + + # Synchronously download the file + $webClient = New-Object System.Net.WebClient + $webClient.DownloadFile($download.Uri, $download.Path) + + # Update progress + Write-Progress -Activity $progressActivity -Status "Completed" -PercentComplete 100 + + Write-Output "[INFO] $($download.Name) downloaded successfully." + } catch { + Write-Output "[ERROR] Failed to download $($download.Name): $_" + throw + } + } else { + Write-Output "[INFO] $($download.Name) already exists. Skipping download." + } + } + + Write-Output "[INFO] All downloads completed" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +function Install-DCV { + param ( + [bool]$VirtualDisplayDriverRequired + ) + Write-Output "[INFO] Starting Install-DCV function" + + if (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue)) { + if ($VirtualDisplayDriverRequired) { + Write-Output "[INFO] Installing DCV Display Driver" + Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVDisplayDriver.msi /quiet /norestart" -Wait + } else { + Write-Output "[INFO] DCV Display Driver installation skipped (not required)." + } + Write-Output "[INFO] Installing DCV Server" + Start-Process "C:\Windows\System32\msiexec.exe" -ArgumentList "/I C:\Windows\Temp\DCVServer.msi ADDLOCAL=ALL /quiet /norestart /l*v C:\Windows\Temp\dcv_install_msi.log" -Wait + } else { + Write-Output "[INFO] DCV Server already installed, skipping installation." + } + + # Wait for the service to appear with a timeout + $timeout = 10 # seconds + $elapsed = 0 + while (-not (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) { + Start-Sleep -Seconds 1 + $elapsed++ + } + + if ($elapsed -ge $timeout) { + Write-Output "[WARNING] Timeout waiting for dcvserver service. A restart is required to complete installation." + Restart-SystemForDCV + } else { + Write-Output "[INFO] dcvserver service detected successfully." + } +} + +function Restart-SystemForDCV { + Write-Output "[INFO] The system will restart in 10 seconds to finalize DCV installation." + Start-Sleep -Seconds 10 + + # Initiate restart + Restart-Computer -Force + + # Exit the script after initiating restart + Write-Output "[INFO] Please wait for the system to restart..." + + Exit 1 +} + + +function Configure-DCV { + Write-Output "[INFO] Starting Configure-DCV function" + $dcvPath = "Microsoft.PowerShell.Core\Registry::\HKEY_USERS\S-1-5-18\Software\GSettings\com\nicesoftware\dcv" + + # Create the required paths + @("$dcvPath\connectivity", "$dcvPath\session-management", "$dcvPath\session-management\automatic-console-session", "$dcvPath\display") | ForEach-Object { + if (-not (Test-Path $_)) { + New-Item -Path $_ -Force | Out-Null + } + } + + # Set registry keys + New-ItemProperty -Path "$dcvPath\session-management" -Name create-session -PropertyType DWORD -Value 1 -Force + New-ItemProperty -Path "$dcvPath\session-management\automatic-console-session" -Name owner -Value Administrator -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name quic-port -PropertyType DWORD -Value $port -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name web-port -PropertyType DWORD -Value $port -Force + New-ItemProperty -Path "$dcvPath\connectivity" -Name web-url-path -PropertyType String -Value $webURLPath -Force + + # Attempt to restart service + if (Get-Service -Name "dcvserver" -ErrorAction SilentlyContinue) { + Restart-Service -Name "dcvserver" + } else { + Write-Output "[WARNING] dcvserver service not found. Ensure the system was restarted properly." + } + + Write-Output "[INFO] DCV configuration completed" + Read-Host "[DEBUG] Press Enter to proceed to the next step" +} + +# Main Script Execution +Write-Output "[INFO] Starting script" +$VirtualDisplayDriverRequired = [bool](Get-VirtualDisplayDriverRequired) +Set-LocalAdminUser +Download-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired +Install-DCV -VirtualDisplayDriverRequired $VirtualDisplayDriverRequired +Configure-DCV +Write-Output "[INFO] Script completed" diff --git a/registry/coder/modules/amazon-dcv-windows/main.tf b/registry/coder/modules/amazon-dcv-windows/main.tf new file mode 100644 index 0000000..90058af --- /dev/null +++ b/registry/coder/modules/amazon-dcv-windows/main.tf @@ -0,0 +1,85 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "admin_password" { + type = string + default = "coderDCV!" + sensitive = true +} + +variable "port" { + type = number + description = "The port number for the DCV server." + default = 8443 +} + +variable "subdomain" { + type = bool + description = "Whether to use a subdomain for the DCV server." + default = true +} + +variable "slug" { + type = string + description = "The slug of the web-dcv coder_app resource." + default = "web-dcv" +} + +resource "coder_app" "web-dcv" { + agent_id = var.agent_id + slug = var.slug + display_name = "Web DCV" + url = "https://localhost:${var.port}${local.web_url_path}?username=${local.admin_username}&password=${var.admin_password}" + icon = "/icon/dcv.svg" + subdomain = var.subdomain +} + +resource "coder_script" "install-dcv" { + agent_id = var.agent_id + display_name = "Install DCV" + icon = "/icon/dcv.svg" + run_on_start = true + script = templatefile("${path.module}/install-dcv.ps1", { + admin_password : var.admin_password, + port : var.port, + web_url_path : local.web_url_path + }) +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + web_url_path = var.subdomain ? "/" : format("/@%s/%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) + admin_username = "Administrator" +} + +output "web_url_path" { + value = local.web_url_path +} + +output "username" { + value = local.admin_username +} + +output "password" { + value = var.admin_password + sensitive = true +} + +output "port" { + value = var.port +} diff --git a/registry/coder/modules/apache-airflow/README.md b/registry/coder/modules/apache-airflow/README.md new file mode 100644 index 0000000..875dbdf --- /dev/null +++ b/registry/coder/modules/apache-airflow/README.md @@ -0,0 +1,24 @@ +--- +display_name: airflow +description: A module that adds Apache Airflow in your Coder template +icon: ../../../../.icons/airflow.svg +maintainer_github: coder +partner_github: nataindata +verified: true +tags: [airflow, idea, web, helper] +--- + +# airflow + +A module that adds Apache Airflow in your Coder template. + +```tf +module "airflow" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/apache-airflow/coder" + version = "1.0.13" + agent_id = coder_agent.main.id +} +``` + +![Airflow](../../.images/airflow.png) diff --git a/registry/coder/modules/apache-airflow/main.tf b/registry/coder/modules/apache-airflow/main.tf new file mode 100644 index 0000000..91b6682 --- /dev/null +++ b/registry/coder/modules/apache-airflow/main.tf @@ -0,0 +1,65 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "log_path" { + type = string + description = "The path to log airflow to." + default = "/tmp/airflow.log" +} + +variable "port" { + type = number + description = "The port to run airflow on." + default = 8080 +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +resource "coder_script" "airflow" { + agent_id = var.agent_id + display_name = "airflow" + icon = "/icon/apache-guacamole.svg" + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port + }) + run_on_start = true +} + +resource "coder_app" "airflow" { + agent_id = var.agent_id + slug = "airflow" + display_name = "airflow" + url = "http://localhost:${var.port}" + icon = "/icon/apache-guacamole.svg" + subdomain = true + share = var.share + order = var.order +} diff --git a/registry/coder/modules/apache-airflow/run.sh b/registry/coder/modules/apache-airflow/run.sh new file mode 100644 index 0000000..d881260 --- /dev/null +++ b/registry/coder/modules/apache-airflow/run.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh + +BOLD='\033[0;1m' + +PATH=$PATH:~/.local/bin +pip install --upgrade apache-airflow + +filename=~/airflow/airflow.db +if ! [ -f $filename ] || ! [ -s $filename ]; then + airflow db init +fi + +export AIRFLOW__CORE__LOAD_EXAMPLES=false + +airflow webserver > ${LOG_PATH} 2>&1 & + +airflow scheduler >> /tmp/airflow_scheduler.log 2>&1 & + +airflow users create -u admin -p admin -r Admin -e admin@admin.com -f Coder -l User diff --git a/registry/coder/modules/aws-region/README.md b/registry/coder/modules/aws-region/README.md new file mode 100644 index 0000000..eb2bdb3 --- /dev/null +++ b/registry/coder/modules/aws-region/README.md @@ -0,0 +1,85 @@ +--- +display_name: AWS Region +description: A parameter with human region names and icons +icon: ../../../../.icons/aws.svg +maintainer_github: coder +verified: true +tags: [helper, parameter, regions, aws] +--- + +# AWS Region + +A parameter with all AWS regions. This allows developers to select +the region closest to them. + +Customize the preselected parameter value: + +```tf +module "aws-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/aws-region/coder" + version = "1.0.12" + default = "us-east-1" +} + +provider "aws" { + region = module.aws_region.value +} +``` + +![AWS Regions](../../.images/aws-regions.png) + +## Examples + +### Customize regions + +Change the display name and icon for a region using the corresponding maps: + +```tf +module "aws-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/aws-region/coder" + version = "1.0.12" + default = "ap-south-1" + + custom_names = { + "ap-south-1" : "Awesome Mumbai!" + } + + custom_icons = { + "ap-south-1" : "/emojis/1f33a.png" + } +} + +provider "aws" { + region = module.aws_region.value +} +``` + +![AWS Custom](../../.images/aws-custom.png) + +### Exclude regions + +Hide the Asia Pacific regions Seoul and Osaka: + +```tf +module "aws-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/aws-region/coder" + version = "1.0.12" + exclude = ["ap-northeast-2", "ap-northeast-3"] +} + +provider "aws" { + region = module.aws_region.value +} +``` + +![AWS Exclude](../../.images/aws-exclude.png) + +## Related templates + +For a complete AWS EC2 template, see the following examples in the [Coder Registry](https://registry.coder.com/). + +- [AWS EC2 (Linux)](https://registry.coder.com/templates/aws-linux) +- [AWS EC2 (Windows)](https://registry.coder.com/templates/aws-windows) diff --git a/registry/coder/modules/aws-region/main.test.ts b/registry/coder/modules/aws-region/main.test.ts new file mode 100644 index 0000000..b7768cf --- /dev/null +++ b/registry/coder/modules/aws-region/main.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("aws-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "us-west-2", + }); + expect(state.outputs.value.value).toBe("us-west-2"); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/coder/modules/aws-region/main.tf b/registry/coder/modules/aws-region/main.tf new file mode 100644 index 0000000..12a01fe --- /dev/null +++ b/registry/coder/modules/aws-region/main.tf @@ -0,0 +1,199 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "display_name" { + default = "AWS Region" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "The region to deploy workspace infrastructure." + description = "The description of the parameter." + type = string +} + +variable "default" { + default = "" + description = "The default region to use if no region is specified." + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "exclude" { + default = [] + description = "A list of region IDs to exclude." + type = list(string) +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + # This is a static list because the regions don't change _that_ + # frequently and including the `aws_regions` data source requires + # the provider, which requires a region. + regions = { + "af-south-1" = { + name = "Africa (Cape Town)" + icon = "/emojis/1f1ff-1f1e6.png" + } + "ap-east-1" = { + name = "Asia Pacific (Hong Kong)" + icon = "/emojis/1f1ed-1f1f0.png" + } + "ap-northeast-1" = { + name = "Asia Pacific (Tokyo)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "ap-northeast-2" = { + name = "Asia Pacific (Seoul)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "ap-northeast-3" = { + name = "Asia Pacific (Osaka)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "ap-south-1" = { + name = "Asia Pacific (Mumbai)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "ap-south-2" = { + name = "Asia Pacific (Hyderabad)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "ap-southeast-1" = { + name = "Asia Pacific (Singapore)" + icon = "/emojis/1f1f8-1f1ec.png" + } + "ap-southeast-2" = { + name = "Asia Pacific (Sydney)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "ap-southeast-3" = { + name = "Asia Pacific (Jakarta)" + icon = "/emojis/1f1ee-1f1e9.png" + } + "ap-southeast-4" = { + name = "Asia Pacific (Melbourne)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "ca-central-1" = { + name = "Canada (Central)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "ca-west-1" = { + name = "Canada West (Calgary)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "eu-central-1" = { + name = "EU (Frankfurt)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-central-2" = { + name = "Europe (Zurich)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-north-1" = { + name = "EU (Stockholm)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-south-1" = { + name = "Europe (Milan)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-south-2" = { + name = "Europe (Spain)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-west-1" = { + name = "EU (Ireland)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-west-2" = { + name = "EU (London)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "eu-west-3" = { + name = "EU (Paris)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "il-central-1" = { + name = "Israel (Tel Aviv)" + icon = "/emojis/1f1ee-1f1f1.png" + } + "me-south-1" = { + name = "Middle East (Bahrain)" + icon = "/emojis/1f1e7-1f1ed.png" + } + "sa-east-1" = { + name = "South America (São Paulo)" + icon = "/emojis/1f1e7-1f1f7.png" + } + "us-east-1" = { + name = "US East (N. Virginia)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east-2" = { + name = "US East (Ohio)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west-1" = { + name = "US West (N. California)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west-2" = { + name = "US West (Oregon)" + icon = "/emojis/1f1fa-1f1f8.png" + } + } +} + +data "coder_parameter" "region" { + name = "aws_region" + display_name = var.display_name + description = var.description + default = var.default == "" ? null : var.default + order = var.coder_parameter_order + mutable = var.mutable + dynamic "option" { + for_each = { for k, v in local.regions : k => v if !(contains(var.exclude, k)) } + content { + name = try(var.custom_names[option.key], option.value.name) + icon = try(var.custom_icons[option.key], option.value.icon) + value = option.key + } + } +} + +output "value" { + value = data.coder_parameter.region.value +} \ No newline at end of file diff --git a/registry/coder/modules/azure-region/README.md b/registry/coder/modules/azure-region/README.md new file mode 100644 index 0000000..1b3d810 --- /dev/null +++ b/registry/coder/modules/azure-region/README.md @@ -0,0 +1,84 @@ +--- +display_name: Azure Region +description: A parameter with human region names and icons +icon: ../../../../.icons/azure.svg +maintainer_github: coder +verified: true +tags: [helper, parameter, azure, regions] +--- + +# Azure Region + +This module adds a parameter with all Azure regions, allowing developers to select the region closest to them. + +```tf +module "azure_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/azure-region/coder" + version = "1.0.12" + default = "eastus" +} + +resource "azurem_resource_group" "example" { + location = module.azure_region.value +} +``` + +![Azure Region Default](../../.images/azure-default.png) + +## Examples + +### Customize existing regions + +Change the display name and icon for a region using the corresponding maps: + +```tf +module "azure-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/azure-region/coder" + version = "1.0.12" + custom_names = { + "australia" : "Go Australia!" + } + custom_icons = { + "australia" : "/icons/smiley.svg" + } +} + +resource "azurerm_resource_group" "example" { + location = module.azure_region.value +} +``` + +![Azure Region Custom](../../.images/azure-custom.png) + +### Exclude Regions + +Hide all regions in Australia except australiacentral: + +```tf +module "azure-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/azure-region/coder" + version = "1.0.12" + exclude = [ + "australia", + "australiacentral2", + "australiaeast", + "australiasoutheast" + ] +} + +resource "azurerm_resource_group" "example" { + location = module.azure_region.value +} +``` + +![Azure Exclude](../../.images/azure-exclude.png) + +## Related templates + +For a complete Azure template, see the following examples in the [Coder Registry](https://registry.coder.com/). + +- [Azure VM (Linux)](https://registry.coder.com/templates/azure-linux) +- [Azure VM (Windows)](https://registry.coder.com/templates/azure-windows) diff --git a/registry/coder/modules/azure-region/main.test.ts b/registry/coder/modules/azure-region/main.test.ts new file mode 100644 index 0000000..a20f8d4 --- /dev/null +++ b/registry/coder/modules/azure-region/main.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("azure-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "westus", + }); + expect(state.outputs.value.value).toBe("westus"); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/coder/modules/azure-region/main.tf b/registry/coder/modules/azure-region/main.tf new file mode 100644 index 0000000..3d1c2f1 --- /dev/null +++ b/registry/coder/modules/azure-region/main.tf @@ -0,0 +1,333 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.11" + } + } +} + +variable "display_name" { + default = "Azure Region" + description = "The display name of the Coder parameter." + type = string +} + +variable "description" { + default = "The region where your workspace will live." + description = "Description of the Coder parameter." +} + +variable "default" { + default = "" + description = "The default region to use if no region is specified." + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "exclude" { + default = [] + description = "A list of region IDs to exclude." + type = list(string) +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + # Note: Options are limited to 64 regions, some redundant regions have been removed. + all_regions = { + "australia" = { + name = "Australia" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australiacentral" = { + name = "Australia Central" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australiacentral2" = { + name = "Australia Central 2" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australiaeast" = { + name = "Australia (New South Wales)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australiasoutheast" = { + name = "Australia Southeast" + icon = "/emojis/1f1e6-1f1fa.png" + } + "brazil" = { + name = "Brazil" + icon = "/emojis/1f1e7-1f1f7.png" + } + "brazilsouth" = { + name = "Brazil (Sao Paulo)" + icon = "/emojis/1f1e7-1f1f7.png" + } + "brazilsoutheast" = { + name = "Brazil Southeast" + icon = "/emojis/1f1e7-1f1f7.png" + } + "brazilus" = { + name = "Brazil US" + icon = "/emojis/1f1e7-1f1f7.png" + } + "canada" = { + name = "Canada" + icon = "/emojis/1f1e8-1f1e6.png" + } + "canadacentral" = { + name = "Canada (Toronto)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "canadaeast" = { + name = "Canada East" + icon = "/emojis/1f1e8-1f1e6.png" + } + "centralindia" = { + name = "India (Pune)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "centralus" = { + name = "US (Iowa)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "eastasia" = { + name = "East Asia (Hong Kong)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "eastus" = { + name = "US (Virginia)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "eastus2" = { + name = "US (Virginia) 2" + icon = "/emojis/1f1fa-1f1f8.png" + } + "europe" = { + name = "Europe" + icon = "/emojis/1f30d.png" + } + "france" = { + name = "France" + icon = "/emojis/1f1eb-1f1f7.png" + } + "francecentral" = { + name = "France (Paris)" + icon = "/emojis/1f1eb-1f1f7.png" + } + "francesouth" = { + name = "France South" + icon = "/emojis/1f1eb-1f1f7.png" + } + "germany" = { + name = "Germany" + icon = "/emojis/1f1e9-1f1ea.png" + } + "germanynorth" = { + name = "Germany North" + icon = "/emojis/1f1e9-1f1ea.png" + } + "germanywestcentral" = { + name = "Germany (Frankfurt)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "india" = { + name = "India" + icon = "/emojis/1f1ee-1f1f3.png" + } + "japan" = { + name = "Japan" + icon = "/emojis/1f1ef-1f1f5.png" + } + "japaneast" = { + name = "Japan (Tokyo)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "japanwest" = { + name = "Japan West" + icon = "/emojis/1f1ef-1f1f5.png" + } + "jioindiacentral" = { + name = "Jio India Central" + icon = "/emojis/1f1ee-1f1f3.png" + } + "jioindiawest" = { + name = "Jio India West" + icon = "/emojis/1f1ee-1f1f3.png" + } + "koreacentral" = { + name = "Korea (Seoul)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "koreasouth" = { + name = "Korea South" + icon = "/emojis/1f1f0-1f1f7.png" + } + "northcentralus" = { + name = "North Central US" + icon = "/emojis/1f1fa-1f1f8.png" + } + "northeurope" = { + name = "Europe (Ireland)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "norway" = { + name = "Norway" + icon = "/emojis/1f1f3-1f1f4.png" + } + "norwayeast" = { + name = "Norway (Oslo)" + icon = "/emojis/1f1f3-1f1f4.png" + } + "norwaywest" = { + name = "Norway West" + icon = "/emojis/1f1f3-1f1f4.png" + } + "qatarcentral" = { + name = "Qatar (Doha)" + icon = "/emojis/1f1f6-1f1e6.png" + } + "singapore" = { + name = "Singapore" + icon = "/emojis/1f1f8-1f1ec.png" + } + "southafrica" = { + name = "South Africa" + icon = "/emojis/1f1ff-1f1e6.png" + } + "southafricanorth" = { + name = "South Africa (Johannesburg)" + icon = "/emojis/1f1ff-1f1e6.png" + } + "southafricawest" = { + name = "South Africa West" + icon = "/emojis/1f1ff-1f1e6.png" + } + "southcentralus" = { + name = "US (Texas)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "southeastasia" = { + name = "Southeast Asia (Singapore)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "southindia" = { + name = "South India" + icon = "/emojis/1f1ee-1f1f3.png" + } + "swedencentral" = { + name = "Sweden (Gävle)" + icon = "/emojis/1f1f8-1f1ea.png" + } + "switzerland" = { + name = "Switzerland" + icon = "/emojis/1f1e8-1f1ed.png" + } + "switzerlandnorth" = { + name = "Switzerland (Zurich)" + icon = "/emojis/1f1e8-1f1ed.png" + } + "switzerlandwest" = { + name = "Switzerland West" + icon = "/emojis/1f1e8-1f1ed.png" + } + "uae" = { + name = "United Arab Emirates" + icon = "/emojis/1f1e6-1f1ea.png" + } + "uaecentral" = { + name = "UAE Central" + icon = "/emojis/1f1e6-1f1ea.png" + } + "uaenorth" = { + name = "UAE (Dubai)" + icon = "/emojis/1f1e6-1f1ea.png" + } + "uk" = { + name = "United Kingdom" + icon = "/emojis/1f1ec-1f1e7.png" + } + "uksouth" = { + name = "UK (London)" + icon = "/emojis/1f1ec-1f1e7.png" + } + "ukwest" = { + name = "UK West" + icon = "/emojis/1f1ec-1f1e7.png" + } + "unitedstates" = { + name = "United States" + icon = "/emojis/1f1fa-1f1f8.png" + } + "westcentralus" = { + name = "West Central US" + icon = "/emojis/1f1fa-1f1f8.png" + } + "westeurope" = { + name = "Europe (Netherlands)" + icon = "/emojis/1f1ea-1f1fa.png" + } + "westindia" = { + name = "West India" + icon = "/emojis/1f1ee-1f1f3.png" + } + "westus" = { + name = "West US" + icon = "/emojis/1f1fa-1f1f8.png" + } + "westus2" = { + name = "US (Washington)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "westus3" = { + name = "US (Arizona)" + icon = "/emojis/1f1fa-1f1f8.png" + } + } +} + +data "coder_parameter" "region" { + name = "azure_region" + display_name = var.display_name + description = var.description + default = var.default == "" ? null : var.default + order = var.coder_parameter_order + mutable = var.mutable + icon = "/icon/azure.png" + dynamic "option" { + for_each = { for k, v in local.all_regions : k => v if !(contains(var.exclude, k)) } + content { + name = try(var.custom_names[option.key], option.value.name) + icon = try(var.custom_icons[option.key], option.value.icon) + value = option.key + } + } +} + +output "value" { + value = data.coder_parameter.region.value +} diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md new file mode 100644 index 0000000..b693440 --- /dev/null +++ b/registry/coder/modules/claude-code/README.md @@ -0,0 +1,114 @@ +--- +display_name: Claude Code +description: Run Claude Code in your workspace +icon: ../../../../.icons/claude.svg +maintainer_github: coder +verified: true +tags: [agent, claude-code] +--- + +# Claude Code + +Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. + +```tf +module "claude-code" { + source = "registry.coder.com/modules/claude-code/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_claude_code = true + claude_code_version = "latest" +} +``` + +### Prerequisites + +- Node.js and npm must be installed in your workspace to install Claude Code +- `screen` must be installed in your workspace to run Claude Code in the background +- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template + +The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. + +## Examples + +### Run in the background and report tasks (Experimental) + +> This functionality is in early access as of Coder v2.21 and is still evolving. +> For now, we recommend testing it in a demo or staging environment, +> rather than deploying to production +> +> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents) +> +> Join our [Discord channel](https://discord.gg/coder) or +> [contact us](https://coder.com/contact) to get help or share feedback. + +Your workspace must have `screen` installed to use this. + +```tf +variable "anthropic_api_key" { + type = string + description = "The Anthropic API key" + sensitive = true +} + +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder-login/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} + +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Write a prompt for Claude Code" + mutable = true +} + +# Set the prompt and system prompt for Claude Code via environment variables +resource "coder_agent" "main" { + # ... + env = { + CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter + CODER_MCP_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value + CODER_MCP_APP_STATUS_SLUG = "claude-code" + CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT + You are a helpful assistant that can help with code. + EOT + } +} + +module "claude-code" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/claude-code/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_claude_code = true + claude_code_version = "0.2.57" + + # Enable experimental features + experiment_use_screen = true + experiment_report_tasks = true +} +``` + +## Run standalone + +Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it directly without using screen or any task reporting to the Coder UI. + +```tf +module "claude-code" { + source = "registry.coder.com/modules/claude-code/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_claude_code = true + claude_code_version = "latest" + + # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL + icon = "https://registry.npmmirror.com/@lobehub/icons-static-png/1.24.0/files/dark/claude-color.png" +} +``` diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf new file mode 100644 index 0000000..349af17 --- /dev/null +++ b/registry/coder/modules/claude-code/main.tf @@ -0,0 +1,170 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/claude.svg" +} + +variable "folder" { + type = string + description = "The folder to run Claude Code in." + default = "/home/coder" +} + +variable "install_claude_code" { + type = bool + description = "Whether to install Claude Code." + default = true +} + +variable "claude_code_version" { + type = string + description = "The version of Claude Code to install." + default = "latest" +} + +variable "experiment_use_screen" { + type = bool + description = "Whether to use screen for running Claude Code in the background." + default = false +} + +variable "experiment_report_tasks" { + type = bool + description = "Whether to enable task reporting." + default = false +} + +# Install and Initialize Claude Code +resource "coder_script" "claude_code" { + agent_id = var.agent_id + display_name = "Claude Code" + icon = var.icon + script = <<-EOT + #!/bin/bash + set -e + + # Function to check if a command exists + command_exists() { + command -v "$1" >/dev/null 2>&1 + } + + # Install Claude Code if enabled + if [ "${var.install_claude_code}" = "true" ]; then + if ! command_exists npm; then + echo "Error: npm is not installed. Please install Node.js and npm first." + exit 1 + fi + echo "Installing Claude Code..." + npm install -g @anthropic-ai/claude-code@${var.claude_code_version} + fi + + if [ "${var.experiment_report_tasks}" = "true" ]; then + echo "Configuring Claude Code to report tasks via Coder MCP..." + coder exp mcp configure claude-code ${var.folder} + fi + + # Run with screen if enabled + if [ "${var.experiment_use_screen}" = "true" ]; then + echo "Running Claude Code in the background..." + + # Check if screen is installed + if ! command_exists screen; then + echo "Error: screen is not installed. Please install screen manually." + exit 1 + fi + + touch "$HOME/.claude-code.log" + + # Ensure the screenrc exists + if [ ! -f "$HOME/.screenrc" ]; then + echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log" + echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" + fi + + if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then + echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" + echo "multiuser on" >> "$HOME/.screenrc" + fi + + if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then + echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" + echo "acladd $(whoami)" >> "$HOME/.screenrc" + fi + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + screen -U -dmS claude-code bash -c ' + cd ${var.folder} + claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log" + exec bash + ' + # Extremely hacky way to send the prompt to the screen session + # This will be fixed in the future, but `claude` was not sending MCP + # tasks when an initial prompt is provided. + screen -S claude-code -X stuff "$CODER_MCP_CLAUDE_TASK_PROMPT" + sleep 5 + screen -S claude-code -X stuff "^M" + else + # Check if claude is installed before running + if ! command_exists claude; then + echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually." + exit 1 + fi + fi + EOT + run_on_start = true +} + +resource "coder_app" "claude_code" { + slug = "claude-code" + display_name = "Claude Code" + agent_id = var.agent_id + command = <<-EOT + #!/bin/bash + set -e + + if [ "${var.experiment_use_screen}" = "true" ]; then + if screen -list | grep -q "claude-code"; then + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + echo "Attaching to existing Claude Code session." | tee -a "$HOME/.claude-code.log" + screen -xRR claude-code + else + echo "Starting a new Claude Code session." | tee -a "$HOME/.claude-code.log" + screen -S claude-code bash -c 'export LANG=en_US.UTF-8; export LC_ALL=en_US.UTF-8; claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash' + fi + else + cd ${var.folder} + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + claude + fi + EOT + icon = var.icon +} diff --git a/registry/coder/modules/code-server/README.md b/registry/coder/modules/code-server/README.md new file mode 100644 index 0000000..30aeff0 --- /dev/null +++ b/registry/coder/modules/code-server/README.md @@ -0,0 +1,115 @@ +--- +display_name: code-server +description: VS Code in the browser +icon: ../../../../.icons/code.svg +maintainer_github: coder +verified: true +tags: [helper, ide, web] +--- + +# code-server + +Automatically install [code-server](https://github.com/coder/code-server) in a workspace, create an app to access it via the dashboard, install extensions, and pre-configure editor settings. + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id +} +``` + +![Screenshot 1](https://github.com/coder/code-server/raw/main/docs/assets/screenshot-1.png?raw=true) + +## Examples + +### Pin Versions + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + install_version = "4.8.3" +} +``` + +### Pre-install Extensions + +Install the Dracula theme from [OpenVSX](https://open-vsx.org/): + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + extensions = [ + "dracula-theme.theme-dracula" + ] +} +``` + +Enter the `.` into the extensions array and code-server will automatically install on start. + +### Pre-configure Settings + +Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file: + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + extensions = ["dracula-theme.theme-dracula"] + settings = { + "workbench.colorTheme" = "Dracula" + } +} +``` + +### Install multiple extensions + +Just run code-server in the background, don't fetch it from GitHub: + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] +} +``` + +### Offline and Use Cached Modes + +By default the module looks for code-server at `/tmp/code-server` but this can be changed with `install_prefix`. + +Run an existing copy of code-server if found, otherwise download from GitHub: + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + use_cached = true + extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] +} +``` + +Just run code-server in the background, don't fetch it from GitHub: + +```tf +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + offline = true +} +``` diff --git a/registry/coder/modules/code-server/main.test.ts b/registry/coder/modules/code-server/main.test.ts new file mode 100644 index 0000000..01e8088 --- /dev/null +++ b/registry/coder/modules/code-server/main.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("code-server", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("use_cached and offline can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + use_cached: "true", + offline: "true", + }); + }; + expect(t).toThrow("Offline and Use Cached can not be used together"); + }); + + it("offline and extensions can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + offline: "true", + extensions: '["1", "2"]', + }); + }; + expect(t).toThrow("Offline mode does not allow extensions to be installed"); + }); + + // More tests depend on shebang refactors +}); diff --git a/registry/coder/modules/code-server/main.tf b/registry/coder/modules/code-server/main.tf new file mode 100644 index 0000000..c80e537 --- /dev/null +++ b/registry/coder/modules/code-server/main.tf @@ -0,0 +1,175 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "extensions" { + type = list(string) + description = "A list of extensions to install." + default = [] +} + +variable "port" { + type = number + description = "The port to run code-server on." + default = 13337 +} + +variable "display_name" { + type = string + description = "The display name for the code-server application." + default = "code-server" +} + +variable "slug" { + type = string + description = "The slug for the code-server application." + default = "code-server" +} + +variable "settings" { + type = any + description = "A map of settings to apply to code-server." + default = {} +} + +variable "folder" { + type = string + description = "The folder to open in code-server." + default = "" +} + +variable "install_prefix" { + type = string + description = "The prefix to install code-server to." + default = "/tmp/code-server" +} + +variable "log_path" { + type = string + description = "The path to log code-server to." + default = "/tmp/code-server.log" +} + +variable "install_version" { + type = string + description = "The version of code-server to install." + default = "" +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "offline" { + type = bool + description = "Just run code-server in the background, don't fetch it from GitHub" + default = false +} + +variable "use_cached" { + type = bool + description = "Uses cached copy code-server in the background, otherwise fetched it from GitHub" + default = false +} + +variable "use_cached_extensions" { + type = bool + description = "Uses cached copy of extensions, otherwise do a forced upgrade" + default = false +} + +variable "extensions_dir" { + type = string + description = "Override the directory to store extensions in." + default = "" +} + +variable "auto_install_extensions" { + type = bool + description = "Automatically install recommended extensions when code-server starts." + default = false +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = false +} + +resource "coder_script" "code-server" { + agent_id = var.agent_id + display_name = "code-server" + icon = "/icon/code.svg" + script = templatefile("${path.module}/run.sh", { + VERSION : var.install_version, + EXTENSIONS : join(",", var.extensions), + APP_NAME : var.display_name, + PORT : var.port, + LOG_PATH : var.log_path, + INSTALL_PREFIX : var.install_prefix, + // This is necessary otherwise the quotes are stripped! + SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), + OFFLINE : var.offline, + USE_CACHED : var.use_cached, + USE_CACHED_EXTENSIONS : var.use_cached_extensions, + EXTENSIONS_DIR : var.extensions_dir, + FOLDER : var.folder, + AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, + }) + run_on_start = true + + lifecycle { + precondition { + condition = !var.offline || length(var.extensions) == 0 + error_message = "Offline mode does not allow extensions to be installed" + } + + precondition { + condition = !var.offline || !var.use_cached + error_message = "Offline and Use Cached can not be used together" + } + } +} + +resource "coder_app" "code-server" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : ""}" + icon = "/icon/code.svg" + subdomain = var.subdomain + share = var.share + order = var.order + + healthcheck { + url = "http://localhost:${var.port}/healthz" + interval = 5 + threshold = 6 + } +} diff --git a/registry/coder/modules/code-server/run.sh b/registry/coder/modules/code-server/run.sh new file mode 100644 index 0000000..99b30c0 --- /dev/null +++ b/registry/coder/modules/code-server/run.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +EXTENSIONS=("${EXTENSIONS}") +BOLD='\033[0;1m' +CODE='\033[36;40;1m' +RESET='\033[0m' +CODE_SERVER="${INSTALL_PREFIX}/bin/code-server" + +# Set extension directory +EXTENSION_ARG="" +if [ -n "${EXTENSIONS_DIR}" ]; then + EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" + mkdir -p "${EXTENSIONS_DIR}" +fi + +function run_code_server() { + echo "👷 Running code-server in the background..." + echo "Check logs at ${LOG_PATH}!" + $CODE_SERVER "$EXTENSION_ARG" --auth none --port "${PORT}" --app-name "${APP_NAME}" > "${LOG_PATH}" 2>&1 & +} + +# Check if the settings file exists... +if [ ! -f ~/.local/share/code-server/User/settings.json ]; then + echo "⚙️ Creating settings file..." + mkdir -p ~/.local/share/code-server/User + echo "${SETTINGS}" > ~/.local/share/code-server/User/settings.json +fi + +# Check if code-server is already installed for offline +if [ "${OFFLINE}" = true ]; then + if [ -f "$CODE_SERVER" ]; then + echo "🥳 Found a copy of code-server" + run_code_server + exit 0 + fi + # Offline mode always expects a copy of code-server to be present + echo "Failed to find a copy of code-server" + exit 1 +fi + +# If there is no cached install OR we don't want to use a cached install +if [ ! -f "$CODE_SERVER" ] || [ "${USE_CACHED}" != true ]; then + printf "$${BOLD}Installing code-server!\n" + + # Clean up from other install (in case install prefix changed). + if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/code-server" ]; then + rm "$CODER_SCRIPT_BIN_DIR/code-server" + fi + + ARGS=( + "--method=standalone" + "--prefix=${INSTALL_PREFIX}" + ) + if [ -n "${VERSION}" ]; then + ARGS+=("--version=${VERSION}") + fi + + output=$(curl -fsSL https://code-server.dev/install.sh | sh -s -- "$${ARGS[@]}") + if [ $? -ne 0 ]; then + echo "Failed to install code-server: $output" + exit 1 + fi + printf "🥳 code-server has been installed in ${INSTALL_PREFIX}\n\n" +fi + +# Make the code-server available in PATH. +if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/code-server" ]; then + ln -s "$CODE_SERVER" "$CODER_SCRIPT_BIN_DIR/code-server" +fi + +# Get the list of installed extensions... +LIST_EXTENSIONS=$($CODE_SERVER --list-extensions $EXTENSION_ARG) +readarray -t EXTENSIONS_ARRAY <<< "$LIST_EXTENSIONS" +function extension_installed() { + if [ "${USE_CACHED_EXTENSIONS}" != true ]; then + return 1 + fi + for _extension in "$${EXTENSIONS_ARRAY[@]}"; do + if [ "$_extension" == "$1" ]; then + echo "Extension $1 was already installed." + return 0 + fi + done + return 1 +} + +# Install each extension... +IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" +for extension in "$${EXTENSIONLIST[@]}"; do + if [ -z "$extension" ]; then + continue + fi + if extension_installed "$extension"; then + continue + fi + printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n" + output=$($CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension") + if [ $? -ne 0 ]; then + echo "Failed to install extension: $extension: $output" + exit 1 + fi +done + +if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then + if ! command -v jq > /dev/null; then + echo "jq is required to install extensions from a workspace file." + exit 0 + fi + + WORKSPACE_DIR="$HOME" + if [ -n "${FOLDER}" ]; then + WORKSPACE_DIR="${FOLDER}" + fi + + if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then + printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" + # Use sed to remove single-line comments before parsing with jq + extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]') + for extension in $extensions; do + if extension_installed "$extension"; then + continue + fi + $CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension" + done + fi +fi + +run_code_server diff --git a/registry/coder/modules/coder-login/README.md b/registry/coder/modules/coder-login/README.md new file mode 100644 index 0000000..944a43a --- /dev/null +++ b/registry/coder/modules/coder-login/README.md @@ -0,0 +1,23 @@ +--- +display_name: Coder Login +description: Automatically logs the user into Coder on their workspace +icon: ../../../../.icons/coder-white.svg +maintainer_github: coder +verified: true +tags: [helper] +--- + +# Coder Login + +Automatically logs the user into Coder when creating their workspace. + +```tf +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder-login/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} +``` + +![Coder Login Logs](../../.images/coder-login.png) diff --git a/registry/coder/modules/coder-login/main.test.ts b/registry/coder/modules/coder-login/main.test.ts new file mode 100644 index 0000000..c7ecef1 --- /dev/null +++ b/registry/coder/modules/coder-login/main.test.ts @@ -0,0 +1,10 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "~test"; + +describe("coder-login", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); +}); diff --git a/registry/coder/modules/coder-login/main.tf b/registry/coder/modules/coder-login/main.tf new file mode 100644 index 0000000..0db33a8 --- /dev/null +++ b/registry/coder/modules/coder-login/main.tf @@ -0,0 +1,31 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_script" "coder-login" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + CODER_USER_TOKEN : data.coder_workspace_owner.me.session_token, + CODER_DEPLOYMENT_URL : data.coder_workspace.me.access_url + }) + display_name = "Coder Login" + icon = "/icon/coder.svg" + run_on_start = true + start_blocks_login = true +} + diff --git a/registry/coder/modules/coder-login/run.sh b/registry/coder/modules/coder-login/run.sh new file mode 100644 index 0000000..c91eb1e --- /dev/null +++ b/registry/coder/modules/coder-login/run.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh + +# Automatically authenticate the user if they are not +# logged in to another deployment + +BOLD='\033[0;1m' + +printf "$${BOLD}Logging into Coder...\n\n$${RESET}" + +if ! coder list > /dev/null 2>&1; then + set +x + coder login --token="${CODER_USER_TOKEN}" --url="${CODER_DEPLOYMENT_URL}" +else + echo "You are already authenticated with coder." +fi diff --git a/registry/coder/modules/cursor/README.md b/registry/coder/modules/cursor/README.md new file mode 100644 index 0000000..22dd70d --- /dev/null +++ b/registry/coder/modules/cursor/README.md @@ -0,0 +1,37 @@ +--- +display_name: Cursor IDE +description: Add a one-click button to launch Cursor IDE +icon: ../../../../.icons/cursor.svg +maintainer_github: coder +verified: true +tags: [ide, cursor, helper] +--- + +# Cursor IDE + +Add a button to open any workspace with a single click in Cursor IDE. + +Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder). + +```tf +module "cursor" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/cursor/coder" + version = "1.0.19" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "cursor" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/cursor/coder" + version = "1.0.19" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` diff --git a/registry/coder/modules/cursor/main.test.ts b/registry/coder/modules/cursor/main.test.ts new file mode 100644 index 0000000..ed92b9c --- /dev/null +++ b/registry/coder/modules/cursor/main.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("cursor", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "cursor", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("adds folder", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder and open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + open_recent: "true", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder but not open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + openRecent: "false", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + open_recent: "true", + }); + expect(state.outputs.cursor_url.value).toBe( + "cursor://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("expect order to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "cursor", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); +}); diff --git a/registry/coder/modules/cursor/main.tf b/registry/coder/modules/cursor/main.tf new file mode 100644 index 0000000..f350f94 --- /dev/null +++ b/registry/coder/modules/cursor/main.tf @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "folder" { + type = string + description = "The folder to open in Cursor IDE." + default = "" +} + +variable "open_recent" { + type = bool + description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." + default = false +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "cursor" { + agent_id = var.agent_id + external = true + icon = "/icon/cursor.svg" + slug = "cursor" + display_name = "Cursor Desktop" + order = var.order + url = join("", [ + "cursor://coder.coder-remote/open", + "?owner=", + data.coder_workspace_owner.me.name, + "&workspace=", + data.coder_workspace.me.name, + var.folder != "" ? join("", ["&folder=", var.folder]) : "", + var.open_recent ? "&openRecent" : "", + "&url=", + data.coder_workspace.me.access_url, + "&token=$SESSION_TOKEN", + ]) +} + +output "cursor_url" { + value = coder_app.cursor.url + description = "Cursor IDE Desktop URL." +} diff --git a/registry/coder/modules/dotfiles/README.md b/registry/coder/modules/dotfiles/README.md new file mode 100644 index 0000000..15624e4 --- /dev/null +++ b/registry/coder/modules/dotfiles/README.md @@ -0,0 +1,84 @@ +--- +display_name: Dotfiles +description: Allow developers to optionally bring their own dotfiles repository to customize their shell and IDE settings! +icon: ../../../../.icons/dotfiles.svg +maintainer_github: coder +verified: true +tags: [helper] +--- + +# Dotfiles + +Allow developers to optionally bring their own [dotfiles repository](https://dotfiles.github.io). + +This will prompt the user for their dotfiles repository URL on template creation using a `coder_parameter`. + +Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/latest/dotfiles) command. + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Apply dotfiles as the current user + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id +} +``` + +### Apply dotfiles as another user (only works if sudo is passwordless) + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id + user = "root" +} +``` + +### Apply the same dotfiles as the current user and root (the root dotfiles can only be applied if sudo is passwordless) + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id +} + +module "dotfiles-root" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id + user = "root" + dotfiles_uri = module.dotfiles.dotfiles_uri +} +``` + +## Setting a default dotfiles repository + +You can set a default dotfiles repository for all users by setting the `default_dotfiles_uri` variable: + +```tf +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.29" + agent_id = coder_agent.example.id + default_dotfiles_uri = "https://github.com/coder/dotfiles" +} +``` diff --git a/registry/coder/modules/dotfiles/main.test.ts b/registry/coder/modules/dotfiles/main.test.ts new file mode 100644 index 0000000..8c82cd1 --- /dev/null +++ b/registry/coder/modules/dotfiles/main.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("dotfiles", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.dotfiles_uri.value).toBe(""); + }); + + it("set a default dotfiles_uri", async () => { + const default_dotfiles_uri = "foo"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + default_dotfiles_uri, + }); + expect(state.outputs.dotfiles_uri.value).toBe(default_dotfiles_uri); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(2); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/coder/modules/dotfiles/main.tf b/registry/coder/modules/dotfiles/main.tf new file mode 100644 index 0000000..9bc3735 --- /dev/null +++ b/registry/coder/modules/dotfiles/main.tf @@ -0,0 +1,91 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "default_dotfiles_uri" { + type = string + description = "The default dotfiles URI if the workspace user does not provide one" + default = "" +} + +variable "dotfiles_uri" { + type = string + description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)" + + default = null +} + +variable "user" { + type = string + description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)" + default = null +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +variable "manual_update" { + type = bool + description = "If true, this adds a button to workspace page to refresh dotfiles on demand." + default = false +} + +data "coder_parameter" "dotfiles_uri" { + count = var.dotfiles_uri == null ? 1 : 0 + type = "string" + name = "dotfiles_uri" + display_name = "Dotfiles URL" + order = var.coder_parameter_order + default = var.default_dotfiles_uri + description = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace" + mutable = true + icon = "/icon/dotfiles.svg" +} + +locals { + dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value + user = var.user != null ? var.user : "" +} + +resource "coder_script" "dotfiles" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + DOTFILES_URI : local.dotfiles_uri, + DOTFILES_USER : local.user + }) + display_name = "Dotfiles" + icon = "/icon/dotfiles.svg" + run_on_start = true +} + +resource "coder_app" "dotfiles" { + count = var.manual_update ? 1 : 0 + agent_id = var.agent_id + display_name = "Refresh Dotfiles" + slug = "dotfiles" + icon = "/icon/dotfiles.svg" + command = templatefile("${path.module}/run.sh", { + DOTFILES_URI : local.dotfiles_uri, + DOTFILES_USER : local.user + }) +} + +output "dotfiles_uri" { + description = "Dotfiles URI" + value = local.dotfiles_uri +} diff --git a/registry/coder/modules/dotfiles/run.sh b/registry/coder/modules/dotfiles/run.sh new file mode 100644 index 0000000..e059941 --- /dev/null +++ b/registry/coder/modules/dotfiles/run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DOTFILES_URI="${DOTFILES_URI}" +DOTFILES_USER="${DOTFILES_USER}" + +if [ -n "$${DOTFILES_URI// }" ]; then + if [ -z "$DOTFILES_USER" ]; then + DOTFILES_USER="$USER" + fi + + echo "✨ Applying dotfiles for user $DOTFILES_USER" + + if [ "$DOTFILES_USER" = "$USER" ]; then + coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log + else + # The `eval echo ~"$DOTFILES_USER"` part is used to dynamically get the home directory of the user, see https://superuser.com/a/484280 + # eval echo ~coder -> "/home/coder" + # eval echo ~root -> "/root" + + CODER_BIN=$(which coder) + DOTFILES_USER_HOME=$(eval echo ~"$DOTFILES_USER") + sudo -u "$DOTFILES_USER" sh -c "'$CODER_BIN' dotfiles '$DOTFILES_URI' -y 2>&1 | tee '$DOTFILES_USER_HOME'/.dotfiles.log" + fi +fi diff --git a/registry/coder/modules/filebrowser/README.md b/registry/coder/modules/filebrowser/README.md new file mode 100644 index 0000000..8cf1ac7 --- /dev/null +++ b/registry/coder/modules/filebrowser/README.md @@ -0,0 +1,62 @@ +--- +display_name: File Browser +description: A file browser for your workspace +icon: ../../../../.icons/filebrowser.svg +maintainer_github: coder +verified: true +tags: [helper, filebrowser] +--- + +# File Browser + +A file browser for your workspace. + +```tf +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" + agent_id = coder_agent.example.id +} +``` + +![Filebrowsing Example](../../.images/filebrowser.png) + +## Examples + +### Serve a specific directory + +```tf +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` + +### Specify location of `filebrowser.db` + +```tf +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + database_path = ".config/filebrowser.db" +} +``` + +### Serve from the same domain (no subdomain) + +```tf +module "filebrowser" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/filebrowser/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + agent_name = "main" + subdomain = false +} +``` diff --git a/registry/coder/modules/filebrowser/main.tf b/registry/coder/modules/filebrowser/main.tf new file mode 100644 index 0000000..ba83844 --- /dev/null +++ b/registry/coder/modules/filebrowser/main.tf @@ -0,0 +1,123 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "agent_name" { + type = string + description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)" + default = null +} + +variable "database_path" { + type = string + description = "The path to the filebrowser database." + default = "filebrowser.db" + validation { + # Ensures path leads to */filebrowser.db + condition = can(regex(".*filebrowser\\.db$", var.database_path)) + error_message = "The database_path must end with 'filebrowser.db'." + } +} + +variable "log_path" { + type = string + description = "The path to log filebrowser to." + default = "/tmp/filebrowser.log" +} + +variable "port" { + type = number + description = "The port to run filebrowser on." + default = 13339 +} + +variable "folder" { + type = string + description = "--root value for filebrowser." + default = "~" +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "slug" { + type = string + description = "The slug of the coder_app resource." + default = "filebrowser" +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = true +} + +resource "coder_script" "filebrowser" { + agent_id = var.agent_id + display_name = "File Browser" + icon = "/icon/filebrowser.svg" + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port, + FOLDER : var.folder, + LOG_PATH : var.log_path, + DB_PATH : var.database_path, + SUBDOMAIN : var.subdomain, + SERVER_BASE_PATH : local.server_base_path + }) + run_on_start = true +} + +resource "coder_app" "filebrowser" { + agent_id = var.agent_id + slug = var.slug + display_name = "File Browser" + url = local.url + icon = "/icon/filebrowser.svg" + subdomain = var.subdomain + share = var.share + order = var.order + + healthcheck { + url = local.healthcheck_url + interval = 5 + threshold = 6 + } +} + +locals { + server_base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug) + url = "http://localhost:${var.port}${local.server_base_path}" + healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/health" +} \ No newline at end of file diff --git a/registry/coder/modules/filebrowser/run.sh b/registry/coder/modules/filebrowser/run.sh new file mode 100644 index 0000000..84810e4 --- /dev/null +++ b/registry/coder/modules/filebrowser/run.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +BOLD='\033[[0;1m' + +printf "$${BOLD}Installing filebrowser \n\n" + +# Check if filebrowser is installed +if ! command -v filebrowser &> /dev/null; then + curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash +fi + +printf "🥳 Installation complete! \n\n" + +printf "🛠️ Configuring filebrowser \n\n" + +ROOT_DIR=${FOLDER} +ROOT_DIR=$${ROOT_DIR/\~/$HOME} + +echo "DB_PATH: ${DB_PATH}" + +export FB_DATABASE="${DB_PATH}" + +# Check if filebrowser db exists +if [[ ! -f "${DB_PATH}" ]]; then + filebrowser config init 2>&1 | tee -a ${LOG_PATH} + filebrowser users add admin "" --perm.admin=true --viewMode=mosaic 2>&1 | tee -a ${LOG_PATH} +fi + +filebrowser config set --baseurl=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH} + +printf "👷 Starting filebrowser in background... \n\n" + +printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n" + +filebrowser >> ${LOG_PATH} 2>&1 & + +printf "📝 Logs at ${LOG_PATH} \n\n" diff --git a/registry/coder/modules/fly-region/README.md b/registry/coder/modules/fly-region/README.md new file mode 100644 index 0000000..a71c132 --- /dev/null +++ b/registry/coder/modules/fly-region/README.md @@ -0,0 +1,70 @@ +--- +display_name: Fly.io Region +description: A parameter with human region names and icons +icon: ../../../../.icons/fly.svg +maintainer_github: coder +verified: true +tags: [helper, parameter, fly.io, regions] +--- + +# Fly.io Region + +This module adds Fly.io regions to your Coder template. Regions can be whitelisted using the `regions` argument and given custom names and custom icons with their respective map arguments (`custom_names`, `custom_icons`). + +We can use the simplest format here, only adding a default selection as the `atl` region. + +```tf +module "fly-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/fly-region/coder" + version = "1.0.2" + default = "atl" +} +``` + +![Fly.io Default](../../.images/flyio-basic.png) + +## Examples + +### Using region whitelist + +The regions argument can be used to display only the desired regions in the Coder parameter. + +```tf +module "fly-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/fly-region/coder" + version = "1.0.2" + default = "ams" + regions = ["ams", "arn", "atl"] +} +``` + +![Fly.io Filtered Regions](../../.images/flyio-filtered.png) + +### Using custom icons and names + +Set custom icons and names with their respective maps. + +```tf +module "fly-region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/fly-region/coder" + version = "1.0.2" + default = "ams" + + custom_icons = { + "ams" = "/emojis/1f90e.png" + } + + custom_names = { + "ams" = "We love the Netherlands!" + } +} +``` + +![Fly.io custom icon and name](../../.images/flyio-custom.png) + +## Associated template + +Also see the Coder template registry for a [Fly.io template](https://registry.coder.com/templates/fly-docker-image) that provisions workspaces as Fly.io machines. diff --git a/registry/coder/modules/fly-region/main.test.ts b/registry/coder/modules/fly-region/main.test.ts new file mode 100644 index 0000000..4882fd0 --- /dev/null +++ b/registry/coder/modules/fly-region/main.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("fly-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "atl", + }); + expect(state.outputs.value.value).toBe("atl"); + }); + + it("region filter", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "atl", + regions: '["arn", "ams", "bos"]', + }); + expect(state.outputs.value.value).toBe(""); + }); +}); diff --git a/registry/coder/modules/fly-region/main.tf b/registry/coder/modules/fly-region/main.tf new file mode 100644 index 0000000..ff6a9e3 --- /dev/null +++ b/registry/coder/modules/fly-region/main.tf @@ -0,0 +1,287 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "display_name" { + default = "Fly.io Region" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "The region to deploy workspace infrastructure." + description = "The description of the parameter." + type = string +} + +variable "default" { + default = null + description = "The default region to use if no region is specified." + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "regions" { + default = [] + description = "List of regions to include for region selection." + type = list(string) +} + +locals { + regions = { + "ams" = { + name = "Amsterdam, Netherlands" + gateway = true + paid_only = false + icon = "/emojis/1f1f3-1f1f1.png" + } + "arn" = { + name = "Stockholm, Sweden" + gateway = false + paid_only = false + icon = "/emojis/1f1f8-1f1ea.png" + } + "atl" = { + name = "Atlanta, Georgia (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "bog" = { + name = "Bogotá, Colombia" + gateway = false + paid_only = false + icon = "/emojis/1f1e8-1f1f4.png" + } + "bom" = { + name = "Mumbai, India" + gateway = true + paid_only = true + icon = "/emojis/1f1ee-1f1f3.png" + } + "bos" = { + name = "Boston, Massachusetts (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "cdg" = { + name = "Paris, France" + gateway = true + paid_only = false + icon = "/emojis/1f1eb-1f1f7.png" + } + "den" = { + name = "Denver, Colorado (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "dfw" = { + name = "Dallas, Texas (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "ewr" = { + name = "Secaucus, NJ (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "eze" = { + name = "Ezeiza, Argentina" + gateway = false + paid_only = false + icon = "/emojis/1f1e6-1f1f7.png" + } + "fra" = { + name = "Frankfurt, Germany" + gateway = true + paid_only = true + icon = "/emojis/1f1e9-1f1ea.png" + } + "gdl" = { + name = "Guadalajara, Mexico" + gateway = false + paid_only = false + icon = "/emojis/1f1f2-1f1fd.png" + } + "gig" = { + name = "Rio de Janeiro, Brazil" + gateway = false + paid_only = false + icon = "/emojis/1f1e7-1f1f7.png" + } + "gru" = { + name = "Sao Paulo, Brazil" + gateway = false + paid_only = false + icon = "/emojis/1f1e7-1f1f7.png" + } + "hkg" = { + name = "Hong Kong, Hong Kong" + gateway = true + paid_only = false + icon = "/emojis/1f1ed-1f1f0.png" + } + "iad" = { + name = "Ashburn, Virginia (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "jnb" = { + name = "Johannesburg, South Africa" + gateway = false + paid_only = false + icon = "/emojis/1f1ff-1f1e6.png" + } + "lax" = { + name = "Los Angeles, California (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "lhr" = { + name = "London, United Kingdom" + gateway = true + paid_only = false + icon = "/emojis/1f1ec-1f1e7.png" + } + "mad" = { + name = "Madrid, Spain" + gateway = false + paid_only = false + icon = "/emojis/1f1ea-1f1f8.png" + } + "mia" = { + name = "Miami, Florida (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "nrt" = { + name = "Tokyo, Japan" + gateway = true + paid_only = false + icon = "/emojis/1f1ef-1f1f5.png" + } + "ord" = { + name = "Chicago, Illinois (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "otp" = { + name = "Bucharest, Romania" + gateway = false + paid_only = false + icon = "/emojis/1f1f7-1f1f4.png" + } + "phx" = { + name = "Phoenix, Arizona (US)" + gateway = false + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "qro" = { + name = "Querétaro, Mexico" + gateway = false + paid_only = false + icon = "/emojis/1f1f2-1f1fd.png" + } + "scl" = { + name = "Santiago, Chile" + gateway = true + paid_only = false + icon = "/emojis/1f1e8-1f1f1.png" + } + "sea" = { + name = "Seattle, Washington (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "sin" = { + name = "Singapore, Singapore" + gateway = true + paid_only = false + icon = "/emojis/1f1f8-1f1ec.png" + } + "sjc" = { + name = "San Jose, California (US)" + gateway = true + paid_only = false + icon = "/emojis/1f1fa-1f1f8.png" + } + "syd" = { + name = "Sydney, Australia" + gateway = true + paid_only = false + icon = "/emojis/1f1e6-1f1fa.png" + } + "waw" = { + name = "Warsaw, Poland" + gateway = false + paid_only = false + icon = "/emojis/1f1f5-1f1f1.png" + } + "yul" = { + name = "Montreal, Canada" + gateway = false + paid_only = false + icon = "/emojis/1f1e8-1f1e6.png" + } + "yyz" = { + name = "Toronto, Canada" + gateway = true + paid_only = false + icon = "/emojis/1f1e8-1f1e6.png" + } + } +} + +data "coder_parameter" "fly_region" { + name = "flyio_region" + display_name = var.display_name + description = var.description + default = (var.default != null && var.default != "") && ((var.default != null ? contains(var.regions, var.default) : false) || length(var.regions) == 0) ? var.default : null + mutable = var.mutable + dynamic "option" { + for_each = { for k, v in local.regions : k => v if anytrue([for d in var.regions : k == d]) || length(var.regions) == 0 } + content { + name = try(var.custom_names[option.key], option.value.name) + icon = try(var.custom_icons[option.key], option.value.icon) + value = option.key + } + } +} + +output "value" { + value = data.coder_parameter.fly_region.value +} \ No newline at end of file diff --git a/registry/coder/modules/gcp-region/README.md b/registry/coder/modules/gcp-region/README.md new file mode 100644 index 0000000..ff469d4 --- /dev/null +++ b/registry/coder/modules/gcp-region/README.md @@ -0,0 +1,81 @@ +--- +display_name: GCP Region +description: Add Google Cloud Platform regions to your Coder template. +icon: ../../../../.icons/gcp.svg +maintainer_github: coder +verified: true +tags: [gcp, regions, parameter, helper] +--- + +# Google Cloud Platform Regions + +This module adds Google Cloud Platform regions to your Coder template. + +```tf +module "gcp_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/gcp-region/coder" + version = "1.0.12" + regions = ["us", "europe"] +} + +resource "google_compute_instance" "example" { + zone = module.gcp_region.value +} +``` + +![GCP Regions](../../.images/gcp-regions.png) + +## Examples + +### Add only GPU zones in the US West 1 region + +Note: setting `gpu_only = true` and using a default region without GPU support, the default will be set to `null`. + +```tf +module "gcp_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/gcp-region/coder" + version = "1.0.12" + default = ["us-west1-a"] + regions = ["us-west1"] + gpu_only = false +} + +resource "google_compute_instance" "example" { + zone = module.gcp_region.value +} +``` + +### Add all zones in the Europe West region + +```tf +module "gcp_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/gcp-region/coder" + version = "1.0.12" + regions = ["europe-west"] + single_zone_per_region = false +} + +resource "google_compute_instance" "example" { + zone = module.gcp_region.value +} +``` + +### Add a single zone from each region in US and Europe that has GPUs + +```tf +module "gcp_region" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/gcp-region/coder" + version = "1.0.12" + regions = ["us", "europe"] + gpu_only = true + single_zone_per_region = true +} + +resource "google_compute_instance" "example" { + zone = module.gcp_region.value +} +``` diff --git a/registry/coder/modules/gcp-region/main.test.ts b/registry/coder/modules/gcp-region/main.test.ts new file mode 100644 index 0000000..3acfaa2 --- /dev/null +++ b/registry/coder/modules/gcp-region/main.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("gcp-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + regions: '["asia"]', + default: "asia-east1-a", + }); + expect(state.outputs.value.value).toBe("asia-east1-a"); + }); + + it("gpu only invalid default", async () => { + const state = await runTerraformApply(import.meta.dir, { + regions: '["us-west2"]', + default: "us-west2-a", + gpu_only: "true", + }); + expect(state.outputs.value.value).toBe(""); + }); + + it("gpu only valid default", async () => { + const state = await runTerraformApply(import.meta.dir, { + regions: '["us-west2"]', + default: "us-west2-b", + gpu_only: "true", + }); + expect(state.outputs.value.value).toBe("us-west2-b"); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/coder/modules/gcp-region/main.tf b/registry/coder/modules/gcp-region/main.tf new file mode 100644 index 0000000..0a75924 --- /dev/null +++ b/registry/coder/modules/gcp-region/main.tf @@ -0,0 +1,748 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.11" + } + } +} + +variable "display_name" { + default = "GCP Region" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "The region to deploy workspace infrastructure." + description = "The description of the parameter." + type = string +} + +variable "default" { + default = null + description = "Default zone" + type = string +} + +variable "regions" { + description = "List of GCP regions to include." + type = list(string) + default = ["us-central1"] +} + +variable "gpu_only" { + description = "Whether to only include zones with GPUs." + type = bool + default = false +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "single_zone_per_region" { + default = true + description = "Whether to only include a single zone per region." + type = bool +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + zones = { + # US Central + "us-central1-a" = { + gpu = true + name = "Council Bluffs, Iowa, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-central1-b" = { + gpu = true + name = "Council Bluffs, Iowa, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-central1-c" = { + gpu = true + name = "Council Bluffs, Iowa, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-central1-f" = { + gpu = true + name = "Council Bluffs, Iowa, USA (f)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + # US East + "us-east1-b" = { + gpu = true + name = "Moncks Corner, S. Carolina, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east1-c" = { + gpu = true + name = "Moncks Corner, S. Carolina, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east1-d" = { + gpu = true + name = "Moncks Corner, S. Carolina, USA (d)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-east4-a" = { + gpu = true + name = "Ashburn, Virginia, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east4-b" = { + gpu = true + name = "Ashburn, Virginia, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east4-c" = { + gpu = true + name = "Ashburn, Virginia, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-east5-a" = { + gpu = false + name = "Columbus, Ohio, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east5-b" = { + gpu = true + name = "Columbus, Ohio, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-east5-c" = { + gpu = false + name = "Columbus, Ohio, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + # Us West + "us-west1-a" = { + gpu = true + name = "The Dalles, Oregon, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west1-b" = { + gpu = true + name = "The Dalles, Oregon, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west1-c" = { + gpu = false + name = "The Dalles, Oregon, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-west2-a" = { + gpu = false + name = "Los Angeles, California, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west2-b" = { + gpu = true + name = "Los Angeles, California, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west2-c" = { + gpu = true + name = "Los Angeles, California, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-west3-a" = { + gpu = true + name = "Salt Lake City, Utah, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west3-b" = { + gpu = true + name = "Salt Lake City, Utah, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west3-c" = { + gpu = true + name = "Salt Lake City, Utah, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + "us-west4-a" = { + gpu = true + name = "Las Vegas, Nevada, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west4-b" = { + gpu = true + name = "Las Vegas, Nevada, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-west4-c" = { + gpu = true + name = "Las Vegas, Nevada, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + # US South + "us-south1-a" = { + gpu = false + name = "Dallas, Texas, USA (a)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-south1-b" = { + gpu = false + name = "Dallas, Texas, USA (b)" + icon = "/emojis/1f1fa-1f1f8.png" + } + "us-south1-c" = { + gpu = false + name = "Dallas, Texas, USA (c)" + icon = "/emojis/1f1fa-1f1f8.png" + } + + # Canada + "northamerica-northeast1-a" = { + gpu = true + name = "Montréal, Québec, Canada (a)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "northamerica-northeast1-b" = { + gpu = true + name = "Montréal, Québec, Canada (b)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "northamerica-northeast1-c" = { + gpu = true + name = "Montréal, Québec, Canada (c)" + icon = "/emojis/1f1e8-1f1e6.png" + } + + "northamerica-northeast2-a" = { + gpu = false + name = "Toronto, Ontario, Canada (a)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "northamerica-northeast2-b" = { + gpu = false + name = "Toronto, Ontario, Canada (b)" + icon = "/emojis/1f1e8-1f1e6.png" + } + "northamerica-northeast2-c" = { + gpu = false + name = "Toronto, Ontario, Canada (c)" + icon = "/emojis/1f1e8-1f1e6.png" + } + + # South America East (Brazil, Chile) + "southamerica-east1-a" = { + gpu = true + name = "Osasco, São Paulo, Brazil (a)" + icon = "/emojis/1f1e7-1f1f7.png" + } + "southamerica-east1-b" = { + gpu = false + name = "Osasco, São Paulo, Brazil (b)" + icon = "/emojis/1f1e7-1f1f7.png" + } + "southamerica-east1-c" = { + gpu = true + name = "Osasco, São Paulo, Brazil (c)" + icon = "/emojis/1f1e7-1f1f7.png" + } + + "southamerica-west1-a" = { + gpu = false + name = "Santiago, Chile (a)" + icon = "/emojis/1f1e8-1f1f1.png" + } + "southamerica-west1-b" = { + gpu = false + name = "Santiago, Chile (b)" + icon = "/emojis/1f1e8-1f1f1.png" + } + "southamerica-west1-c" = { + gpu = false + name = "Santiago, Chile (c)" + icon = "/emojis/1f1e8-1f1f1.png" + } + + # Europe North (Finland) + "europe-north1-a" = { + gpu = false + name = "Hamina, Finland (a)" + icon = "/emojis/1f1e7-1f1ee.png" + } + "europe-north1-b" = { + gpu = false + name = "Hamina, Finland (b)" + icon = "/emojis/1f1e7-1f1ee.png" + } + "europe-north1-c" = { + gpu = false + name = "Hamina, Finland (c)" + icon = "/emojis/1f1e7-1f1ee.png" + } + + # Europe Central (Poland) + "europe-central2-a" = { + gpu = false + name = "Warsaw, Poland (a)" + icon = "/emojis/1f1f5-1f1f1.png" + } + "europe-central2-b" = { + gpu = true + name = "Warsaw, Poland (b)" + icon = "/emojis/1f1f5-1f1f1.png" + } + "europe-central2-c" = { + gpu = true + name = "Warsaw, Poland (c)" + icon = "/emojis/1f1f5-1f1f1.png" + } + + # Europe Southwest (Spain) + "europe-southwest1-a" = { + gpu = false + name = "Madrid, Spain (a)" + icon = "/emojis/1f1ea-1f1f8.png" + } + "europe-southwest1-b" = { + gpu = false + name = "Madrid, Spain (b)" + icon = "/emojis/1f1ea-1f1f8.png" + } + "europe-southwest1-c" = { + gpu = false + name = "Madrid, Spain (c)" + icon = "/emojis/1f1ea-1f1f8.png" + } + + # Europe West + "europe-west1-b" = { + gpu = true + name = "St. Ghislain, Belgium (b)" + icon = "/emojis/1f1e7-1f1ea.png" + } + "europe-west1-c" = { + gpu = true + name = "St. Ghislain, Belgium (c)" + icon = "/emojis/1f1e7-1f1ea.png" + } + "europe-west1-d" = { + gpu = true + name = "St. Ghislain, Belgium (d)" + icon = "/emojis/1f1e7-1f1ea.png" + } + + "europe-west2-a" = { + gpu = true + name = "London, England (a)" + icon = "/emojis/1f1ec-1f1e7.png" + } + "europe-west2-b" = { + gpu = true + name = "London, England (b)" + icon = "/emojis/1f1ec-1f1e7.png" + } + "europe-west2-c" = { + gpu = false + name = "London, England (c)" + icon = "/emojis/1f1ec-1f1e7.png" + } + + "europe-west3-b" = { + gpu = false + name = "Frankfurt, Germany (b)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "europe-west3-c" = { + gpu = true + name = "Frankfurt, Germany (c)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "europe-west3-d" = { + gpu = false + name = "Frankfurt, Germany (d)" + icon = "/emojis/1f1e9-1f1ea.png" + } + + "europe-west4-a" = { + gpu = true + name = "Eemshaven, Netherlands (a)" + icon = "/emojis/1f1f3-1f1f1.png" + } + "europe-west4-b" = { + gpu = true + name = "Eemshaven, Netherlands (b)" + icon = "/emojis/1f1f3-1f1f1.png" + } + "europe-west4-c" = { + gpu = true + name = "Eemshaven, Netherlands (c)" + icon = "/emojis/1f1f3-1f1f1.png" + } + + "europe-west6-a" = { + gpu = false + name = "Zurich, Switzerland (a)" + icon = "/emojis/1f1e8-1f1ed.png" + } + "europe-west6-b" = { + gpu = false + name = "Zurich, Switzerland (b)" + icon = "/emojis/1f1e8-1f1ed.png" + } + "europe-west6-c" = { + gpu = false + name = "Zurich, Switzerland (c)" + icon = "/emojis/1f1e8-1f1ed.png" + } + + "europe-west8-a" = { + gpu = false + name = "Milan, Italy (a)" + icon = "/emojis/1f1ee-1f1f9.png" + } + "europe-west8-b" = { + gpu = false + name = "Milan, Italy (b)" + icon = "/emojis/1f1ee-1f1f9.png" + } + "europe-west8-c" = { + gpu = false + name = "Milan, Italy (c)" + icon = "/emojis/1f1ee-1f1f9.png" + } + + "europe-west9-a" = { + gpu = false + name = "Paris, France (a)" + icon = "/emojis/1f1eb-1f1f7.png" + } + "europe-west9-b" = { + gpu = false + name = "Paris, France (b)" + icon = "/emojis/1f1eb-1f1f7.png" + } + "europe-west9-c" = { + gpu = false + name = "Paris, France (c)" + icon = "/emojis/1f1eb-1f1f7.png" + } + + "europe-west10-a" = { + gpu = false + name = "Berlin, Germany (a)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "europe-west10-b" = { + gpu = false + name = "Berlin, Germany (b)" + icon = "/emojis/1f1e9-1f1ea.png" + } + "europe-west10-c" = { + gpu = false + name = "Berlin, Germany (c)" + icon = "/emojis/1f1e9-1f1ea.png" + } + + "europe-west12-a" = { + gpu = false + name = "Turin, Italy (a)" + icon = "/emojis/1f1ee-1f1f9.png" + } + "europe-west12-b" = { + gpu = false + name = "Turin, Italy (b)" + icon = "/emojis/1f1ee-1f1f9.png" + } + "europe-west12-c" = { + gpu = false + name = "Turin, Italy (c)" + icon = "/emojis/1f1ee-1f1f9.png" + } + + # Middleeast Central (Qatar, Saudi Arabia) + "me-central1-a" = { + gpu = false + name = "Doha, Qatar (a)" + icon = "/emojis/1f1f6-1f1e6.png" + } + "me-central1-b" = { + gpu = false + name = "Doha, Qatar (b)" + icon = "/emojis/1f1f6-1f1e6.png" + } + "me-central1-c" = { + gpu = false + name = "Doha, Qatar (c)" + icon = "/emojis/1f1f6-1f1e6.png" + } + + "me-central2-a" = { + gpu = false + name = "Dammam, Saudi Arabia (a)" + icon = "/emojis/1f1f8-1f1e6.png" + } + "me-central2-b" = { + gpu = false + name = "Dammam, Saudi Arabia (b)" + icon = "/emojis/1f1f8-1f1e6.png" + } + "me-central2-c" = { + gpu = false + name = "Dammam, Saudi Arabia (c)" + icon = "/emojis/1f1f8-1f1e6.png" + } + + # Middleeast West (Israel) + "me-west1-a" = { + gpu = false + name = "Tel Aviv, Israel (a)" + icon = "/emojis/1f1ee-1f1f1.png" + } + "me-west1-b" = { + gpu = true + name = "Tel Aviv, Israel (b)" + icon = "/emojis/1f1ee-1f1f1.png" + } + "me-west1-c" = { + gpu = true + name = "Tel Aviv, Israel (c)" + icon = "/emojis/1f1ee-1f1f1.png" + } + + # Asia East (Taiwan, Hong Kong) + "asia-east1-a" = { + gpu = true + name = "Changhua County, Taiwan (a)" + icon = "/emojis/1f1f9-1f1fc.png" + } + "asia-east1-b" = { + gpu = true + name = "Changhua County, Taiwan (b)" + icon = "/emojis/1f1f9-1f1fc.png" + } + "asia-east1-c" = { + gpu = true + name = "Changhua County, Taiwan (c)" + icon = "/emojis/1f1f9-1f1fc.png" + } + + "asia-east2-a" = { + gpu = true + name = "Hong Kong (a)" + icon = "/emojis/1f1ed-1f1f0.png" + } + "asia-east2-b" = { + gpu = false + name = "Hong Kong (b)" + icon = "/emojis/1f1ed-1f1f0.png" + } + "asia-east2-c" = { + gpu = true + name = "Hong Kong (c)" + icon = "/emojis/1f1ed-1f1f0.png" + } + + # Asia Northeast (Japan, South Korea) + "asia-northeast1-a" = { + gpu = true + name = "Tokyo, Japan (a)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast1-b" = { + gpu = false + name = "Tokyo, Japan (b)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast1-c" = { + gpu = true + name = "Tokyo, Japan (c)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast2-a" = { + gpu = false + name = "Osaka, Japan (a)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast2-b" = { + gpu = false + name = "Osaka, Japan (b)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast2-c" = { + gpu = false + name = "Osaka, Japan (c)" + icon = "/emojis/1f1ef-1f1f5.png" + } + "asia-northeast3-a" = { + gpu = true + name = "Seoul, South Korea (a)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "asia-northeast3-b" = { + gpu = true + name = "Seoul, South Korea (b)" + icon = "/emojis/1f1f0-1f1f7.png" + } + "asia-northeast3-c" = { + gpu = true + name = "Seoul, South Korea (c)" + icon = "/emojis/1f1f0-1f1f7.png" + } + + # Asia South (India) + "asia-south1-a" = { + gpu = true + name = "Mumbai, India (a)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south1-b" = { + gpu = true + name = "Mumbai, India (b)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south1-c" = { + gpu = false + name = "Mumbai, India (c)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south2-a" = { + gpu = false + name = "Delhi, India (a)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south2-b" = { + gpu = false + name = "Delhi, India (b)" + icon = "/emojis/1f1ee-1f1f3.png" + } + "asia-south2-c" = { + gpu = false + name = "Delhi, India (c)" + icon = "/emojis/1f1ee-1f1f3.png" + } + + # Asia Southeast (Singapore, Indonesia) + "asia-southeast1-a" = { + gpu = true + name = "Jurong West, Singapore (a)" + icon = "/emojis/1f1f8-1f1ec.png" + } + "asia-southeast1-b" = { + gpu = true + name = "Jurong West, Singapore (b)" + icon = "/emojis/1f1f8-1f1ec.png" + } + "asia-southeast1-c" = { + gpu = true + name = "Jurong West, Singapore (c)" + icon = "/emojis/1f1f8-1f1ec.png" + } + "asia-southeast2-a" = { + gpu = true + name = "Jakarta, Indonesia (a)" + icon = "/emojis/1f1ee-1f1e9.png" + } + "asia-southeast2-b" = { + gpu = true + name = "Jakarta, Indonesia (b)" + icon = "/emojis/1f1ee-1f1e9.png" + } + "asia-southeast2-c" = { + gpu = true + name = "Jakarta, Indonesia (c)" + icon = "/emojis/1f1ee-1f1e9.png" + } + + # Australia (Sydney, Melbourne) + "australia-southeast1-a" = { + gpu = true + name = "Sydney, Australia (a)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast1-b" = { + gpu = true + name = "Sydney, Australia (b)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast1-c" = { + gpu = true + name = "Sydney, Australia (c)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast2-a" = { + gpu = false + name = "Melbourne, Australia (a)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast2-b" = { + gpu = false + name = "Melbourne, Australia (b)" + icon = "/emojis/1f1e6-1f1fa.png" + } + "australia-southeast2-c" = { + gpu = false + name = "Melbourne, Australia (c)" + icon = "/emojis/1f1e6-1f1fa.png" + } + } +} + +data "coder_parameter" "region" { + name = "gcp_region" + display_name = var.display_name + description = var.description + icon = "/icon/gcp.png" + mutable = var.mutable + default = var.default != null && var.default != "" && (!var.gpu_only || try(local.zones[var.default].gpu, false)) ? var.default : null + order = var.coder_parameter_order + dynamic "option" { + for_each = { + for k, v in local.zones : k => v + if anytrue([for d in var.regions : startswith(k, d)]) && (!var.gpu_only || v.gpu) && (!var.single_zone_per_region || endswith(k, "-a")) + } + content { + icon = try(var.custom_icons[option.key], option.value.icon) + # if single_zone_per_region is true, remove the zone letter from the name + name = try(var.custom_names[option.key], var.single_zone_per_region ? substr(option.value.name, 0, length(option.value.name) - 4) : option.value.name) + description = option.key + value = option.key + } + } +} + +output "value" { + description = "GCP zone identifier." + value = data.coder_parameter.region.value +} + +output "region" { + description = "GCP region identifier." + value = substr(data.coder_parameter.region.value, 0, length(data.coder_parameter.region.value) - 2) +} diff --git a/registry/coder/modules/git-clone/README.md b/registry/coder/modules/git-clone/README.md new file mode 100644 index 0000000..1de7413 --- /dev/null +++ b/registry/coder/modules/git-clone/README.md @@ -0,0 +1,182 @@ +--- +display_name: Git Clone +description: Clone a Git repository by URL and skip if it exists. +icon: ../../../../.icons/git.svg +maintainer_github: coder +verified: true +tags: [git, helper] +--- + +# Git Clone + +This module allows you to automatically clone a repository by URL and skip if it exists in the base directory provided. + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" +} +``` + +## Examples + +### Custom Path + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" + base_dir = "~/projects/coder" +} +``` + +### Git Authentication + +To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-providers), add the provider by ID to your template: + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" +} + +data "coder_git_auth" "github" { + id = "github" +} +``` + +## GitHub clone with branch name + +To GitHub clone with a specific branch like `feat/example` + +```tf +# Prompt the user for the git repo URL +data "coder_parameter" "git_repo" { + name = "git_repo" + display_name = "Git repository" + default = "https://github.com/coder/coder/tree/feat/example" +} + +# Clone the repository for branch `feat/example` +module "git_clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = data.coder_parameter.git_repo.value +} + +# Create a code-server instance for the cloned repository +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + order = 1 + folder = "/home/${local.username}/${module.git_clone[count.index].folder_name}" +} + +# Create a Coder app for the website +resource "coder_app" "website" { + count = data.coder_workspace.me.start_count + agent_id = coder_agent.example.id + order = 2 + slug = "website" + external = true + display_name = module.git_clone[count.index].folder_name + url = module.git_clone[count.index].web_url + icon = module.git_clone[count.index].git_provider != "" ? "/icon/${module.git_clone[count.index].git_provider}.svg" : "/icon/git.svg" +} +``` + +Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `github.example.com` + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.example.com/coder/coder/tree/feat/example" + git_providers = { + "https://github.example.com/" = { + provider = "github" + } + } +} +``` + +## GitLab clone with branch name + +To GitLab clone with a specific branch like `feat/example` + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://gitlab.com/coder/coder/-/tree/feat/example" +} +``` + +Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com` + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" + git_providers = { + "https://gitlab.example.com/" = { + provider = "gitlab" + } + } +} +``` + +## Git clone with branch_name set + +Alternatively, you can set the `branch_name` attribute to clone a specific branch. + +For example, to clone the `feat/example` branch: + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" + branch_name = "feat/example" +} +``` + +## Git clone with different destination folder + +By default, the repository will be cloned into a folder matching the repository name. You can use the `folder_name` attribute to change the name of the destination folder to something else. + +For example, this will clone into the `~/projects/coder/coder-dev` folder: + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-clone/coder" + version = "1.0.18" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" + folder_name = "coder-dev" + base_dir = "~/projects/coder" +} +``` diff --git a/registry/coder/modules/git-clone/main.test.ts b/registry/coder/modules/git-clone/main.test.ts new file mode 100644 index 0000000..1e074fc --- /dev/null +++ b/registry/coder/modules/git-clone/main.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("git-clone", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + url: "foo", + }); + + it("fails without git", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "some-url", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(1); + expect(output.stdout).toEqual(["Git is not installed!"]); + }); + + it("runs with git", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "fake-url", + }); + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.exitCode).toBe(128); + expect(output.stdout).toEqual([ + "Creating directory ~/fake-url...", + "Cloning fake-url to ~/fake-url...", + ]); + }); + + it("repo_dir should match repo name for https", async () => { + const url = "https://github.com/coder/coder.git"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/coder"); + expect(state.outputs.folder_name.value).toEqual("coder"); + expect(state.outputs.clone_url.value).toEqual(url); + expect(state.outputs.web_url.value).toEqual(url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("repo_dir should match repo name for https without .git", async () => { + const url = "https://github.com/coder/coder"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/coder"); + expect(state.outputs.clone_url.value).toEqual(url); + expect(state.outputs.web_url.value).toEqual(url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("repo_dir should match repo name for ssh", async () => { + const url = "git@github.com:coder/coder.git"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/coder"); + expect(state.outputs.git_provider.value).toEqual(""); + expect(state.outputs.clone_url.value).toEqual(url); + const https_url = "https://github.com/coder/coder.git"; + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("repo_dir should match base_dir/folder_name", async () => { + const url = "git@github.com:coder/coder.git"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + folder_name: "foo", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/foo"); + expect(state.outputs.folder_name.value).toEqual("foo"); + expect(state.outputs.clone_url.value).toEqual(url); + const https_url = "https://github.com/coder/coder.git"; + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("branch_name should not include query string", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch?ref_type=heads", + }); + expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log"); + expect(state.outputs.folder_name.value).toEqual("repo-tests.log"); + const https_url = "https://gitlab.com/mike.brew/repo-tests.log"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/branch"); + }); + + it("branch_name should not include fragments", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch#name", + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log"); + const https_url = "https://gitlab.com/mike.brew/repo-tests.log"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/branch"); + }); + + it("gitlab url with branch should match", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch", + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log"); + expect(state.outputs.git_provider.value).toEqual("gitlab"); + const https_url = "https://gitlab.com/mike.brew/repo-tests.log"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/branch"); + }); + + it("github url with branch should match", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch", + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log"); + expect(state.outputs.git_provider.value).toEqual("github"); + const https_url = "https://github.com/michaelbrewer/repo-tests.log"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/branch"); + }); + + it("self-host git url with branch should match", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url: "https://git.example.com/example/project/-/tree/feat/example", + git_providers: ` + { + "https://git.example.com/" = { + provider = "gitlab" + } + }`, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/project"); + expect(state.outputs.git_provider.value).toEqual("gitlab"); + const https_url = "https://git.example.com/example/project"; + expect(state.outputs.clone_url.value).toEqual(https_url); + expect(state.outputs.web_url.value).toEqual(https_url); + expect(state.outputs.branch_name.value).toEqual("feat/example"); + }); + + it("handle unsupported git provider configuration", async () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "foo", + git_providers: ` + { + "https://git.example.com/" = { + provider = "bitbucket" + } + }`, + }); + }; + expect(t).toThrow('Allowed values for provider are "github" or "gitlab".'); + }); + + it("handle unknown git provider url", async () => { + const url = "https://git.unknown.com/coder/coder"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + base_dir: "/tmp", + url, + }); + expect(state.outputs.repo_dir.value).toEqual("/tmp/coder"); + expect(state.outputs.clone_url.value).toEqual(url); + expect(state.outputs.web_url.value).toEqual(url); + expect(state.outputs.branch_name.value).toEqual(""); + }); + + it("runs with github clone with switch to feat/branch", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch", + }); + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "Creating directory ~/repo-tests.log...", + "Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...", + ]); + }); + + it("runs with gitlab clone with switch to feat/branch", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch", + }); + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "Creating directory ~/repo-tests.log...", + "Cloning https://gitlab.com/mike.brew/repo-tests.log to ~/repo-tests.log on branch feat/branch...", + ]); + }); + + it("runs with github clone with branch_name set to feat/branch", async () => { + const url = "https://github.com/michaelbrewer/repo-tests.log"; + const branch_name = "feat/branch"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url, + branch_name, + }); + expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log"); + expect(state.outputs.clone_url.value).toEqual(url); + expect(state.outputs.web_url.value).toEqual(url); + expect(state.outputs.branch_name.value).toEqual(branch_name); + + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "Creating directory ~/repo-tests.log...", + "Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...", + ]); + }); +}); diff --git a/registry/coder/modules/git-clone/main.tf b/registry/coder/modules/git-clone/main.tf new file mode 100644 index 0000000..0295444 --- /dev/null +++ b/registry/coder/modules/git-clone/main.tf @@ -0,0 +1,121 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "url" { + description = "The URL of the Git repository." + type = string +} + +variable "base_dir" { + default = "" + description = "The base directory to clone the repository. Defaults to \"$HOME\"." + type = string +} + +variable "agent_id" { + description = "The ID of a Coder agent." + type = string +} + +variable "git_providers" { + type = map(object({ + provider = string + })) + description = "A mapping of URLs to their git provider." + default = { + "https://github.com/" = { + provider = "github" + }, + "https://gitlab.com/" = { + provider = "gitlab" + }, + } + validation { + error_message = "Allowed values for provider are \"github\" or \"gitlab\"." + condition = alltrue([for provider in var.git_providers : contains(["github", "gitlab"], provider.provider)]) + } +} + +variable "branch_name" { + description = "The branch name to clone. If not provided, the default branch will be cloned." + type = string + default = "" +} + +variable "folder_name" { + description = "The destination folder to clone the repository into." + type = string + default = "" +} + +locals { + # Remove query parameters and fragments from the URL + url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "") + + # Find the git provider based on the URL and determine the tree path + provider_key = try(one([for key in keys(var.git_providers) : key if startswith(local.url, key)]), null) + provider = try(lookup(var.git_providers, local.provider_key).provider, "") + tree_path = local.provider == "gitlab" ? "/-/tree/" : local.provider == "github" ? "/tree/" : "" + + # Remove tree and branch name from the URL + clone_url = var.branch_name == "" && local.tree_path != "" ? replace(local.url, "/${local.tree_path}.*/", "") : local.url + # Extract the branch name from the URL + branch_name = var.branch_name == "" && local.tree_path != "" ? replace(replace(local.url, local.clone_url, ""), "/.*${local.tree_path}/", "") : var.branch_name + # Extract the folder name from the URL + folder_name = var.folder_name == "" ? replace(basename(local.clone_url), ".git", "") : var.folder_name + # Construct the path to clone the repository + clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name]) + # Construct the web URL + web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url +} + +output "repo_dir" { + value = local.clone_path + description = "Full path of cloned repo directory" +} + +output "git_provider" { + value = local.provider + description = "The git provider of the repository" +} + +output "folder_name" { + value = local.folder_name + description = "The name of the folder that will be created" +} + +output "clone_url" { + value = local.clone_url + description = "The exact Git repository URL that will be cloned" +} + +output "web_url" { + value = local.web_url + description = "Git https repository URL (may be invalid for unsupported providers)" +} + +output "branch_name" { + value = local.branch_name + description = "Git branch name (may be empty)" +} + +resource "coder_script" "git_clone" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + CLONE_PATH = local.clone_path, + REPO_URL : local.clone_url, + BRANCH_NAME : local.branch_name, + }) + display_name = "Git Clone" + icon = "/icon/git.svg" + run_on_start = true + start_blocks_login = true +} diff --git a/registry/coder/modules/git-clone/run.sh b/registry/coder/modules/git-clone/run.sh new file mode 100644 index 0000000..bd80717 --- /dev/null +++ b/registry/coder/modules/git-clone/run.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +REPO_URL="${REPO_URL}" +CLONE_PATH="${CLONE_PATH}" +BRANCH_NAME="${BRANCH_NAME}" +# Expand home if it's specified! +CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}" + +# Check if the variable is empty... +if [ -z "$REPO_URL" ]; then + echo "No repository specified!" + exit 1 +fi + +# Check if the variable is empty... +if [ -z "$CLONE_PATH" ]; then + echo "No clone path specified!" + exit 1 +fi + +# Check if `git` is installed... +if ! command -v git > /dev/null; then + echo "Git is not installed!" + exit 1 +fi + +# Check if the directory for the cloning exists +# and if not, create it +if [ ! -d "$CLONE_PATH" ]; then + echo "Creating directory $CLONE_PATH..." + mkdir -p "$CLONE_PATH" +fi + +# Check if the directory is empty +# and if it is, clone the repo, otherwise skip cloning +if [ -z "$(ls -A "$CLONE_PATH")" ]; then + if [ -z "$BRANCH_NAME" ]; then + echo "Cloning $REPO_URL to $CLONE_PATH..." + git clone "$REPO_URL" "$CLONE_PATH" + else + echo "Cloning $REPO_URL to $CLONE_PATH on branch $BRANCH_NAME..." + git clone "$REPO_URL" -b "$BRANCH_NAME" "$CLONE_PATH" + fi +else + echo "$CLONE_PATH already exists and isn't empty, skipping clone!" + exit 0 +fi diff --git a/registry/coder/modules/git-commit-signing/README.md b/registry/coder/modules/git-commit-signing/README.md new file mode 100644 index 0000000..16d003f --- /dev/null +++ b/registry/coder/modules/git-commit-signing/README.md @@ -0,0 +1,29 @@ +--- +display_name: Git commit signing +description: Configures Git to sign commits using your Coder SSH key +icon: ../../../../.icons/git.svg +maintainer_github: coder +verified: true +tags: [helper, git] +--- + +# git-commit-signing + +> [!IMPORTANT] +> This module will only work with Git versions >=2.34, prior versions [do not support signing commits via SSH keys](https://lore.kernel.org/git/xmqq8rxpgwki.fsf@gitster.g/). + +This module downloads your SSH key from Coder and uses it to sign commits with Git. +It requires `curl` and `jq` to be installed inside your workspace. + +Please observe that using the SSH key that's part of your Coder account for commit signing, means that in the event of a breach of your Coder account, or a malicious admin, someone could perform commit signing pretending to be you. + +This module has a chance of conflicting with the user's dotfiles / the personalize module if one of those has configuration directives that overwrite this module's / each other's git configuration. + +```tf +module "git-commit-signing" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-commit-signing/coder" + version = "1.0.11" + agent_id = coder_agent.example.id +} +``` diff --git a/registry/coder/modules/git-commit-signing/main.tf b/registry/coder/modules/git-commit-signing/main.tf new file mode 100644 index 0000000..7c8cd3b --- /dev/null +++ b/registry/coder/modules/git-commit-signing/main.tf @@ -0,0 +1,25 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +resource "coder_script" "git-commit-signing" { + display_name = "Git commit signing" + icon = "/icon/git.svg" + + script = file("${path.module}/run.sh") + run_on_start = true + + agent_id = var.agent_id +} diff --git a/registry/coder/modules/git-commit-signing/run.sh b/registry/coder/modules/git-commit-signing/run.sh new file mode 100644 index 0000000..c0e0faa --- /dev/null +++ b/registry/coder/modules/git-commit-signing/run.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env sh + +if ! command -v git > /dev/null; then + echo "git is not installed" + exit 1 +fi + +if ! command -v curl > /dev/null; then + echo "curl is not installed" + exit 1 +fi + +if ! command -v jq > /dev/null; then + echo "jq is not installed" + exit 1 +fi + +mkdir -p ~/.ssh/git-commit-signing + +echo "Downloading SSH key" + +ssh_key=$(curl --request GET \ + --url "${CODER_AGENT_URL}api/v2/workspaceagents/me/gitsshkey" \ + --header "Coder-Session-Token: ${CODER_AGENT_TOKEN}" \ + --silent --show-error) + +jq --raw-output ".public_key" > ~/.ssh/git-commit-signing/coder.pub << EOF +$ssh_key +EOF + +jq --raw-output ".private_key" > ~/.ssh/git-commit-signing/coder << EOF +$ssh_key +EOF + +chmod -R 600 ~/.ssh/git-commit-signing/coder +chmod -R 644 ~/.ssh/git-commit-signing/coder.pub + +echo "Configuring git to use the SSH key" + +git config --global gpg.format ssh +git config --global commit.gpgsign true +git config --global user.signingkey ~/.ssh/git-commit-signing/coder diff --git a/registry/coder/modules/git-config/README.md b/registry/coder/modules/git-config/README.md new file mode 100644 index 0000000..7744590 --- /dev/null +++ b/registry/coder/modules/git-config/README.md @@ -0,0 +1,52 @@ +--- +display_name: Git Config +description: Stores Git configuration from Coder credentials +icon: ../../../../.icons/git.svg +maintainer_github: coder +verified: true +tags: [helper, git] +--- + +# git-config + +Runs a script that updates git credentials in the workspace to match the user's Coder credentials, optionally allowing to the developer to override the defaults. + +```tf +module "git-config" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-config/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} +``` + +TODO: Add screenshot + +## Examples + +### Allow users to override both username and email + +```tf +module "git-config" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-config/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + allow_email_change = true +} +``` + +TODO: Add screenshot + +## Disallowing users from overriding both username and email + +```tf +module "git-config" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/git-config/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + allow_username_change = false + allow_email_change = false +} +``` diff --git a/registry/coder/modules/git-config/main.test.ts b/registry/coder/modules/git-config/main.test.ts new file mode 100644 index 0000000..90f48c0 --- /dev/null +++ b/registry/coder/modules/git-config/main.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("git-config", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("can run apply allow_username_change and allow_email_change disabled", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_username_change: "false", + allow_email_change: "false", + }); + + const resources = state.resources; + expect(resources).toHaveLength(6); + expect(resources).toMatchObject([ + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + }); + + it("can run apply allow_email_change enabled", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_email_change: "true", + }); + + const resources = state.resources; + expect(resources).toHaveLength(8); + expect(resources).toMatchObject([ + { type: "coder_parameter", name: "user_email" }, + { type: "coder_parameter", name: "username" }, + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + }); + + it("can run apply allow_email_change enabled", async () => { + const state = await runTerraformApply( + import.meta.dir, + { + agent_id: "foo", + allow_username_change: "false", + allow_email_change: "false", + }, + { CODER_WORKSPACE_OWNER_EMAIL: "foo@email.com" }, + ); + + const resources = state.resources; + expect(resources).toHaveLength(6); + expect(resources).toMatchObject([ + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + }); + + it("set custom order for coder_parameter for both fields", async () => { + const order = 20; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_username_change: "true", + allow_email_change: "true", + coder_parameter_order: order.toString(), + }); + const resources = state.resources; + expect(resources).toHaveLength(8); + expect(resources).toMatchObject([ + { type: "coder_parameter", name: "user_email" }, + { type: "coder_parameter", name: "username" }, + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + // user_email order is the same as the order + expect(resources[0].instances[0].attributes.order).toBe(order); + // username order is incremented by 1 + // @ts-ignore: Object is possibly 'null'. + expect(resources[1].instances[0]?.attributes.order).toBe(order + 1); + }); + + it("set custom order for coder_parameter for just username", async () => { + const order = 30; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_email_change: "false", + allow_username_change: "true", + coder_parameter_order: order.toString(), + }); + const resources = state.resources; + expect(resources).toHaveLength(7); + expect(resources).toMatchObject([ + { type: "coder_parameter", name: "username" }, + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + // user_email was not created + // username order is incremented by 1 + expect(resources[0].instances[0].attributes.order).toBe(order + 1); + }); +}); diff --git a/registry/coder/modules/git-config/main.tf b/registry/coder/modules/git-config/main.tf new file mode 100644 index 0000000..e8fea8f --- /dev/null +++ b/registry/coder/modules/git-config/main.tf @@ -0,0 +1,84 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "allow_username_change" { + type = bool + description = "Allow developers to change their git username." + default = true +} + +variable "allow_email_change" { + type = bool + description = "Allow developers to change their git email." + default = false +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "user_email" { + count = var.allow_email_change ? 1 : 0 + name = "user_email" + type = "string" + default = "" + order = var.coder_parameter_order != null ? var.coder_parameter_order + 0 : null + description = "Git user.email to be used for commits. Leave empty to default to Coder user's email." + display_name = "Git config user.email" + mutable = true +} + +data "coder_parameter" "username" { + count = var.allow_username_change ? 1 : 0 + name = "username" + type = "string" + default = "" + order = var.coder_parameter_order != null ? var.coder_parameter_order + 1 : null + description = "Git user.name to be used for commits. Leave empty to default to Coder user's Full Name." + display_name = "Full Name for Git config" + mutable = true +} + +resource "coder_env" "git_author_name" { + agent_id = var.agent_id + name = "GIT_AUTHOR_NAME" + value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) +} + +resource "coder_env" "git_commmiter_name" { + agent_id = var.agent_id + name = "GIT_COMMITTER_NAME" + value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) +} + +resource "coder_env" "git_author_email" { + agent_id = var.agent_id + name = "GIT_AUTHOR_EMAIL" + value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace_owner.me.email) + count = data.coder_workspace_owner.me.email != "" ? 1 : 0 +} + +resource "coder_env" "git_commmiter_email" { + agent_id = var.agent_id + name = "GIT_COMMITTER_EMAIL" + value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace_owner.me.email) + count = data.coder_workspace_owner.me.email != "" ? 1 : 0 +} diff --git a/registry/coder/modules/github-upload-public-key/README.md b/registry/coder/modules/github-upload-public-key/README.md new file mode 100644 index 0000000..3659ade --- /dev/null +++ b/registry/coder/modules/github-upload-public-key/README.md @@ -0,0 +1,55 @@ +--- +display_name: Github Upload Public Key +description: Automates uploading Coder public key to Github so users don't have to. +icon: ../../../../.icons/github.svg +maintainer_github: coder +verified: true +tags: [helper, git] +--- + +# github-upload-public-key + +Templates that utilize Github External Auth can automatically ensure that the Coder public key is uploaded to Github so that users can clone repositories without needing to upload the public key themselves. + +```tf +module "github-upload-public-key" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/github-upload-public-key/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} +``` + +# Requirements + +This module requires `curl` and `jq` to be installed inside your workspace. + +Github External Auth must be enabled in the workspace for this module to work. The Github app that is configured for external auth must have both read and write permissions to "Git SSH keys" in order to upload the public key. Additionally, a Coder admin must also have the `admin:public_key` scope added to the external auth configuration of the Coder deployment. For example: + +``` +CODER_EXTERNAL_AUTH_0_ID="USER_DEFINED_ID" +CODER_EXTERNAL_AUTH_0_TYPE=github +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key" +``` + +Note that the default scopes if not provided are `repo,workflow`. If the module is failing to complete after updating the external auth configuration, instruct users of the module to "Unlink" and "Link" their Github account in the External Auth user settings page to get the new scopes. + +# Example + +Using a coder github external auth with a non-default id: (default is `github`) + +```tf +data "coder_external_auth" "github" { + id = "myauthid" +} + +module "github-upload-public-key" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/github-upload-public-key/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + external_auth_id = data.coder_external_auth.github.id +} +``` diff --git a/registry/coder/modules/github-upload-public-key/main.test.ts b/registry/coder/modules/github-upload-public-key/main.test.ts new file mode 100644 index 0000000..467d6b9 --- /dev/null +++ b/registry/coder/modules/github-upload-public-key/main.test.ts @@ -0,0 +1,132 @@ +import { type Server, serve } from "bun"; +import { describe, expect, it } from "bun:test"; +import { + createJSONResponse, + execContainer, + findResourceInstance, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + writeCoder, +} from "~test"; + +describe("github-upload-public-key", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("creates new key if one does not exist", async () => { + const { instance, id, server } = await setupContainer(); + await writeCoder(id, "echo foo"); + + const url = server.url.toString().slice(0, -1); + const exec = await execContainer(id, [ + "env", + `CODER_ACCESS_URL=${url}`, + `GITHUB_API_URL=${url}`, + "CODER_OWNER_SESSION_TOKEN=foo", + "CODER_EXTERNAL_AUTH_ID=github", + "bash", + "-c", + instance.script, + ]); + expect(exec.stdout).toContain( + "Your Coder public key has been added to GitHub!", + ); + expect(exec.exitCode).toBe(0); + // we need to increase timeout to pull the container + }, 15000); + + it("does nothing if one already exists", async () => { + const { instance, id, server } = await setupContainer(); + // use keyword to make server return a existing key + await writeCoder(id, "echo findkey"); + + const url = server.url.toString().slice(0, -1); + const exec = await execContainer(id, [ + "env", + `CODER_ACCESS_URL=${url}`, + `GITHUB_API_URL=${url}`, + "CODER_OWNER_SESSION_TOKEN=foo", + "CODER_EXTERNAL_AUTH_ID=github", + "bash", + "-c", + instance.script, + ]); + expect(exec.stdout).toContain( + "Your Coder public key is already on GitHub!", + ); + expect(exec.exitCode).toBe(0); + }); +}); + +const setupContainer = async ( + image = "lorello/alpine-bash", + vars: Record = {}, +) => { + const server = await setupServer(); + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ...vars, + }); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + return { id, instance, server }; +}; + +const setupServer = async (): Promise => { + let url: URL; + const fakeSlackHost = serve({ + fetch: (req) => { + url = new URL(req.url); + if (url.pathname === "/api/v2/users/me/gitsshkey") { + return createJSONResponse({ + public_key: "exists", + }); + } + + if (url.pathname === "/user/keys") { + if (req.method === "POST") { + return createJSONResponse( + { + key: "created", + }, + 201, + ); + } + + // case: key already exists + if (req.headers.get("Authorization") === "Bearer findkey") { + return createJSONResponse([ + { + key: "foo", + }, + { + key: "exists", + }, + ]); + } + + // case: key does not exist + return createJSONResponse([ + { + key: "foo", + }, + ]); + } + + return createJSONResponse( + { + error: "not_found", + }, + 404, + ); + }, + port: 0, + }); + + return fakeSlackHost; +}; diff --git a/registry/coder/modules/github-upload-public-key/main.tf b/registry/coder/modules/github-upload-public-key/main.tf new file mode 100644 index 0000000..b527400 --- /dev/null +++ b/registry/coder/modules/github-upload-public-key/main.tf @@ -0,0 +1,43 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "external_auth_id" { + type = string + description = "The ID of the GitHub external auth." + default = "github" +} + +variable "github_api_url" { + type = string + description = "The URL of the GitHub instance." + default = "https://api.github.com" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_script" "github_upload_public_key" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + CODER_OWNER_SESSION_TOKEN : data.coder_workspace_owner.me.session_token, + CODER_ACCESS_URL : data.coder_workspace.me.access_url, + CODER_EXTERNAL_AUTH_ID : var.external_auth_id, + GITHUB_API_URL : var.github_api_url, + }) + display_name = "Github Upload Public Key" + icon = "/icon/github.svg" + run_on_start = true +} \ No newline at end of file diff --git a/registry/coder/modules/github-upload-public-key/run.sh b/registry/coder/modules/github-upload-public-key/run.sh new file mode 100644 index 0000000..a382a40 --- /dev/null +++ b/registry/coder/modules/github-upload-public-key/run.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +if [ -z "$CODER_ACCESS_URL" ]; then + if [ -z "${CODER_ACCESS_URL}" ]; then + echo "CODER_ACCESS_URL is empty!" + exit 1 + fi + CODER_ACCESS_URL=${CODER_ACCESS_URL} +fi + +if [ -z "$CODER_OWNER_SESSION_TOKEN" ]; then + if [ -z "${CODER_OWNER_SESSION_TOKEN}" ]; then + echo "CODER_OWNER_SESSION_TOKEN is empty!" + exit 1 + fi + CODER_OWNER_SESSION_TOKEN=${CODER_OWNER_SESSION_TOKEN} +fi + +if [ -z "$CODER_EXTERNAL_AUTH_ID" ]; then + if [ -z "${CODER_EXTERNAL_AUTH_ID}" ]; then + echo "CODER_EXTERNAL_AUTH_ID is empty!" + exit 1 + fi + CODER_EXTERNAL_AUTH_ID=${CODER_EXTERNAL_AUTH_ID} +fi + +if [ -z "$GITHUB_API_URL" ]; then + if [ -z "${GITHUB_API_URL}" ]; then + echo "GITHUB_API_URL is empty!" + exit 1 + fi + GITHUB_API_URL=${GITHUB_API_URL} +fi + +echo "Fetching GitHub token..." +GITHUB_TOKEN=$(coder external-auth access-token $CODER_EXTERNAL_AUTH_ID) +if [ $? -ne 0 ]; then + printf "Authenticate with Github to automatically upload Coder public key:\n$GITHUB_TOKEN\n" + exit 1 +fi + +echo "Fetching public key from Coder..." +PUBLIC_KEY_RESPONSE=$( + curl -L -s \ + -w "\n%%{http_code}" \ + -H 'accept: application/json' \ + -H "cookie: coder_session_token=$CODER_OWNER_SESSION_TOKEN" \ + "$CODER_ACCESS_URL/api/v2/users/me/gitsshkey" +) +PUBLIC_KEY_RESPONSE_STATUS=$(tail -n1 <<< "$PUBLIC_KEY_RESPONSE") +PUBLIC_KEY_BODY=$(sed \$d <<< "$PUBLIC_KEY_RESPONSE") + +if [ "$PUBLIC_KEY_RESPONSE_STATUS" -ne 200 ]; then + echo "Failed to fetch Coder public SSH key with status code $PUBLIC_KEY_RESPONSE_STATUS!" + echo "$PUBLIC_KEY_BODY" + exit 1 +fi +PUBLIC_KEY=$(jq -r '.public_key' <<< "$PUBLIC_KEY_BODY") +if [ -z "$PUBLIC_KEY" ]; then + echo "No Coder public SSH key found!" + exit 1 +fi + +echo "Fetching public keys from GitHub..." +GITHUB_KEYS_RESPONSE=$( + curl -L -s \ + -w "\n%%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + $GITHUB_API_URL/user/keys +) +GITHUB_KEYS_RESPONSE_STATUS=$(tail -n1 <<< "$GITHUB_KEYS_RESPONSE") +GITHUB_KEYS_RESPONSE_BODY=$(sed \$d <<< "$GITHUB_KEYS_RESPONSE") + +if [ "$GITHUB_KEYS_RESPONSE_STATUS" -ne 200 ]; then + echo "Failed to fetch Coder public SSH key with status code $GITHUB_KEYS_RESPONSE_STATUS!" + echo "$GITHUB_KEYS_RESPONSE_BODY" + exit 1 +fi + +GITHUB_MATCH=$(jq -r --arg PUBLIC_KEY "$PUBLIC_KEY" '.[] | select(.key == $PUBLIC_KEY) | .key' <<< "$GITHUB_KEYS_RESPONSE_BODY") + +if [ "$PUBLIC_KEY" = "$GITHUB_MATCH" ]; then + echo "Your Coder public key is already on GitHub!" + exit 0 +fi + +echo "Your Coder public key is not in GitHub. Adding it now..." +CODER_PUBLIC_KEY_NAME="$CODER_ACCESS_URL Workspaces" +UPLOAD_RESPONSE=$( + curl -L -s \ + -X POST \ + -w "\n%%{http_code}" \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + $GITHUB_API_URL/user/keys \ + -d "{\"title\":\"$CODER_PUBLIC_KEY_NAME\",\"key\":\"$PUBLIC_KEY\"}" +) +UPLOAD_RESPONSE_STATUS=$(tail -n1 <<< "$UPLOAD_RESPONSE") +UPLOAD_RESPONSE_BODY=$(sed \$d <<< "$UPLOAD_RESPONSE") + +if [ "$UPLOAD_RESPONSE_STATUS" -ne 201 ]; then + echo "Failed to upload Coder public SSH key with status code $UPLOAD_RESPONSE_STATUS!" + echo "$UPLOAD_RESPONSE_BODY" + exit 1 +fi + +echo "Your Coder public key has been added to GitHub!" diff --git a/registry/coder/modules/goose/README.md b/registry/coder/modules/goose/README.md new file mode 100644 index 0000000..5c1dcb8 --- /dev/null +++ b/registry/coder/modules/goose/README.md @@ -0,0 +1,130 @@ +--- +display_name: Goose +description: Run Goose in your workspace +icon: ../../../../.icons/goose.svg +maintainer_github: coder +verified: true +tags: [agent, goose] +--- + +# Goose + +Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks. + +```tf +module "goose" { + source = "registry.coder.com/modules/goose/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" +} +``` + +### Prerequisites + +- `screen` must be installed in your workspace to run Goose in the background +- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template + +The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. + +## Examples + +Your workspace must have `screen` installed to use this. + +### Run in the background and report tasks (Experimental) + +> This functionality is in early access as of Coder v2.21 and is still evolving. +> For now, we recommend testing it in a demo or staging environment, +> rather than deploying to production +> +> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents) +> +> Join our [Discord channel](https://discord.gg/coder) or +> [contact us](https://coder.com/contact) to get help or share feedback. + +```tf +module "coder-login" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/coder-login/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} + +variable "anthropic_api_key" { + type = string + description = "The Anthropic API key" + sensitive = true +} + +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Write a prompt for Goose" + mutable = true +} + +# Set the prompt and system prompt for Goose via environment variables +resource "coder_agent" "main" { + # ... + env = { + GOOSE_SYSTEM_PROMPT = <<-EOT + You are a helpful assistant that can help write code. + + Run all long running tasks (e.g. npm run dev) in the background and not in the foreground. + + Periodically check in on background tasks. + + Notify Coder of the status of the task before and after your steps. + EOT + GOOSE_TASK_PROMPT = data.coder_parameter.ai_prompt.value + + # An API key is required for experiment_auto_configure + # See https://block.github.io/goose/docs/getting-started/providers + ANTHROPIC_API_KEY = var.anthropic_api_key # or use a coder_parameter + } +} + +module "goose" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/goose/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" + + # Enable experimental features + experiment_report_tasks = true + + # Run Goose in the background + experiment_use_screen = true + + # Avoid configuring Goose manually + experiment_auto_configure = true + + # Required for experiment_auto_configure + experiment_goose_provider = "anthropic" + experiment_goose_model = "claude-3-5-sonnet-latest" +} +``` + +## Run standalone + +Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or any task reporting to the Coder UI. + +```tf +module "goose" { + source = "registry.coder.com/modules/goose/coder" + version = "1.0.31" + agent_id = coder_agent.example.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" + + # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL + icon = "https://raw.githubusercontent.com/block/goose/refs/heads/main/ui/desktop/src/images/icon.svg" +} +``` diff --git a/registry/coder/modules/goose/main.tf b/registry/coder/modules/goose/main.tf new file mode 100644 index 0000000..fcb6baa --- /dev/null +++ b/registry/coder/modules/goose/main.tf @@ -0,0 +1,207 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/goose.svg" +} + +variable "folder" { + type = string + description = "The folder to run Goose in." + default = "/home/coder" +} + +variable "install_goose" { + type = bool + description = "Whether to install Goose." + default = true +} + +variable "goose_version" { + type = string + description = "The version of Goose to install." + default = "stable" +} + +variable "experiment_use_screen" { + type = bool + description = "Whether to use screen for running Goose in the background." + default = false +} + +variable "experiment_report_tasks" { + type = bool + description = "Whether to enable task reporting." + default = false +} + +variable "experiment_auto_configure" { + type = bool + description = "Whether to automatically configure Goose." + default = false +} + +variable "experiment_goose_provider" { + type = string + description = "The provider to use for Goose (e.g., anthropic)." + default = null +} + +variable "experiment_goose_model" { + type = string + description = "The model to use for Goose (e.g., claude-3-5-sonnet-latest)." + default = null +} + +# Install and Initialize Goose +resource "coder_script" "goose" { + agent_id = var.agent_id + display_name = "Goose" + icon = var.icon + script = <<-EOT + #!/bin/bash + set -e + + # Function to check if a command exists + command_exists() { + command -v "$1" >/dev/null 2>&1 + } + + # Install Goose if enabled + if [ "${var.install_goose}" = "true" ]; then + if ! command_exists npm; then + echo "Error: npm is not installed. Please install Node.js and npm first." + exit 1 + fi + echo "Installing Goose..." + RELEASE_TAG=v${var.goose_version} curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash + fi + + # Configure Goose if auto-configure is enabled + if [ "${var.experiment_auto_configure}" = "true" ]; then + echo "Configuring Goose..." + mkdir -p "$HOME/.config/goose" + cat > "$HOME/.config/goose/config.yaml" << EOL +GOOSE_PROVIDER: ${var.experiment_goose_provider} +GOOSE_MODEL: ${var.experiment_goose_model} +extensions: + coder: + args: + - exp + - mcp + - server + cmd: coder + description: Report ALL tasks and statuses (in progress, done, failed) before and after starting + enabled: true + envs: + CODER_MCP_APP_STATUS_SLUG: goose + name: Coder + timeout: 3000 + type: stdio + developer: + display_name: Developer + enabled: true + name: developer + timeout: 300 + type: builtin +EOL + fi + + # Run with screen if enabled + if [ "${var.experiment_use_screen}" = "true" ]; then + echo "Running Goose in the background..." + + # Check if screen is installed + if ! command_exists screen; then + echo "Error: screen is not installed. Please install screen manually." + exit 1 + fi + + touch "$HOME/.goose.log" + + # Ensure the screenrc exists + if [ ! -f "$HOME/.screenrc" ]; then + echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.goose.log" + echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" + fi + + if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then + echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.goose.log" + echo "multiuser on" >> "$HOME/.screenrc" + fi + + if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then + echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.goose.log" + echo "acladd $(whoami)" >> "$HOME/.screenrc" + fi + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + screen -U -dmS goose bash -c ' + cd ${var.folder} + $HOME/.local/bin/goose run --text "$GOOSE_SYSTEM_PROMPT. Your task: $GOOSE_TASK_PROMPT" --interactive | tee -a "$HOME/.goose.log" + exec bash + ' + else + # Check if goose is installed before running + if ! command_exists $HOME/.local/bin/goose; then + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 + fi + fi + EOT + run_on_start = true +} + +resource "coder_app" "goose" { + slug = "goose" + display_name = "Goose" + agent_id = var.agent_id + command = <<-EOT + #!/bin/bash + set -e + + if [ "${var.experiment_use_screen}" = "true" ]; then + if screen -list | grep -q "goose"; then + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + echo "Attaching to existing Goose session." | tee -a "$HOME/.goose.log" + screen -xRR goose + else + echo "Starting a new Goose session." | tee -a "$HOME/.goose.log" + screen -S goose bash -c 'export LANG=en_US.UTF-8; export LC_ALL=en_US.UTF-8; $HOME/.local/bin/goose run --text "Always report status and instructions to Coder, before and after your steps" --interactive | tee -a "$HOME/.goose.log"; exec bash' + fi + else + cd ${var.folder} + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + $HOME/.local/bin/goose + fi + EOT + icon = var.icon +} diff --git a/registry/coder/modules/hcp-vault-secrets/README.md b/registry/coder/modules/hcp-vault-secrets/README.md new file mode 100644 index 0000000..db29a24 --- /dev/null +++ b/registry/coder/modules/hcp-vault-secrets/README.md @@ -0,0 +1,80 @@ +--- +display_name: "HCP Vault Secrets" +description: "Fetch secrets from HCP Vault" +icon: ../../../../.icons/vault.svg +maintainer_github: coder +partner_github: hashicorp +verified: true +tags: [helper, integration, vault, hashicorp, hvs] +--- + +# HCP Vault Secrets + +This module lets you fetch all or selective secrets from a [HCP Vault Secrets](https://developer.hashicorp.com/hcp/docs/vault-secrets) app into your [Coder](https://coder.com) workspaces. It makes use of the [`hcp_vault_secrets_app`](https://registry.terraform.io/providers/hashicorp/hcp/latest/docs/data-sources/vault_secrets_app) data source from the [HCP provider](https://registry.terraform.io/providers/hashicorp/hcp/latest). + +```tf +module "vault" { + source = "registry.coder.com/modules/hcp-vault-secrets/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + app_name = "demo-app" + project_id = "aaa-bbb-ccc" +} +``` + +## Configuration + +To configure the HCP Vault Secrets module, follow these steps, + +1. [Create secrets in HCP Vault Secrets](https://developer.hashicorp.com/vault/tutorials/hcp-vault-secrets-get-started/hcp-vault-secrets-create-secret) +2. Create an HCP Service Principal from the HCP Vault Secrets app in the HCP console. This will give you the `HCP_CLIENT_ID` and `HCP_CLIENT_SECRET` that you need to authenticate with HCP Vault Secrets. + ![HCP vault secrets credentials](../../.images/hcp-vault-secrets-credentials.png) +3. Set `HCP_CLIENT_ID` and `HCP_CLIENT_SECRET` variables on the coder provisioner (recommended) or supply them as input to the module. +4. Set the `project_id`. This is the ID of the project where the HCP Vault Secrets app is running. + +> See the [HCP Vault Secrets documentation](https://developer.hashicorp.com/hcp/docs/vault-secrets) for more information. + +## Fetch All Secrets + +To fetch all secrets from the HCP Vault Secrets app, skip the `secrets` input. + +```tf +module "vault" { + source = "registry.coder.com/modules/hcp-vault-secrets/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + app_name = "demo-app" + project_id = "aaa-bbb-ccc" +} +``` + +## Fetch Selective Secrets + +To fetch selective secrets from the HCP Vault Secrets app, set the `secrets` input. + +```tf +module "vault" { + source = "registry.coder.com/modules/hcp-vault-secrets/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + app_name = "demo-app" + project_id = "aaa-bbb-ccc" + secrets = ["MY_SECRET_1", "MY_SECRET_2"] +} +``` + +## Set Client ID and Client Secret as Inputs + +Set `client_id` and `client_secret` as module inputs. + +```tf +module "vault" { + source = "registry.coder.com/modules/hcp-vault-secrets/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + app_name = "demo-app" + project_id = "aaa-bbb-ccc" + client_id = "HCP_CLIENT_ID" + client_secret = "HCP_CLIENT_SECRET" +} +``` diff --git a/registry/coder/modules/hcp-vault-secrets/main.tf b/registry/coder/modules/hcp-vault-secrets/main.tf new file mode 100644 index 0000000..9a5e94b --- /dev/null +++ b/registry/coder/modules/hcp-vault-secrets/main.tf @@ -0,0 +1,73 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12.4" + } + hcp = { + source = "hashicorp/hcp" + version = ">= 0.82.0" + } + } +} + +provider "hcp" { + client_id = var.client_id + client_secret = var.client_secret + project_id = var.project_id +} + +provider "coder" {} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "project_id" { + type = string + description = "The ID of the HCP project." +} + +variable "client_id" { + type = string + description = <<-EOF + The client ID for the HCP Vault Secrets service principal. (Optional if HCP_CLIENT_ID is set as an environment variable.) + EOF + default = null + sensitive = true +} + +variable "client_secret" { + type = string + description = <<-EOF + The client secret for the HCP Vault Secrets service principal. (Optional if HCP_CLIENT_SECRET is set as an environment variable.) + EOF + default = null + sensitive = true +} + +variable "app_name" { + type = string + description = "The name of the secrets app in HCP Vault Secrets" +} + +variable "secrets" { + type = list(string) + description = "The names of the secrets to retrieve from HCP Vault Secrets" + default = null +} + +data "hcp_vault_secrets_app" "secrets" { + app_name = var.app_name +} + +resource "coder_env" "hvs_secrets" { + # https://support.hashicorp.com/hc/en-us/articles/4538432032787-Variable-has-a-sensitive-value-and-cannot-be-used-as-for-each-arguments + for_each = var.secrets != null ? toset(var.secrets) : nonsensitive(toset(keys(data.hcp_vault_secrets_app.secrets.secrets))) + agent_id = var.agent_id + name = each.key + value = data.hcp_vault_secrets_app.secrets.secrets[each.key] +} \ No newline at end of file diff --git a/registry/coder/modules/jetbrains-gateway/README.md b/registry/coder/modules/jetbrains-gateway/README.md new file mode 100644 index 0000000..dbf4dba --- /dev/null +++ b/registry/coder/modules/jetbrains-gateway/README.md @@ -0,0 +1,133 @@ +--- +display_name: JetBrains Gateway +description: Add a one-click button to launch JetBrains Gateway IDEs in the dashboard. +icon: ../../../../.icons/gateway.svg +maintainer_github: coder +verified: true +tags: [ide, jetbrains, helper, parameter] +--- + +# JetBrains Gateway + +This module adds a JetBrains Gateway Button to open any workspace with a single click. + +JetBrains recommends a minimum of 4 CPU cores and 8GB of RAM. +Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prerequisites.html#min_requirements) to confirm other system requirements. + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"] + default = "GO" +} +``` + +![JetBrains Gateway IDes list](../../.images/jetbrains-gateway.png) + +## Examples + +### Add GoLand and WebStorm as options with the default set to GoLand + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["GO", "WS"] + default = "GO" +} +``` + +### Use the latest version of each IDE + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["IU", "PY"] + default = "IU" + latest = true +} +``` + +### Use fixed versions set by `jetbrains_ide_versions` + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["IU", "PY"] + default = "IU" + latest = false + jetbrains_ide_versions = { + "IU" = { + build_number = "243.21565.193" + version = "2024.3" + } + "PY" = { + build_number = "243.21565.199" + version = "2024.3" + } + } +} +``` + +### Use the latest EAP version + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["GO", "WS"] + default = "GO" + latest = true + channel = "eap" +} +``` + +### Custom base link + +Due to the highest priority of the `ide_download_link` parameter in the `(jetbrains-gateway://...` within IDEA, the pre-configured download address will be overridden when using [IDEA's offline mode](https://www.jetbrains.com/help/idea/fully-offline-mode.html). Therefore, it is necessary to configure the `download_base_link` parameter for the `jetbrains_gateway` module to change the value of `ide_download_link`. + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.28" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + jetbrains_ides = ["GO", "WS"] + releases_base_link = "https://releases.internal.site/" + download_base_link = "https://download.internal.site/" + default = "GO" +} +``` + +## Supported IDEs + +This module and JetBrains Gateway support the following JetBrains IDEs: + +- [GoLand (`GO`)](https://www.jetbrains.com/go/) +- [WebStorm (`WS`)](https://www.jetbrains.com/webstorm/) +- [IntelliJ IDEA Ultimate (`IU`)](https://www.jetbrains.com/idea/) +- [PyCharm Professional (`PY`)](https://www.jetbrains.com/pycharm/) +- [PhpStorm (`PS`)](https://www.jetbrains.com/phpstorm/) +- [CLion (`CL`)](https://www.jetbrains.com/clion/) +- [RubyMine (`RM`)](https://www.jetbrains.com/ruby/) +- [Rider (`RD`)](https://www.jetbrains.com/rider/) +- [RustRover (`RR`)](https://www.jetbrains.com/rust/) diff --git a/registry/coder/modules/jetbrains-gateway/main.test.ts b/registry/coder/modules/jetbrains-gateway/main.test.ts new file mode 100644 index 0000000..764170e --- /dev/null +++ b/registry/coder/modules/jetbrains-gateway/main.test.ts @@ -0,0 +1,43 @@ +import { it, expect, describe } from "bun:test"; +import { + runTerraformInit, + testRequiredVariables, + runTerraformApply, +} from "~test"; + +describe("jetbrains-gateway", async () => { + await runTerraformInit(import.meta.dir); + + await testRequiredVariables(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + }); + + it("should create a link with the default values", async () => { + const state = await runTerraformApply(import.meta.dir, { + // These are all required. + agent_id: "foo", + folder: "/home/coder", + }); + expect(state.outputs.url.value).toBe( + "jetbrains-gateway://connect#type=coder&workspace=default&owner=default&folder=/home/coder&url=https://mydeployment.coder.com&token=$SESSION_TOKEN&ide_product_code=IU&ide_build_number=243.21565.193&ide_download_link=https://download.jetbrains.com/idea/ideaIU-2024.3.tar.gz", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "gateway", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("default to first ide", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + jetbrains_ides: '["IU", "GO", "PY"]', + }); + expect(state.outputs.identifier.value).toBe("IU"); + }); +}); diff --git a/registry/coder/modules/jetbrains-gateway/main.tf b/registry/coder/modules/jetbrains-gateway/main.tf new file mode 100644 index 0000000..d197399 --- /dev/null +++ b/registry/coder/modules/jetbrains-gateway/main.tf @@ -0,0 +1,341 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + http = { + source = "hashicorp/http" + version = ">= 3.0" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "slug" { + type = string + description = "The slug for the coder_app. Allows resuing the module with the same template." + default = "gateway" +} + +variable "agent_name" { + type = string + description = "Agent name. (unused). Will be removed in a future version" + + default = "" +} + +variable "folder" { + type = string + description = "The directory to open in the IDE. e.g. /home/coder/project" + validation { + condition = can(regex("^(?:/[^/]+)+$", var.folder)) + error_message = "The folder must be a full path and must not start with a ~." + } +} + +variable "default" { + default = "" + type = string + description = "Default IDE" +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +variable "latest" { + type = bool + description = "Whether to fetch the latest version of the IDE." + default = false +} + +variable "channel" { + type = string + description = "JetBrains IDE release channel. Valid values are release and eap." + default = "release" + validation { + condition = can(regex("^(release|eap)$", var.channel)) + error_message = "The channel must be either release or eap." + } +} + +variable "jetbrains_ide_versions" { + type = map(object({ + build_number = string + version = string + })) + description = "The set of versions for each jetbrains IDE" + default = { + "IU" = { + build_number = "243.21565.193" + version = "2024.3" + } + "PS" = { + build_number = "243.21565.202" + version = "2024.3" + } + "WS" = { + build_number = "243.21565.180" + version = "2024.3" + } + "PY" = { + build_number = "243.21565.199" + version = "2024.3" + } + "CL" = { + build_number = "243.21565.238" + version = "2024.1" + } + "GO" = { + build_number = "243.21565.208" + version = "2024.3" + } + "RM" = { + build_number = "243.21565.197" + version = "2024.3" + } + "RD" = { + build_number = "243.21565.191" + version = "2024.3" + } + "RR" = { + build_number = "243.22562.230" + version = "2024.3" + } + } + validation { + condition = ( + alltrue([ + for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code) + ]) + ) + error_message = "The jetbrains_ide_versions must contain a map of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}." + } +} + +variable "jetbrains_ides" { + type = list(string) + description = "The list of IDE product codes." + default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"] + validation { + condition = ( + alltrue([ + for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"], code) + ]) + ) + error_message = "The jetbrains_ides must be a list of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"])}." + } + # check if the list is empty + validation { + condition = length(var.jetbrains_ides) > 0 + error_message = "The jetbrains_ides must not be empty." + } + # check if the list contains duplicates + validation { + condition = length(var.jetbrains_ides) == length(toset(var.jetbrains_ides)) + error_message = "The jetbrains_ides must not contain duplicates." + } +} + +variable "releases_base_link" { + type = string + description = "" + default = "https://data.services.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.releases_base_link)) + error_message = "The releases_base_link must be a valid HTTP/S address." + } +} + +variable "download_base_link" { + type = string + description = "" + default = "https://download.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.download_base_link)) + error_message = "The download_base_link must be a valid HTTP/S address." + } +} + +data "http" "jetbrains_ide_versions" { + for_each = var.latest ? toset(var.jetbrains_ides) : toset([]) + url = "${var.releases_base_link}/products/releases?code=${each.key}&latest=true&type=${var.channel}" +} + +locals { + jetbrains_ides = { + "GO" = { + icon = "/icon/goland.svg", + name = "GoLand", + identifier = "GO", + build_number = var.jetbrains_ide_versions["GO"].build_number, + download_link = "${var.download_base_link}/go/goland-${var.jetbrains_ide_versions["GO"].version}.tar.gz" + version = var.jetbrains_ide_versions["GO"].version + }, + "WS" = { + icon = "/icon/webstorm.svg", + name = "WebStorm", + identifier = "WS", + build_number = var.jetbrains_ide_versions["WS"].build_number, + download_link = "${var.download_base_link}/webstorm/WebStorm-${var.jetbrains_ide_versions["WS"].version}.tar.gz" + version = var.jetbrains_ide_versions["WS"].version + }, + "IU" = { + icon = "/icon/intellij.svg", + name = "IntelliJ IDEA Ultimate", + identifier = "IU", + build_number = var.jetbrains_ide_versions["IU"].build_number, + download_link = "${var.download_base_link}/idea/ideaIU-${var.jetbrains_ide_versions["IU"].version}.tar.gz" + version = var.jetbrains_ide_versions["IU"].version + }, + "PY" = { + icon = "/icon/pycharm.svg", + name = "PyCharm Professional", + identifier = "PY", + build_number = var.jetbrains_ide_versions["PY"].build_number, + download_link = "${var.download_base_link}/python/pycharm-professional-${var.jetbrains_ide_versions["PY"].version}.tar.gz" + version = var.jetbrains_ide_versions["PY"].version + }, + "CL" = { + icon = "/icon/clion.svg", + name = "CLion", + identifier = "CL", + build_number = var.jetbrains_ide_versions["CL"].build_number, + download_link = "${var.download_base_link}/cpp/CLion-${var.jetbrains_ide_versions["CL"].version}.tar.gz" + version = var.jetbrains_ide_versions["CL"].version + }, + "PS" = { + icon = "/icon/phpstorm.svg", + name = "PhpStorm", + identifier = "PS", + build_number = var.jetbrains_ide_versions["PS"].build_number, + download_link = "${var.download_base_link}/webide/PhpStorm-${var.jetbrains_ide_versions["PS"].version}.tar.gz" + version = var.jetbrains_ide_versions["PS"].version + }, + "RM" = { + icon = "/icon/rubymine.svg", + name = "RubyMine", + identifier = "RM", + build_number = var.jetbrains_ide_versions["RM"].build_number, + download_link = "${var.download_base_link}/ruby/RubyMine-${var.jetbrains_ide_versions["RM"].version}.tar.gz" + version = var.jetbrains_ide_versions["RM"].version + }, + "RD" = { + icon = "/icon/rider.svg", + name = "Rider", + identifier = "RD", + build_number = var.jetbrains_ide_versions["RD"].build_number, + download_link = "${var.download_base_link}/rider/JetBrains.Rider-${var.jetbrains_ide_versions["RD"].version}.tar.gz" + version = var.jetbrains_ide_versions["RD"].version + }, + "RR" = { + icon = "/icon/rustrover.svg", + name = "RustRover", + identifier = "RR", + build_number = var.jetbrains_ide_versions["RR"].build_number, + download_link = "${var.download_base_link}/rustrover/RustRover-${var.jetbrains_ide_versions["RR"].version}.tar.gz" + version = var.jetbrains_ide_versions["RR"].version + } + } + + icon = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].icon + json_data = var.latest ? jsondecode(data.http.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].response_body) : {} + key = var.latest ? keys(local.json_data)[0] : "" + display_name = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].name + identifier = data.coder_parameter.jetbrains_ide.value + download_link = var.latest ? local.json_data[local.key][0].downloads.linux.link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link + build_number = var.latest ? local.json_data[local.key][0].build : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].build_number + version = var.latest ? local.json_data[local.key][0].version : var.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].version +} + +data "coder_parameter" "jetbrains_ide" { + type = "string" + name = "jetbrains_ide" + display_name = "JetBrains IDE" + icon = "/icon/gateway.svg" + mutable = true + default = var.default == "" ? var.jetbrains_ides[0] : var.default + order = var.coder_parameter_order + + dynamic "option" { + for_each = var.jetbrains_ides + content { + icon = local.jetbrains_ides[option.value].icon + name = local.jetbrains_ides[option.value].name + value = option.value + } + } +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "gateway" { + agent_id = var.agent_id + slug = var.slug + display_name = local.display_name + icon = local.icon + external = true + order = var.order + url = join("", [ + "jetbrains-gateway://connect#type=coder&workspace=", + data.coder_workspace.me.name, + "&owner=", + data.coder_workspace_owner.me.name, + "&folder=", + var.folder, + "&url=", + data.coder_workspace.me.access_url, + "&token=", + "$SESSION_TOKEN", + "&ide_product_code=", + data.coder_parameter.jetbrains_ide.value, + "&ide_build_number=", + local.build_number, + "&ide_download_link=", + local.download_link, + ]) +} + +output "identifier" { + value = local.identifier +} + +output "display_name" { + value = local.display_name +} + +output "icon" { + value = local.icon +} + +output "download_link" { + value = local.download_link +} + +output "build_number" { + value = local.build_number +} + +output "version" { + value = local.version +} + +output "url" { + value = coder_app.gateway.url +} diff --git a/registry/coder/modules/jfrog-oauth/.npmrc.tftpl b/registry/coder/modules/jfrog-oauth/.npmrc.tftpl new file mode 100644 index 0000000..8bb9fb8 --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/.npmrc.tftpl @@ -0,0 +1,5 @@ +email=${ARTIFACTORY_EMAIL} +%{ for REPO in REPOS ~} +${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME} +//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN} +%{ endfor ~} diff --git a/registry/coder/modules/jfrog-oauth/README.md b/registry/coder/modules/jfrog-oauth/README.md new file mode 100644 index 0000000..312c4c0 --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/README.md @@ -0,0 +1,107 @@ +--- +display_name: JFrog (OAuth) +description: Install the JF CLI and authenticate with Artifactory using OAuth. +icon: ../../../../.icons/jfrog.svg +maintainer_github: coder +partner_github: jfrog +verified: true +tags: [integration, jfrog] +--- + +# JFrog + +Install the JF CLI and authenticate package managers with Artifactory using OAuth configured via the Coder [`external-auth`](https://coder.com/docs/v2/latest/admin/external-auth) feature. + +![JFrog OAuth](../../.images/jfrog-oauth.png) + +```tf +module "jfrog" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jfrog-oauth/coder" + version = "1.0.19" + agent_id = coder_agent.example.id + jfrog_url = "https://example.jfrog.io" + username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username" + + package_managers = { + npm = ["npm", "@scoped:npm-scoped"] + go = ["go", "another-go-repo"] + pypi = ["pypi", "extra-index-pypi"] + docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"] + } +} +``` + +> Note +> This module does not install `npm`, `go`, `pip`, etc but only configure them. You need to handle the installation of these tools yourself. + +## Prerequisites + +This module is usable by JFrog self-hosted (on-premises) Artifactory as it requires configuring a custom integration. This integration benefits from Coder's [external-auth](https://coder.com/docs/v2/latest/admin/external-auth) feature and allows each user to authenticate with Artifactory using an OAuth flow and issues user-scoped tokens to each user. For configuration instructions, see this [guide](https://coder.com/docs/v2/latest/guides/artifactory-integration#jfrog-oauth) on the Coder documentation. + +## Examples + +Configure the Python pip package manager to fetch packages from Artifactory while mapping the Coder email to the Artifactory username. + +```tf +module "jfrog" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jfrog-oauth/coder" + version = "1.0.19" + agent_id = coder_agent.example.id + jfrog_url = "https://example.jfrog.io" + username_field = "email" + + package_managers = { + pypi = ["pypi"] + } +} +``` + +You should now be able to install packages from Artifactory using both the `jf pip` and `pip` command. + +```shell +jf pip install requests +``` + +```shell +pip install requests +``` + +### Configure code-server with JFrog extension + +The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extension) for VS Code allows you to interact with Artifactory from within the IDE. + +```tf +module "jfrog" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jfrog-oauth/coder" + version = "1.0.19" + agent_id = coder_agent.example.id + jfrog_url = "https://example.jfrog.io" + username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username" + configure_code_server = true # Add JFrog extension configuration for code-server + package_managers = { + npm = ["npm"] + go = ["go"] + pypi = ["pypi"] + } +} +``` + +### Using the access token in other terraform resources + +JFrog Access token is also available as a terraform output. You can use it in other terraform resources. For example, you can use it to configure an [Artifactory docker registry](https://jfrog.com/help/r/jfrog-artifactory-documentation/docker-registry) with the [docker terraform provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs). + +```tf +provider "docker" { + # ... + registry_auth { + address = "https://example.jfrog.io/artifactory/api/docker/REPO-KEY" + username = try(module.jfrog[0].username, "") + password = try(module.jfrog[0].access_token, "") + } +} +``` + +> Here `REPO_KEY` is the name of docker repository in Artifactory. diff --git a/registry/coder/modules/jfrog-oauth/main.test.ts b/registry/coder/modules/jfrog-oauth/main.test.ts new file mode 100644 index 0000000..20ace69 --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/main.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "bun:test"; +import { + findResourceInstance, + runTerraformInit, + runTerraformApply, + testRequiredVariables, +} from "~test"; + +describe("jfrog-oauth", async () => { + type TestVariables = { + agent_id: string; + jfrog_url: string; + package_managers: string; + + username_field?: string; + jfrog_server_id?: string; + external_auth_id?: string; + configure_code_server?: boolean; + }; + + await runTerraformInit(import.meta.dir); + + const fakeFrogApi = "localhost:8081/artifactory/api"; + const fakeFrogUrl = "http://localhost:8081"; + const user = "default"; + + it("can run apply with required variables", async () => { + testRequiredVariables(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: "{}", + }); + }); + + it("generates an npmrc with scoped repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + npm: ["global", "@foo:foo", "@bar:bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const npmrcStanza = `cat << EOF > ~/.npmrc +email=${user}@example.com +registry=http://${fakeFrogApi}/npm/global +//${fakeFrogApi}/npm/global/:_authToken= +@foo:registry=http://${fakeFrogApi}/npm/foo +//${fakeFrogApi}/npm/foo/:_authToken= +@bar:registry=http://${fakeFrogApi}/npm/bar +//${fakeFrogApi}/npm/bar/:_authToken= + +EOF`; + expect(coderScript.script).toContain(npmrcStanza); + expect(coderScript.script).toContain( + 'jf npmc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured npm', + ); + }); + + it("generates a pip config with extra-indexes", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + pypi: ["global", "foo", "bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const pipStanza = `cat << EOF > ~/.pip/pip.conf +[global] +index-url = https://${user}:@${fakeFrogApi}/pypi/global/simple +extra-index-url = + https://${user}:@${fakeFrogApi}/pypi/foo/simple + https://${user}:@${fakeFrogApi}/pypi/bar/simple + +EOF`; + expect(coderScript.script).toContain(pipStanza); + expect(coderScript.script).toContain( + 'jf pipc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured pypi', + ); + }); + + it("registers multiple docker repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const dockerStanza = ["foo", "bar", "baz"] + .map((r) => `register_docker "${r}.jfrog.io"`) + .join("\n"); + expect(coderScript.script).toContain(dockerStanza); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured docker', + ); + }); + + it("sets goproxy with multiple repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + package_managers: JSON.stringify({ + go: ["foo", "bar", "baz"], + }), + }); + const proxyEnv = findResourceInstance(state, "coder_env", "goproxy"); + const proxies = ["foo", "bar", "baz"] + .map((r) => `https://${user}:@${fakeFrogApi}/go/${r}`) + .join(","); + expect(proxyEnv.value).toEqual(proxies); + + const coderScript = findResourceInstance(state, "coder_script"); + expect(coderScript.script).toContain( + 'jf goc --global --repo-resolve "foo"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured go', + ); + }); +}); diff --git a/registry/coder/modules/jfrog-oauth/main.tf b/registry/coder/modules/jfrog-oauth/main.tf new file mode 100644 index 0000000..0bc2256 --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/main.tf @@ -0,0 +1,173 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "jfrog_url" { + type = string + description = "JFrog instance URL. e.g. https://myartifactory.jfrog.io" + # ensue the URL is HTTPS or HTTP + validation { + condition = can(regex("^(https|http)://", var.jfrog_url)) + error_message = "jfrog_url must be a valid URL starting with either 'https://' or 'http://'" + } +} + +variable "jfrog_server_id" { + type = string + description = "The server ID of the JFrog instance for JFrog CLI configuration" + default = "0" +} + +variable "username_field" { + type = string + description = "The field to use for the artifactory username. i.e. Coder username or email." + default = "username" + validation { + condition = can(regex("^(email|username)$", var.username_field)) + error_message = "username_field must be either 'email' or 'username'" + } +} + +variable "external_auth_id" { + type = string + description = "JFrog external auth ID. Default: 'jfrog'" + default = "jfrog" +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "configure_code_server" { + type = bool + description = "Set to true to configure code-server to use JFrog." + default = false +} + +variable "package_managers" { + type = object({ + npm = optional(list(string), []) + go = optional(list(string), []) + pypi = optional(list(string), []) + docker = optional(list(string), []) + }) + description = <<-EOF + A map of package manager names to their respective artifactory repositories. Unused package managers can be omitted. + For example: + { + npm = ["GLOBAL_NPM_REPO_KEY", "@SCOPED:NPM_REPO_KEY"] + go = ["YOUR_GO_REPO_KEY", "ANOTHER_GO_REPO_KEY"] + pypi = ["YOUR_PYPI_REPO_KEY", "ANOTHER_PYPI_REPO_KEY"] + docker = ["YOUR_DOCKER_REPO_KEY", "ANOTHER_DOCKER_REPO_KEY"] + } + EOF +} + +locals { + # The username field to use for artifactory + username = var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name + jfrog_host = split("://", var.jfrog_url)[1] + common_values = { + JFROG_URL = var.jfrog_url + JFROG_HOST = local.jfrog_host + JFROG_SERVER_ID = var.jfrog_server_id + ARTIFACTORY_USERNAME = local.username + ARTIFACTORY_EMAIL = data.coder_workspace_owner.me.email + ARTIFACTORY_ACCESS_TOKEN = data.coder_external_auth.jfrog.access_token + } + npmrc = templatefile( + "${path.module}/.npmrc.tftpl", + merge( + local.common_values, + { + REPOS = [ + for r in var.package_managers.npm : + strcontains(r, ":") ? zipmap(["SCOPE", "NAME"], ["${split(":", r)[0]}:", split(":", r)[1]]) : { SCOPE = "", NAME = r } + ] + } + ) + ) + pip_conf = templatefile( + "${path.module}/pip.conf.tftpl", merge(local.common_values, { REPOS = var.package_managers.pypi }) + ) +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_external_auth" "jfrog" { + id = var.external_auth_id +} + +resource "coder_script" "jfrog" { + agent_id = var.agent_id + display_name = "jfrog" + icon = "/icon/jfrog.svg" + script = templatefile("${path.module}/run.sh", merge( + local.common_values, + { + CONFIGURE_CODE_SERVER = var.configure_code_server + HAS_NPM = length(var.package_managers.npm) == 0 ? "" : "YES" + NPMRC = local.npmrc + REPOSITORY_NPM = try(element(var.package_managers.npm, 0), "") + HAS_GO = length(var.package_managers.go) == 0 ? "" : "YES" + REPOSITORY_GO = try(element(var.package_managers.go, 0), "") + HAS_PYPI = length(var.package_managers.pypi) == 0 ? "" : "YES" + PIP_CONF = local.pip_conf + REPOSITORY_PYPI = try(element(var.package_managers.pypi, 0), "") + HAS_DOCKER = length(var.package_managers.docker) == 0 ? "" : "YES" + REGISTER_DOCKER = join("\n", formatlist("register_docker \"%s\"", var.package_managers.docker)) + } + )) + run_on_start = true +} + +resource "coder_env" "jfrog_ide_url" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_URL" + value = var.jfrog_url +} + +resource "coder_env" "jfrog_ide_access_token" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_ACCESS_TOKEN" + value = data.coder_external_auth.jfrog.access_token +} + +resource "coder_env" "jfrog_ide_store_connection" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_STORE_CONNECTION" + value = true +} + +resource "coder_env" "goproxy" { + count = length(var.package_managers.go) == 0 ? 0 : 1 + agent_id = var.agent_id + name = "GOPROXY" + value = join(",", [ + for repo in var.package_managers.go : + "https://${local.username}:${data.coder_external_auth.jfrog.access_token}@${local.jfrog_host}/artifactory/api/go/${repo}" + ]) +} + +output "access_token" { + description = "value of the JFrog access token" + value = data.coder_external_auth.jfrog.access_token + sensitive = true +} + +output "username" { + description = "value of the JFrog username" + value = local.username +} diff --git a/registry/coder/modules/jfrog-oauth/pip.conf.tftpl b/registry/coder/modules/jfrog-oauth/pip.conf.tftpl new file mode 100644 index 0000000..e4a62e9 --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/pip.conf.tftpl @@ -0,0 +1,6 @@ +[global] +index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${try(element(REPOS, 0), "")}/simple +extra-index-url = +%{ for REPO in try(slice(REPOS, 1, length(REPOS)), []) ~} + https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPO}/simple +%{ endfor ~} diff --git a/registry/coder/modules/jfrog-oauth/run.sh b/registry/coder/modules/jfrog-oauth/run.sh new file mode 100644 index 0000000..7d36e47 --- /dev/null +++ b/registry/coder/modules/jfrog-oauth/run.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' + +not_configured() { + type=$1 + echo "🤔 no $type repository is set, skipping $type configuration." + echo "You can configure a $type repository by providing a key for '$type' in the 'package_managers' input." +} + +config_complete() { + echo "🥳 Configuration complete!" +} + +register_docker() { + repo=$1 + echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login "$repo" --username ${ARTIFACTORY_USERNAME} --password-stdin +} + +# check if JFrog CLI is already installed +if command -v jf > /dev/null 2>&1; then + echo "✅ JFrog CLI is already installed, skipping installation." +else + echo "📦 Installing JFrog CLI..." + curl -fL https://install-cli.jfrog.io | sudo sh + sudo chmod 755 /usr/local/bin/jf +fi + +# The jf CLI checks $CI when determining whether to use interactive +# flows. +export CI=true +# Authenticate JFrog CLI with Artifactory. +echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFROG_URL}" --overwrite "${JFROG_SERVER_ID}" +# Set the configured server as the default. +jf c use "${JFROG_SERVER_ID}" + +# Configure npm to use the Artifactory "npm" repository. +if [ -z "${HAS_NPM}" ]; then + not_configured npm +else + echo "📦 Configuring npm..." + jf npmc --global --repo-resolve "${REPOSITORY_NPM}" + cat << EOF > ~/.npmrc +${NPMRC} +EOF + config_complete +fi + +# Configure the `pip` to use the Artifactory "python" repository. +if [ -z "${HAS_PYPI}" ]; then + not_configured pypi +else + echo "🐍 Configuring pip..." + jf pipc --global --repo-resolve "${REPOSITORY_PYPI}" + mkdir -p ~/.pip + cat << EOF > ~/.pip/pip.conf +${PIP_CONF} +EOF + config_complete +fi + +# Configure Artifactory "go" repository. +if [ -z "${HAS_GO}" ]; then + not_configured go +else + echo "🐹 Configuring go..." + jf goc --global --repo-resolve "${REPOSITORY_GO}" + config_complete +fi + +# Configure the JFrog CLI to use the Artifactory "docker" repository. +if [ -z "${HAS_DOCKER}" ]; then + not_configured docker +else + if command -v docker > /dev/null 2>&1; then + echo "🔑 Configuring 🐳 docker credentials..." + mkdir -p ~/.docker + ${REGISTER_DOCKER} + else + echo "🤔 no docker is installed, skipping docker configuration." + fi +fi + +# Install the JFrog vscode extension for code-server. +if [ "${CONFIGURE_CODE_SERVER}" == "true" ]; then + while ! [ -x /tmp/code-server/bin/code-server ]; do + counter=0 + if [ $counter -eq 60 ]; then + echo "Timed out waiting for /tmp/code-server/bin/code-server to be installed." + exit 1 + fi + echo "Waiting for /tmp/code-server/bin/code-server to be installed..." + sleep 1 + ((counter++)) + done + echo "📦 Installing JFrog extension..." + /tmp/code-server/bin/code-server --install-extension jfrog.jfrog-vscode-extension + echo "🥳 JFrog extension installed!" +else + echo "🤔 Skipping JFrog extension installation. Set configure_code_server to true to install the JFrog extension." +fi + +# Configure the JFrog CLI completion +echo "📦 Configuring JFrog CLI completion..." +# Get the user's shell +SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}') +# Generate the completion script +jf completion $SHELLNAME --install +begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" +# Add the completion script to the user's shell profile +if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then + if ! grep -q "$begin_stanza" ~/.bashrc; then + printf "%s\n" "$begin_stanza" >> ~/.bashrc + echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc + echo "# END: jf CLI shell completion" >> ~/.bashrc + else + echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping." + fi +elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then + if ! grep -q "$begin_stanza" ~/.zshrc; then + printf "\n%s\n" "$begin_stanza" >> ~/.zshrc + echo "autoload -Uz compinit" >> ~/.zshrc + echo "compinit" >> ~/.zshrc + echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc + echo "# END: jf CLI shell completion" >> ~/.zshrc + else + echo "🥳 ~/.zshrc already contains jf CLI shell completion configuration, skipping." + fi +else + echo "🤔 ~/.bashrc or ~/.zshrc does not exist, skipping jf CLI shell completion configuration." +fi diff --git a/registry/coder/modules/jfrog-token/.npmrc.tftpl b/registry/coder/modules/jfrog-token/.npmrc.tftpl new file mode 100644 index 0000000..8bb9fb8 --- /dev/null +++ b/registry/coder/modules/jfrog-token/.npmrc.tftpl @@ -0,0 +1,5 @@ +email=${ARTIFACTORY_EMAIL} +%{ for REPO in REPOS ~} +${REPO.SCOPE}registry=${JFROG_URL}/artifactory/api/npm/${REPO.NAME} +//${JFROG_HOST}/artifactory/api/npm/${REPO.NAME}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN} +%{ endfor ~} diff --git a/registry/coder/modules/jfrog-token/README.md b/registry/coder/modules/jfrog-token/README.md new file mode 100644 index 0000000..7e72aba --- /dev/null +++ b/registry/coder/modules/jfrog-token/README.md @@ -0,0 +1,125 @@ +--- +display_name: JFrog (Token) +description: Install the JF CLI and authenticate with Artifactory using Artifactory terraform provider. +icon: ../../../../.icons/jfrog.svg +maintainer_github: coder +partner_github: jfrog +verified: true +tags: [integration, jfrog] +--- + +# JFrog + +Install the JF CLI and authenticate package managers with Artifactory using Artifactory terraform provider. + +```tf +module "jfrog" { + source = "registry.coder.com/modules/jfrog-token/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + jfrog_url = "https://XXXX.jfrog.io" + artifactory_access_token = var.artifactory_access_token + package_managers = { + npm = ["npm", "@scoped:npm-scoped"] + go = ["go", "another-go-repo"] + pypi = ["pypi", "extra-index-pypi"] + docker = ["example-docker-staging.jfrog.io", "example-docker-production.jfrog.io"] + } +} +``` + +For detailed instructions, please see this [guide](https://coder.com/docs/v2/latest/guides/artifactory-integration#jfrog-token) on the Coder documentation. + +> Note +> This module does not install `npm`, `go`, `pip`, etc but only configure them. You need to handle the installation of these tools yourself. + +![JFrog](../../.images/jfrog.png) + +## Examples + +### Configure npm, go, and pypi to use Artifactory local repositories + +```tf +module "jfrog" { + source = "registry.coder.com/modules/jfrog-token/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + jfrog_url = "https://YYYY.jfrog.io" + artifactory_access_token = var.artifactory_access_token # An admin access token + package_managers = { + npm = ["npm-local"] + go = ["go-local"] + pypi = ["pypi-local"] + } +} +``` + +You should now be able to install packages from Artifactory using both the `jf npm`, `jf go`, `jf pip` and `npm`, `go`, `pip` commands. + +```shell +jf npm install prettier +jf go get github.com/golang/example/hello +jf pip install requests +``` + +```shell +npm install prettier +go get github.com/golang/example/hello +pip install requests +``` + +### Configure code-server with JFrog extension + +The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extension) for VS Code allows you to interact with Artifactory from within the IDE. + +```tf +module "jfrog" { + source = "registry.coder.com/modules/jfrog-token/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + jfrog_url = "https://XXXX.jfrog.io" + artifactory_access_token = var.artifactory_access_token + configure_code_server = true # Add JFrog extension configuration for code-server + package_managers = { + npm = ["npm"] + go = ["go"] + pypi = ["pypi"] + } +} +``` + +### Add a custom token description + +```tf +data "coder_workspace" "me" {} + +module "jfrog" { + source = "registry.coder.com/modules/jfrog-token/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + jfrog_url = "https://XXXX.jfrog.io" + artifactory_access_token = var.artifactory_access_token + token_description = "Token for Coder workspace: ${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}" + package_managers = { + npm = ["npm"] + } +} +``` + +### Using the access token in other terraform resources + +JFrog Access token is also available as a terraform output. You can use it in other terraform resources. For example, you can use it to configure an [Artifactory docker registry](https://jfrog.com/help/r/jfrog-artifactory-documentation/docker-registry) with the [docker terraform provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs). + +```tf + +provider "docker" { + # ... + registry_auth { + address = "https://YYYY.jfrog.io/artifactory/api/docker/REPO-KEY" + username = module.jfrog.username + password = module.jfrog.access_token + } +} +``` + +> Here `REPO_KEY` is the name of docker repository in Artifactory. diff --git a/registry/coder/modules/jfrog-token/main.test.ts b/registry/coder/modules/jfrog-token/main.test.ts new file mode 100644 index 0000000..4aeaba3 --- /dev/null +++ b/registry/coder/modules/jfrog-token/main.test.ts @@ -0,0 +1,165 @@ +import { serve } from "bun"; +import { describe, expect, it } from "bun:test"; +import { + createJSONResponse, + findResourceInstance, + runTerraformInit, + runTerraformApply, + testRequiredVariables, +} from "~test"; + +describe("jfrog-token", async () => { + type TestVariables = { + agent_id: string; + jfrog_url: string; + artifactory_access_token: string; + package_managers: string; + + token_description?: string; + check_license?: boolean; + refreshable?: boolean; + expires_in?: number; + username_field?: string; + username?: string; + jfrog_server_id?: string; + configure_code_server?: boolean; + }; + + await runTerraformInit(import.meta.dir); + + // Run a fake JFrog server so the provider can initialize + // correctly. This saves us from having to make remote requests! + const fakeFrogHost = serve({ + fetch: (req) => { + const url = new URL(req.url); + // See https://jfrog.com/help/r/jfrog-rest-apis/license-information + if (url.pathname === "/artifactory/api/system/license") + return createJSONResponse({ + type: "Commercial", + licensedTo: "JFrog inc.", + validThrough: "May 15, 2036", + }); + if (url.pathname === "/access/api/v1/tokens") + return createJSONResponse({ + token_id: "xxx", + access_token: "xxx", + scopes: "any", + }); + return createJSONResponse({}); + }, + port: 0, + }); + + const fakeFrogApi = `${fakeFrogHost.hostname}:${fakeFrogHost.port}/artifactory/api`; + const fakeFrogUrl = `http://${fakeFrogHost.hostname}:${fakeFrogHost.port}`; + const user = "default"; + const token = "xxx"; + + it("can run apply with required variables", async () => { + testRequiredVariables(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: "{}", + }); + }); + + it("generates an npmrc with scoped repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + npm: ["global", "@foo:foo", "@bar:bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const npmrcStanza = `cat << EOF > ~/.npmrc +email=${user}@example.com +registry=http://${fakeFrogApi}/npm/global +//${fakeFrogApi}/npm/global/:_authToken=xxx +@foo:registry=http://${fakeFrogApi}/npm/foo +//${fakeFrogApi}/npm/foo/:_authToken=xxx +@bar:registry=http://${fakeFrogApi}/npm/bar +//${fakeFrogApi}/npm/bar/:_authToken=xxx + +EOF`; + expect(coderScript.script).toContain(npmrcStanza); + expect(coderScript.script).toContain( + 'jf npmc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured npm', + ); + }); + + it("generates a pip config with extra-indexes", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + pypi: ["global", "foo", "bar"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const pipStanza = `cat << EOF > ~/.pip/pip.conf +[global] +index-url = https://${user}:${token}@${fakeFrogApi}/pypi/global/simple +extra-index-url = + https://${user}:${token}@${fakeFrogApi}/pypi/foo/simple + https://${user}:${token}@${fakeFrogApi}/pypi/bar/simple + +EOF`; + expect(coderScript.script).toContain(pipStanza); + expect(coderScript.script).toContain( + 'jf pipc --global --repo-resolve "global"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured pypi', + ); + }); + + it("registers multiple docker repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + docker: ["foo.jfrog.io", "bar.jfrog.io", "baz.jfrog.io"], + }), + }); + const coderScript = findResourceInstance(state, "coder_script"); + const dockerStanza = ["foo", "bar", "baz"] + .map((r) => `register_docker "${r}.jfrog.io"`) + .join("\n"); + expect(coderScript.script).toContain(dockerStanza); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured docker', + ); + }); + + it("sets goproxy with multiple repos", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "some-agent-id", + jfrog_url: fakeFrogUrl, + artifactory_access_token: "XXXX", + package_managers: JSON.stringify({ + go: ["foo", "bar", "baz"], + }), + }); + const proxyEnv = findResourceInstance(state, "coder_env", "goproxy"); + const proxies = ["foo", "bar", "baz"] + .map((r) => `https://${user}:${token}@${fakeFrogApi}/go/${r}`) + .join(","); + expect(proxyEnv.value).toEqual(proxies); + + const coderScript = findResourceInstance(state, "coder_script"); + expect(coderScript.script).toContain( + 'jf goc --global --repo-resolve "foo"', + ); + expect(coderScript.script).toContain( + 'if [ -z "YES" ]; then\n not_configured go', + ); + }); +}); diff --git a/registry/coder/modules/jfrog-token/main.tf b/registry/coder/modules/jfrog-token/main.tf new file mode 100644 index 0000000..720e2d8 --- /dev/null +++ b/registry/coder/modules/jfrog-token/main.tf @@ -0,0 +1,219 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + artifactory = { + source = "registry.terraform.io/jfrog/artifactory" + version = "~> 10.0.2" + } + } +} + +variable "jfrog_url" { + type = string + description = "JFrog instance URL. e.g. https://myartifactory.jfrog.io" + # ensue the URL is HTTPS or HTTP + validation { + condition = can(regex("^(https|http)://", var.jfrog_url)) + error_message = "jfrog_url must be a valid URL starting with either 'https://' or 'http://'" + } +} + +variable "jfrog_server_id" { + type = string + description = "The server ID of the JFrog instance for JFrog CLI configuration" + default = "0" +} + +variable "artifactory_access_token" { + type = string + description = "The admin-level access token to use for JFrog." +} + +variable "token_description" { + type = string + description = "Free text token description. Useful for filtering and managing tokens." + default = "Token for Coder workspace" +} + +variable "check_license" { + type = bool + description = "Toggle for pre-flight checking of Artifactory license. Default to `true`." + default = true +} + +variable "refreshable" { + type = bool + description = "Is this token refreshable? Default is `false`." + default = false +} + +variable "expires_in" { + type = number + description = "The amount of time, in seconds, it would take for the token to expire." + default = null +} + +variable "username_field" { + type = string + description = "The field to use for the artifactory username. Default `username`." + default = "username" + validation { + condition = can(regex("^(email|username)$", var.username_field)) + error_message = "username_field must be either 'email' or 'username'" + } +} + +variable "username" { + type = string + description = "Username to use for Artifactory. Overrides the field specified in `username_field`" + default = null +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "configure_code_server" { + type = bool + description = "Set to true to configure code-server to use JFrog." + default = false +} + +variable "package_managers" { + type = object({ + npm = optional(list(string), []) + go = optional(list(string), []) + pypi = optional(list(string), []) + docker = optional(list(string), []) + }) + description = <<-EOF + A map of package manager names to their respective artifactory repositories. Unused package managers can be omitted. + For example: + { + npm = ["GLOBAL_NPM_REPO_KEY", "@SCOPED:NPM_REPO_KEY"] + go = ["YOUR_GO_REPO_KEY", "ANOTHER_GO_REPO_KEY"] + pypi = ["YOUR_PYPI_REPO_KEY", "ANOTHER_PYPI_REPO_KEY"] + docker = ["YOUR_DOCKER_REPO_KEY", "ANOTHER_DOCKER_REPO_KEY"] + } + EOF +} + +locals { + # The username to use for artifactory + username = coalesce(var.username, var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name) + jfrog_host = split("://", var.jfrog_url)[1] + common_values = { + JFROG_URL = var.jfrog_url + JFROG_HOST = local.jfrog_host + JFROG_SERVER_ID = var.jfrog_server_id + ARTIFACTORY_USERNAME = local.username + ARTIFACTORY_EMAIL = data.coder_workspace_owner.me.email + ARTIFACTORY_ACCESS_TOKEN = artifactory_scoped_token.me.access_token + } + npmrc = templatefile( + "${path.module}/.npmrc.tftpl", + merge( + local.common_values, + { + REPOS = [ + for r in var.package_managers.npm : + strcontains(r, ":") ? zipmap(["SCOPE", "NAME"], ["${split(":", r)[0]}:", split(":", r)[1]]) : { SCOPE = "", NAME = r } + ] + } + ) + ) + pip_conf = templatefile( + "${path.module}/pip.conf.tftpl", merge(local.common_values, { REPOS = var.package_managers.pypi }) + ) +} + +# Configure the Artifactory provider +provider "artifactory" { + url = join("/", [var.jfrog_url, "artifactory"]) + access_token = var.artifactory_access_token + check_license = var.check_license +} + +resource "artifactory_scoped_token" "me" { + # This is hacky, but on terraform plan the data source gives empty strings, + # which fails validation. + username = length(local.username) > 0 ? local.username : "dummy" + scopes = ["applied-permissions/user"] + refreshable = var.refreshable + expires_in = var.expires_in + description = var.token_description +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_script" "jfrog" { + agent_id = var.agent_id + display_name = "jfrog" + icon = "/icon/jfrog.svg" + script = templatefile("${path.module}/run.sh", merge( + local.common_values, + { + CONFIGURE_CODE_SERVER = var.configure_code_server + HAS_NPM = length(var.package_managers.npm) == 0 ? "" : "YES" + NPMRC = local.npmrc + REPOSITORY_NPM = try(element(var.package_managers.npm, 0), "") + HAS_GO = length(var.package_managers.go) == 0 ? "" : "YES" + REPOSITORY_GO = try(element(var.package_managers.go, 0), "") + HAS_PYPI = length(var.package_managers.pypi) == 0 ? "" : "YES" + PIP_CONF = local.pip_conf + REPOSITORY_PYPI = try(element(var.package_managers.pypi, 0), "") + HAS_DOCKER = length(var.package_managers.docker) == 0 ? "" : "YES" + REGISTER_DOCKER = join("\n", formatlist("register_docker \"%s\"", var.package_managers.docker)) + } + )) + run_on_start = true +} + +resource "coder_env" "jfrog_ide_url" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_URL" + value = var.jfrog_url +} + +resource "coder_env" "jfrog_ide_access_token" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_ACCESS_TOKEN" + value = artifactory_scoped_token.me.access_token +} + +resource "coder_env" "jfrog_ide_store_connection" { + count = var.configure_code_server ? 1 : 0 + agent_id = var.agent_id + name = "JFROG_IDE_STORE_CONNECTION" + value = true +} + +resource "coder_env" "goproxy" { + count = length(var.package_managers.go) == 0 ? 0 : 1 + agent_id = var.agent_id + name = "GOPROXY" + value = join(",", [ + for repo in var.package_managers.go : + "https://${local.username}:${artifactory_scoped_token.me.access_token}@${local.jfrog_host}/artifactory/api/go/${repo}" + ]) +} + +output "access_token" { + description = "value of the JFrog access token" + value = artifactory_scoped_token.me.access_token + sensitive = true +} + +output "username" { + description = "value of the JFrog username" + value = local.username +} diff --git a/registry/coder/modules/jfrog-token/pip.conf.tftpl b/registry/coder/modules/jfrog-token/pip.conf.tftpl new file mode 100644 index 0000000..e4a62e9 --- /dev/null +++ b/registry/coder/modules/jfrog-token/pip.conf.tftpl @@ -0,0 +1,6 @@ +[global] +index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${try(element(REPOS, 0), "")}/simple +extra-index-url = +%{ for REPO in try(slice(REPOS, 1, length(REPOS)), []) ~} + https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPO}/simple +%{ endfor ~} diff --git a/registry/coder/modules/jfrog-token/run.sh b/registry/coder/modules/jfrog-token/run.sh new file mode 100644 index 0000000..d3a1a74 --- /dev/null +++ b/registry/coder/modules/jfrog-token/run.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' + +not_configured() { + type=$1 + echo "🤔 no $type repository is set, skipping $type configuration." + echo "You can configure a $type repository by providing a key for '$type' in the 'package_managers' input." +} + +config_complete() { + echo "🥳 Configuration complete!" +} + +register_docker() { + repo=$1 + echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login "$repo" --username ${ARTIFACTORY_USERNAME} --password-stdin +} + +# check if JFrog CLI is already installed +if command -v jf > /dev/null 2>&1; then + echo "✅ JFrog CLI is already installed, skipping installation." +else + echo "📦 Installing JFrog CLI..." + curl -fL https://install-cli.jfrog.io | sudo sh + sudo chmod 755 /usr/local/bin/jf +fi + +# The jf CLI checks $CI when determining whether to use interactive flows. +export CI=true +# Authenticate JFrog CLI with Artifactory. +echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFROG_URL}" --overwrite "${JFROG_SERVER_ID}" +# Set the configured server as the default. +jf c use "${JFROG_SERVER_ID}" + +# Configure npm to use the Artifactory "npm" repository. +if [ -z "${HAS_NPM}" ]; then + not_configured npm +else + echo "📦 Configuring npm..." + jf npmc --global --repo-resolve "${REPOSITORY_NPM}" + cat << EOF > ~/.npmrc +${NPMRC} +EOF + config_complete +fi + +# Configure the `pip` to use the Artifactory "python" repository. +if [ -z "${HAS_PYPI}" ]; then + not_configured pypi +else + echo "🐍 Configuring pip..." + jf pipc --global --repo-resolve "${REPOSITORY_PYPI}" + mkdir -p ~/.pip + cat << EOF > ~/.pip/pip.conf +${PIP_CONF} +EOF + config_complete +fi + +# Configure Artifactory "go" repository. +if [ -z "${HAS_GO}" ]; then + not_configured go +else + echo "🐹 Configuring go..." + jf goc --global --repo-resolve "${REPOSITORY_GO}" + config_complete +fi + +# Configure the JFrog CLI to use the Artifactory "docker" repository. +if [ -z "${HAS_DOCKER}" ]; then + not_configured docker +else + if command -v docker > /dev/null 2>&1; then + echo "🔑 Configuring 🐳 docker credentials..." + mkdir -p ~/.docker + ${REGISTER_DOCKER} + else + echo "🤔 no docker is installed, skipping docker configuration." + fi +fi + +# Install the JFrog vscode extension for code-server. +if [ "${CONFIGURE_CODE_SERVER}" == "true" ]; then + while ! [ -x /tmp/code-server/bin/code-server ]; do + counter=0 + if [ $counter -eq 60 ]; then + echo "Timed out waiting for /tmp/code-server/bin/code-server to be installed." + exit 1 + fi + echo "Waiting for /tmp/code-server/bin/code-server to be installed..." + sleep 1 + ((counter++)) + done + echo "📦 Installing JFrog extension..." + /tmp/code-server/bin/code-server --install-extension jfrog.jfrog-vscode-extension + echo "🥳 JFrog extension installed!" +else + echo "🤔 Skipping JFrog extension installation. Set configure_code_server to true to install the JFrog extension." +fi + +# Configure the JFrog CLI completion +echo "📦 Configuring JFrog CLI completion..." +# Get the user's shell +SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}') +# Generate the completion script +jf completion $SHELLNAME --install +begin_stanza="# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" +# Add the completion script to the user's shell profile +if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then + if ! grep -q "$begin_stanza" ~/.bashrc; then + printf "%s\n" "$begin_stanza" >> ~/.bashrc + echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc + echo "# END: jf CLI shell completion" >> ~/.bashrc + else + echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping." + fi +elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then + if ! grep -q "$begin_stanza" ~/.zshrc; then + printf "\n%s\n" "$begin_stanza" >> ~/.zshrc + echo "autoload -Uz compinit" >> ~/.zshrc + echo "compinit" >> ~/.zshrc + echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc + echo "# END: jf CLI shell completion" >> ~/.zshrc + else + echo "🥳 ~/.zshrc already contains jf CLI shell completion configuration, skipping." + fi +else + echo "🤔 ~/.bashrc or ~/.zshrc does not exist, skipping jf CLI shell completion configuration." +fi diff --git a/registry/coder/modules/jupyter-notebook/README.md b/registry/coder/modules/jupyter-notebook/README.md new file mode 100644 index 0000000..842eb09 --- /dev/null +++ b/registry/coder/modules/jupyter-notebook/README.md @@ -0,0 +1,23 @@ +--- +display_name: Jupyter Notebook +description: A module that adds Jupyter Notebook in your Coder template. +icon: ../../../../.icons/jupyter.svg +maintainer_github: coder +verified: true +tags: [jupyter, helper, ide, web] +--- + +# Jupyter Notebook + +A module that adds Jupyter Notebook in your Coder template. + +![Jupyter Notebook](../../.images/jupyter-notebook.png) + +```tf +module "jupyter-notebook" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jupyter-notebook/coder" + version = "1.0.19" + agent_id = coder_agent.example.id +} +``` diff --git a/registry/coder/modules/jupyter-notebook/main.tf b/registry/coder/modules/jupyter-notebook/main.tf new file mode 100644 index 0000000..a588ef1 --- /dev/null +++ b/registry/coder/modules/jupyter-notebook/main.tf @@ -0,0 +1,65 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "log_path" { + type = string + description = "The path to log jupyter notebook to." + default = "/tmp/jupyter-notebook.log" +} + +variable "port" { + type = number + description = "The port to run jupyter-notebook on." + default = 19999 +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +resource "coder_script" "jupyter-notebook" { + agent_id = var.agent_id + display_name = "jupyter-notebook" + icon = "/icon/jupyter.svg" + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port + }) + run_on_start = true +} + +resource "coder_app" "jupyter-notebook" { + agent_id = var.agent_id + slug = "jupyter-notebook" + display_name = "Jupyter Notebook" + url = "http://localhost:${var.port}" + icon = "/icon/jupyter.svg" + subdomain = true + share = var.share + order = var.order +} diff --git a/registry/coder/modules/jupyter-notebook/run.sh b/registry/coder/modules/jupyter-notebook/run.sh new file mode 100644 index 0000000..0c7a9b8 --- /dev/null +++ b/registry/coder/modules/jupyter-notebook/run.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env sh + +BOLD='\033[0;1m' + +printf "$${BOLD}Installing jupyter-notebook!\n" + +# check if jupyter-notebook is installed +if ! command -v jupyter-notebook > /dev/null 2>&1; then + # install jupyter-notebook + # check if pipx is installed + if ! command -v pipx > /dev/null 2>&1; then + echo "pipx is not installed" + echo "Please install pipx in your Dockerfile/VM image before using this module" + exit 1 + fi + # install jupyter notebook + pipx install -q notebook + echo "🥳 jupyter-notebook has been installed\n\n" +else + echo "🥳 jupyter-notebook is already installed\n\n" +fi + +echo "👷 Starting jupyter-notebook in background..." +echo "check logs at ${LOG_PATH}" +$HOME/.local/bin/jupyter-notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 & diff --git a/registry/coder/modules/jupyterlab/README.md b/registry/coder/modules/jupyterlab/README.md new file mode 100644 index 0000000..64d9c1c --- /dev/null +++ b/registry/coder/modules/jupyterlab/README.md @@ -0,0 +1,23 @@ +--- +display_name: JupyterLab +description: A module that adds JupyterLab in your Coder template. +icon: ../../../../.icons/jupyter.svg +maintainer_github: coder +verified: true +tags: [jupyter, helper, ide, web] +--- + +# JupyterLab + +A module that adds JupyterLab in your Coder template. + +![JupyterLab](../../.images/jupyterlab.png) + +```tf +module "jupyterlab" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jupyterlab/coder" + version = "1.0.30" + agent_id = coder_agent.example.id +} +``` diff --git a/registry/coder/modules/jupyterlab/main.test.ts b/registry/coder/modules/jupyterlab/main.test.ts new file mode 100644 index 0000000..4ef7fa0 --- /dev/null +++ b/registry/coder/modules/jupyterlab/main.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "bun:test"; +import { + execContainer, + executeScriptInContainer, + findResourceInstance, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + type TerraformState, +} from "~test"; + +// executes the coder script after installing pip +const executeScriptInContainerWithPip = async ( + state: TerraformState, + image: string, + shell = "sh", +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + const respPipx = await execContainer(id, [shell, "-c", "apk add pipx"]); + const resp = await execContainer(id, [shell, "-c", instance.script]); + const stdout = resp.stdout.trim().split("\n"); + const stderr = resp.stderr.trim().split("\n"); + return { + exitCode: resp.exitCode, + stdout, + stderr, + }; +}; + +// executes the coder script after installing pip +const executeScriptInContainerWithUv = async ( + state: TerraformState, + image: string, + shell = "sh", +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + const respPipx = await execContainer(id, [ + shell, + "-c", + "apk --no-cache add uv gcc musl-dev linux-headers && uv venv", + ]); + const resp = await execContainer(id, [shell, "-c", instance.script]); + const stdout = resp.stdout.trim().split("\n"); + const stderr = resp.stderr.trim().split("\n"); + return { + exitCode: resp.exitCode, + stdout, + stderr, + }; +}; + +describe("jupyterlab", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("fails without installers", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(1); + expect(output.stdout).toEqual([ + "Checking for a supported installer", + "No valid installer is not installed", + "Please install pipx or uv in your Dockerfile/VM image before running this script", + ]); + }); + + // TODO: Add faster test to run with uv. + // currently times out. + // it("runs with uv", async () => { + // const state = await runTerraformApply(import.meta.dir, { + // agent_id: "foo", + // }); + // const output = await executeScriptInContainerWithUv(state, "python:3-alpine"); + // expect(output.exitCode).toBe(0); + // expect(output.stdout).toEqual([ + // "Checking for a supported installer", + // "uv is installed", + // "\u001B[0;1mInstalling jupyterlab!", + // "🥳 jupyterlab has been installed", + // "👷 Starting jupyterlab in background...check logs at /tmp/jupyterlab.log", + // ]); + // }); + + // TODO: Add faster test to run with pipx. + // currently times out. + // it("runs with pipx", async () => { + // ... + // const output = await executeScriptInContainerWithPip(state, "alpine"); + // ... + // }); +}); diff --git a/registry/coder/modules/jupyterlab/main.tf b/registry/coder/modules/jupyterlab/main.tf new file mode 100644 index 0000000..d66edb1 --- /dev/null +++ b/registry/coder/modules/jupyterlab/main.tf @@ -0,0 +1,75 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "log_path" { + type = string + description = "The path to log jupyterlab to." + default = "/tmp/jupyterlab.log" +} + +variable "port" { + type = number + description = "The port to run jupyterlab on." + default = 19999 +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "subdomain" { + type = bool + description = "Determines whether JupyterLab will be accessed via its own subdomain or whether it will be accessed via a path on Coder." + default = true +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +resource "coder_script" "jupyterlab" { + agent_id = var.agent_id + display_name = "jupyterlab" + icon = "/icon/jupyter.svg" + script = templatefile("${path.module}/run.sh", { + LOG_PATH : var.log_path, + PORT : var.port + BASE_URL : var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab" + }) + run_on_start = true +} + +resource "coder_app" "jupyterlab" { + agent_id = var.agent_id + slug = "jupyterlab" # sync with the usage in URL + display_name = "JupyterLab" + url = var.subdomain ? "http://localhost:${var.port}" : "http://localhost:${var.port}/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}/apps/jupyterlab" + icon = "/icon/jupyter.svg" + subdomain = var.subdomain + share = var.share + order = var.order +} diff --git a/registry/coder/modules/jupyterlab/run.sh b/registry/coder/modules/jupyterlab/run.sh new file mode 100644 index 0000000..2dd34ac --- /dev/null +++ b/registry/coder/modules/jupyterlab/run.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env sh +INSTALLER="" +check_available_installer() { + # check if pipx is installed + echo "Checking for a supported installer" + if command -v pipx > /dev/null 2>&1; then + echo "pipx is installed" + INSTALLER="pipx" + return + fi + # check if uv is installed + if command -v uv > /dev/null 2>&1; then + echo "uv is installed" + INSTALLER="uv" + return + fi + echo "No valid installer is not installed" + echo "Please install pipx or uv in your Dockerfile/VM image before running this script" + exit 1 +} + +if [ -n "${BASE_URL}" ]; then + BASE_URL_FLAG="--ServerApp.base_url=${BASE_URL}" +fi + +BOLD='\033[0;1m' + +# check if jupyterlab is installed +if ! command -v jupyter-lab > /dev/null 2>&1; then + # install jupyterlab + check_available_installer + printf "$${BOLD}Installing jupyterlab!\n" + case $INSTALLER in + uv) + uv pip install -q jupyterlab \ + && printf "%s\n" "🥳 jupyterlab has been installed" + JUPYTERPATH="$HOME/.venv/bin/" + ;; + pipx) + pipx install jupyterlab \ + && printf "%s\n" "🥳 jupyterlab has been installed" + JUPYTERPATH="$HOME/.local/bin" + ;; + esac +else + printf "%s\n\n" "🥳 jupyterlab is already installed" +fi + +printf "👷 Starting jupyterlab in background..." +printf "check logs at ${LOG_PATH}" +$JUPYTERPATH/jupyter-lab --no-browser \ + "$BASE_URL_FLAG" \ + --ServerApp.ip='*' \ + --ServerApp.port="${PORT}" \ + --ServerApp.token='' \ + --ServerApp.password='' \ + > "${LOG_PATH}" 2>&1 & diff --git a/registry/coder/modules/kasmvnc/README.md b/registry/coder/modules/kasmvnc/README.md new file mode 100644 index 0000000..8616980 --- /dev/null +++ b/registry/coder/modules/kasmvnc/README.md @@ -0,0 +1,24 @@ +--- +display_name: KasmVNC +description: A modern open source VNC server +icon: ../../../../.icons/kasmvnc.svg +maintainer_github: coder +verified: true +tags: [helper, vnc, desktop] +--- + +# KasmVNC + +Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and create an app to access it via the dashboard. + +```tf +module "kasmvnc" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/kasmvnc/coder" + version = "1.0.23" + agent_id = coder_agent.example.id + desktop_environment = "xfce" +} +``` + +> **Note:** This module only works on workspaces with a pre-installed desktop environment. As an example base image you can use `codercom/enterprise-desktop` image. diff --git a/registry/coder/modules/kasmvnc/main.test.ts b/registry/coder/modules/kasmvnc/main.test.ts new file mode 100644 index 0000000..8ec5721 --- /dev/null +++ b/registry/coder/modules/kasmvnc/main.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +const allowedDesktopEnvs = ["xfce", "kde", "gnome", "lxde", "lxqt"] as const; +type AllowedDesktopEnv = (typeof allowedDesktopEnvs)[number]; + +type TestVariables = Readonly<{ + agent_id: string; + desktop_environment: AllowedDesktopEnv; + port?: string; + kasm_version?: string; +}>; + +describe("Kasm VNC", async () => { + await runTerraformInit(import.meta.dir); + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + desktop_environment: "gnome", + }); + + it("Successfully installs for all expected Kasm desktop versions", async () => { + for (const v of allowedDesktopEnvs) { + const applyWithEnv = () => { + runTerraformApply(import.meta.dir, { + agent_id: "foo", + desktop_environment: v, + }); + }; + + expect(applyWithEnv).not.toThrow(); + } + }); +}); diff --git a/registry/coder/modules/kasmvnc/main.tf b/registry/coder/modules/kasmvnc/main.tf new file mode 100644 index 0000000..4265f3c --- /dev/null +++ b/registry/coder/modules/kasmvnc/main.tf @@ -0,0 +1,63 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "port" { + type = number + description = "The port to run KasmVNC on." + default = 6800 +} + +variable "kasm_version" { + type = string + description = "Version of KasmVNC to install." + default = "1.3.2" +} + +variable "desktop_environment" { + type = string + description = "Specifies the desktop environment of the workspace. This should be pre-installed on the workspace." + validation { + condition = contains(["xfce", "kde", "gnome", "lxde", "lxqt"], var.desktop_environment) + error_message = "Invalid desktop environment. Please specify a valid desktop environment." + } +} + +resource "coder_script" "kasm_vnc" { + agent_id = var.agent_id + display_name = "KasmVNC" + icon = "/icon/kasmvnc.svg" + script = templatefile("${path.module}/run.sh", { + PORT : var.port, + DESKTOP_ENVIRONMENT : var.desktop_environment, + KASM_VERSION : var.kasm_version + }) + run_on_start = true +} + +resource "coder_app" "kasm_vnc" { + agent_id = var.agent_id + slug = "kasm-vnc" + display_name = "kasmVNC" + url = "http://localhost:${var.port}" + icon = "/icon/kasmvnc.svg" + subdomain = true + share = "owner" + healthcheck { + url = "http://localhost:${var.port}/app" + interval = 5 + threshold = 5 + } +} diff --git a/registry/coder/modules/kasmvnc/run.sh b/registry/coder/modules/kasmvnc/run.sh new file mode 100644 index 0000000..c285b05 --- /dev/null +++ b/registry/coder/modules/kasmvnc/run.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash + +# Exit on error, undefined variables, and pipe failures +set -euo pipefail + +# Function to check if vncserver is already installed +check_installed() { + if command -v vncserver &> /dev/null; then + echo "vncserver is already installed." + return 0 # Don't exit, just indicate it's installed + else + return 1 # Indicates not installed + fi +} + +# Function to download a file using wget, curl, or busybox as a fallback +download_file() { + local url="$1" + local output="$2" + local download_tool + + if command -v curl &> /dev/null; then + # shellcheck disable=SC2034 + download_tool=(curl -fsSL) + elif command -v wget &> /dev/null; then + # shellcheck disable=SC2034 + download_tool=(wget -q -O-) + elif command -v busybox &> /dev/null; then + # shellcheck disable=SC2034 + download_tool=(busybox wget -O-) + else + echo "ERROR: No download tool available (curl, wget, or busybox required)" + exit 1 + fi + + # shellcheck disable=SC2288 + "$${download_tool[@]}" "$url" > "$output" || { + echo "ERROR: Failed to download $url" + exit 1 + } +} + +# Function to install kasmvncserver for debian-based distros +install_deb() { + local url=$1 + local kasmdeb="/tmp/kasmvncserver.deb" + + download_file "$url" "$kasmdeb" + + CACHE_DIR="/var/lib/apt/lists/partial" + # Check if the directory exists and was modified in the last 60 minutes + if [[ ! -d "$CACHE_DIR" ]] || ! find "$CACHE_DIR" -mmin -60 -print -quit &> /dev/null; then + echo "Stale package cache, updating..." + # Update package cache with a 300-second timeout for dpkg lock + sudo apt-get -o DPkg::Lock::Timeout=300 -qq update + fi + + DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests "$kasmdeb" + rm "$kasmdeb" +} + +# Function to install kasmvncserver for rpm-based distros +install_rpm() { + local url=$1 + local kasmrpm="/tmp/kasmvncserver.rpm" + local package_manager + + if command -v dnf &> /dev/null; then + # shellcheck disable=SC2034 + package_manager=(dnf localinstall -y) + elif command -v zypper &> /dev/null; then + # shellcheck disable=SC2034 + package_manager=(zypper install -y) + elif command -v yum &> /dev/null; then + # shellcheck disable=SC2034 + package_manager=(yum localinstall -y) + elif command -v rpm &> /dev/null; then + # Do we need to manually handle missing dependencies? + # shellcheck disable=SC2034 + package_manager=(rpm -i) + else + echo "ERROR: No supported package manager available (dnf, zypper, yum, or rpm required)" + exit 1 + fi + + download_file "$url" "$kasmrpm" + + # shellcheck disable=SC2288 + sudo "$${package_manager[@]}" "$kasmrpm" || { + echo "ERROR: Failed to install $kasmrpm" + exit 1 + } + + rm "$kasmrpm" +} + +# Function to install kasmvncserver for Alpine Linux +install_alpine() { + local url=$1 + local kasmtgz="/tmp/kasmvncserver.tgz" + + download_file "$url" "$kasmtgz" + + tar -xzf "$kasmtgz" -C /usr/local/bin/ + rm "$kasmtgz" +} + +# Detect system information +if [[ ! -f /etc/os-release ]]; then + echo "ERROR: Cannot detect OS: /etc/os-release not found" + exit 1 +fi + +# shellcheck disable=SC1091 +source /etc/os-release +distro="$ID" +distro_version="$VERSION_ID" +codename="$VERSION_CODENAME" +arch="$(uname -m)" +if [[ "$ID" == "ol" ]]; then + distro="oracle" + distro_version="$${distro_version%%.*}" +elif [[ "$ID" == "fedora" ]]; then + distro_version="$(grep -oP '\(\K[\w ]+' /etc/fedora-release | tr '[:upper:]' '[:lower:]' | tr -d ' ')" +fi + +echo "Detected Distribution: $distro" +echo "Detected Version: $distro_version" +echo "Detected Codename: $codename" +echo "Detected Architecture: $arch" + +# Map arch to package arch +case "$arch" in + x86_64) + if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then + arch="amd64" + fi + ;; + aarch64) + if [[ "$distro" =~ ^(ubuntu|debian|kali)$ ]]; then + arch="arm64" + fi + ;; + arm64) + : # This is effectively a noop + ;; + *) + echo "ERROR: Unsupported architecture: $arch" + exit 1 + ;; +esac + +# Check if vncserver is installed, and install if not +if ! check_installed; then + # Check for NOPASSWD sudo (required) + if ! command -v sudo &> /dev/null || ! sudo -n true 2> /dev/null; then + echo "ERROR: sudo NOPASSWD access required!" + exit 1 + fi + + base_url="https://github.com/kasmtech/KasmVNC/releases/download/v${KASM_VERSION}" + + echo "Installing KASM version: ${KASM_VERSION}" + case $distro in + ubuntu | debian | kali) + bin_name="kasmvncserver_$${codename}_${KASM_VERSION}_$${arch}.deb" + install_deb "$base_url/$bin_name" + ;; + oracle | fedora | opensuse) + bin_name="kasmvncserver_$${distro}_$${distro_version}_${KASM_VERSION}_$${arch}.rpm" + install_rpm "$base_url/$bin_name" + ;; + alpine) + bin_name="kasmvnc.alpine_$${distro_version//./}_$${arch}.tgz" + install_alpine "$base_url/$bin_name" + ;; + *) + echo "Unsupported distribution: $distro" + exit 1 + ;; + esac +else + echo "vncserver already installed. Skipping installation." +fi + +if command -v sudo &> /dev/null && sudo -n true 2> /dev/null; then + kasm_config_file="/etc/kasmvnc/kasmvnc.yaml" + SUDO=sudo +else + kasm_config_file="$HOME/.vnc/kasmvnc.yaml" + SUDO= + + echo "WARNING: Sudo access not available, using user config dir!" + + if [[ -f "$kasm_config_file" ]]; then + echo "WARNING: Custom user KasmVNC config exists, not overwriting!" + echo "WARNING: Ensure that you manually configure the appropriate settings." + kasm_config_file="/dev/stderr" + else + echo "WARNING: This may prevent custom user KasmVNC settings from applying!" + mkdir -p "$HOME/.vnc" + fi +fi + +echo "Writing KasmVNC config to $kasm_config_file" +$SUDO tee "$kasm_config_file" > /dev/null << EOF +network: + protocol: http + websocket_port: ${PORT} + ssl: + require_ssl: false + pem_certificate: + pem_key: + udp: + public_ip: 127.0.0.1 +EOF + +# This password is not used since we start the server without auth. +# The server is protected via the Coder session token / tunnel +# and does not listen publicly +echo -e "password\npassword\n" | vncpasswd -wo -u "$USER" + +# Start the server +printf "🚀 Starting KasmVNC server...\n" +vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > /tmp/kasmvncserver.log 2>&1 & +pid=$! + +# Wait for server to start +sleep 5 +grep -v '^[[:space:]]*$' /tmp/kasmvncserver.log | tail -n 10 +if ps -p $pid | grep -q "^$pid"; then + echo "ERROR: Failed to start KasmVNC server. Check full logs at /tmp/kasmvncserver.log" + exit 1 +fi +printf "🚀 KasmVNC server started successfully!\n" diff --git a/registry/coder/modules/personalize/README.md b/registry/coder/modules/personalize/README.md new file mode 100644 index 0000000..e7fc868 --- /dev/null +++ b/registry/coder/modules/personalize/README.md @@ -0,0 +1,21 @@ +--- +display_name: Personalize +description: Allow developers to customize their workspace on start +icon: ../../../../.icons/personalize.svg +maintainer_github: coder +verified: true +tags: [helper] +--- + +# Personalize + +Run a script on workspace start that allows developers to run custom commands to personalize their workspace. + +```tf +module "personalize" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/personalize/coder" + version = "1.0.2" + agent_id = coder_agent.example.id +} +``` diff --git a/registry/coder/modules/personalize/main.test.ts b/registry/coder/modules/personalize/main.test.ts new file mode 100644 index 0000000..98ed11d --- /dev/null +++ b/registry/coder/modules/personalize/main.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("personalize", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("warns without personalize script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "✨ \u001b[0;1mYou don't have a personalize script!", + "", + "Run \u001b[36;40;1mtouch ~/personalize && chmod +x ~/personalize\u001b[0m to create one.", + "It will run every time your workspace starts. Use it to install personal packages!", + ]); + }); +}); diff --git a/registry/coder/modules/personalize/main.tf b/registry/coder/modules/personalize/main.tf new file mode 100644 index 0000000..9de4b78 --- /dev/null +++ b/registry/coder/modules/personalize/main.tf @@ -0,0 +1,39 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "path" { + type = string + description = "The path to a script that will be ran on start enabling a user to personalize their workspace." + default = "~/personalize" +} + +variable "log_path" { + type = string + description = "The path to a log file that will contain the output of the personalize script." + default = "~/personalize.log" +} + +resource "coder_script" "personalize" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + PERSONALIZE_PATH : var.path, + }) + display_name = "Personalize" + icon = "/icon/personalize.svg" + log_path = var.log_path + run_on_start = true + start_blocks_login = true +} diff --git a/registry/coder/modules/personalize/run.sh b/registry/coder/modules/personalize/run.sh new file mode 100644 index 0000000..dacaf48 --- /dev/null +++ b/registry/coder/modules/personalize/run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' +CODE='\033[36;40;1m' +RESET='\033[0m' +SCRIPT="${PERSONALIZE_PATH}" +SCRIPT="$${SCRIPT/#\~/$${HOME}}" + +# If the personalize script doesn't exist, educate +# the user how they can customize their environment! +if [ ! -f $SCRIPT ]; then + printf "✨ $${BOLD}You don't have a personalize script!\n\n" + printf "Run $${CODE}touch $${SCRIPT} && chmod +x $${SCRIPT}$${RESET} to create one.\n" + printf "It will run every time your workspace starts. Use it to install personal packages!\n\n" + exit 0 +fi + +# Check if the personalize script is executable, if not, +# try to make it executable and educate the user if it fails. +if [ ! -x $SCRIPT ]; then + echo "🔐 Your personalize script isn't executable!" + printf "Run $CODE\`chmod +x $SCRIPT\`$RESET to make it executable.\n" + exit 0 +fi + +# Run the personalize script! +$SCRIPT diff --git a/registry/coder/modules/slackme/README.md b/registry/coder/modules/slackme/README.md new file mode 100644 index 0000000..d28862c --- /dev/null +++ b/registry/coder/modules/slackme/README.md @@ -0,0 +1,85 @@ +--- +display_name: Slack Me +description: Send a Slack message when a command finishes inside a workspace! +icon: ../../../../.icons/slack.svg +maintainer_github: coder +verified: true +tags: [helper] +--- + +# Slack Me + +Add the `slackme` command to your workspace that DMs you on Slack when your command finishes running. + +```bash +slackme npm run long-build +``` + +## Setup + +1. Navigate to [Create a Slack App](https://api.slack.com/apps?new_app=1) and select "From an app manifest". Select a workspace and paste in the following manifest, adjusting the redirect URL to your Coder deployment: + + ```json + { + "display_information": { + "name": "Command Notify", + "description": "Notify developers when commands finish running inside Coder!", + "background_color": "#1b1b1c" + }, + "features": { + "bot_user": { + "display_name": "Command Notify" + } + }, + "oauth_config": { + "redirect_urls": [ + "https:///external-auth/slack/callback" + ], + "scopes": { + "bot": ["chat:write"] + } + } + } + ``` + +2. In the "Basic Information" tab on the left after creating your app, scroll down to the "App Credentials" section. Set the following environment variables in your Coder deployment: + + ```env + CODER_EXTERNAL_AUTH_1_TYPE=slack + CODER_EXTERNAL_AUTH_1_SCOPES="chat:write" + CODER_EXTERNAL_AUTH_1_DISPLAY_NAME="Slack Me" + CODER_EXTERNAL_AUTH_1_CLIENT_ID=" + CODER_EXTERNAL_AUTH_1_CLIENT_SECRET="" + ``` + +3. Restart your Coder deployment. Any Template can now import the Slack Me module, and `slackme` will be available on the `$PATH`: + + ```tf + module "slackme" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/slackme/coder" + version = "1.0.2" + agent_id = coder_agent.example.id + auth_provider_id = "slack" + } + ``` + +## Examples + +### Custom Slack Message + +- `$COMMAND` is replaced with the command the user executed. +- `$DURATION` is replaced with a human-readable duration the command took to execute. + +```tf +module "slackme" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/slackme/coder" + version = "1.0.2" + agent_id = coder_agent.example.id + auth_provider_id = "slack" + slack_message = < { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + auth_provider_id: "foo", + }); + + it("writes to path as executable", async () => { + const { instance, id } = await setupContainer(); + await writeCoder(id, "exit 0"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, ["sh", "-c", "which slackme"]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout.trim()).toEqual("/usr/bin/slackme"); + }); + + it("prints usage with no command", async () => { + const { instance, id } = await setupContainer(); + await writeCoder(id, "echo 👋"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, ["sh", "-c", "slackme"]); + expect(exec.stdout.trim()).toStartWith( + "slackme — Send a Slack notification when a command finishes", + ); + }); + + it("displays url when not authenticated", async () => { + const { instance, id } = await setupContainer(); + await writeCoder(id, "echo 'some-url' && exit 1"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, ["sh", "-c", "slackme echo test"]); + expect(exec.stdout.trim()).toEndWith("some-url"); + }); + + it("default output", async () => { + await assertSlackMessage({ + command: "echo test", + durationMS: 2, + output: "👨‍💻 `echo test` completed in 2ms", + }); + }); + + it("formats multiline message", async () => { + await assertSlackMessage({ + command: "echo test", + format: `this command: +\`$COMMAND\` +executed`, + output: `this command: +\`echo test\` +executed`, + }); + }); + + it("formats execution with milliseconds", async () => { + await assertSlackMessage({ + command: "echo test", + format: "$COMMAND took $DURATION", + durationMS: 150, + output: "echo test took 150ms", + }); + }); + + it("formats execution with seconds", async () => { + await assertSlackMessage({ + command: "echo test", + format: "$COMMAND took $DURATION", + durationMS: 15000, + output: "echo test took 15.0s", + }); + }); + + it("formats execution with minutes", async () => { + await assertSlackMessage({ + command: "echo test", + format: "$COMMAND took $DURATION", + durationMS: 120000, + output: "echo test took 2m 0.0s", + }); + }); + + it("formats execution with hours", async () => { + await assertSlackMessage({ + command: "echo test", + format: "$COMMAND took $DURATION", + durationMS: 60000 * 60, + output: "echo test took 1hr 0m 0.0s", + }); + }); +}); + +const setupContainer = async ( + image = "alpine", + vars: Record = {}, +) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + auth_provider_id: "foo", + ...vars, + }); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + return { id, instance }; +}; + +const assertSlackMessage = async (opts: { + command: string; + format?: string; + durationMS?: number; + output: string; +}) => { + // Have to use non-null assertion because TS can't tell when the fetch + // function will run + let url!: URL; + + const fakeSlackHost = serve({ + fetch: (req) => { + url = new URL(req.url); + if (url.pathname === "/api/chat.postMessage") + return createJSONResponse({ + ok: true, + }); + return createJSONResponse({}, 404); + }, + port: 0, + }); + + const { instance, id } = await setupContainer( + "alpine/curl", + opts.format ? { slack_message: opts.format } : undefined, + ); + + await writeCoder(id, "echo 'token'"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + + exec = await execContainer(id, [ + "sh", + "-c", + `DURATION_MS=${opts.durationMS || 0} SLACK_URL="http://${ + fakeSlackHost.hostname + }:${fakeSlackHost.port}" slackme ${opts.command}`, + ]); + + expect(exec.stderr.trim()).toBe(""); + expect(url.pathname).toEqual("/api/chat.postMessage"); + expect(url.searchParams.get("channel")).toEqual("token"); + expect(url.searchParams.get("text")).toEqual(opts.output); +}; diff --git a/registry/coder/modules/slackme/main.tf b/registry/coder/modules/slackme/main.tf new file mode 100644 index 0000000..5fe948e --- /dev/null +++ b/registry/coder/modules/slackme/main.tf @@ -0,0 +1,46 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "auth_provider_id" { + type = string + description = "The ID of an external auth provider." +} + +variable "slack_message" { + type = string + description = "The message to send to Slack." + default = "👨‍💻 `$COMMAND` completed in $DURATION" +} + +resource "coder_script" "install_slackme" { + agent_id = var.agent_id + display_name = "install_slackme" + run_on_start = true + script = < $CODER_DIR/slackme < + +Example: slackme npm run long-build +EOF +} + +pretty_duration() { + local duration_ms=$1 + + # If the duration is less than 1 second, display in milliseconds + if [ $duration_ms -lt 1000 ]; then + echo "$${duration_ms}ms" + return + fi + + # Convert the duration to seconds + local duration_sec=$((duration_ms / 1000)) + local remaining_ms=$((duration_ms % 1000)) + + # If the duration is less than 1 minute, display in seconds (with ms) + if [ $duration_sec -lt 60 ]; then + echo "$${duration_sec}.$${remaining_ms}s" + return + fi + + # Convert the duration to minutes + local duration_min=$((duration_sec / 60)) + local remaining_sec=$((duration_sec % 60)) + + # If the duration is less than 1 hour, display in minutes and seconds + if [ $duration_min -lt 60 ]; then + echo "$${duration_min}m $${remaining_sec}.$${remaining_ms}s" + return + fi + + # Convert the duration to hours + local duration_hr=$((duration_min / 60)) + local remaining_min=$((duration_min % 60)) + + # Display in hours, minutes, and seconds + echo "$${duration_hr}hr $${remaining_min}m $${remaining_sec}.$${remaining_ms}s" +} + +if [ $# -eq 0 ]; then + usage + exit 1 +fi + +BOT_TOKEN=$(coder external-auth access-token $PROVIDER_ID) +if [ $? -ne 0 ]; then + printf "Authenticate with Slack to be notified when a command finishes:\n$BOT_TOKEN\n" + exit 1 +fi + +USER_ID=$(coder external-auth access-token $PROVIDER_ID --extra "authed_user.id") +if [ $? -ne 0 ]; then + printf "Failed to get authenticated user ID:\n$USER_ID\n" + exit 1 +fi + +START=$(date +%s%N) +# Run all arguments as a command +$@ +END=$(date +%s%N) +DURATION_MS=$${DURATION_MS:-$(((END - START) / 1000000))} +PRETTY_DURATION=$(pretty_duration $DURATION_MS) + +set -e +COMMAND=$(echo $@) +SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$COMMAND|$COMMAND|g") +SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$DURATION|$PRETTY_DURATION|g") + +curl --silent -o /dev/null --header "Authorization: Bearer $BOT_TOKEN" \ + -G --data-urlencode "text=$${SLACK_MESSAGE}" \ + "$SLACK_URL/api/chat.postMessage?channel=$USER_ID&pretty=1" diff --git a/registry/coder/modules/vault-github/README.md b/registry/coder/modules/vault-github/README.md new file mode 100644 index 0000000..b04d0c2 --- /dev/null +++ b/registry/coder/modules/vault-github/README.md @@ -0,0 +1,83 @@ +--- +display_name: Hashicorp Vault Integration (GitHub) +description: Authenticates with Vault using GitHub +icon: ../../../../.icons/vault.svg +maintainer_github: coder +partner_github: hashicorp +verified: true +tags: [helper, integration, vault, github] +--- + +# Hashicorp Vault Integration (GitHub) + +This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces using [external auth](https://coder.com/docs/v2/latest/admin/external-auth) for GitHub. + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-github/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" +} +``` + +Then you can use the Vault CLI in your workspaces to fetch secrets from Vault: + +```shell +vault kv get -namespace=coder -mount=secrets coder +``` + +or using the Vault API: + +```shell +curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder" +``` + +![Vault login](../../.images/vault-login.png) + +## Configuration + +To configure the Vault module, you must set up a Vault GitHub auth method. See the [Vault documentation](https://www.vaultproject.io/docs/auth/github) for more information. + +## Examples + +### Configure Vault integration with a different Coder GitHub external auth ID (i.e., not the default `github`) + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-github/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + coder_github_auth_id = "my-github-auth-id" +} +``` + +### Configure Vault integration with a different Coder GitHub external auth ID and a different Vault GitHub auth path + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-github/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + coder_github_auth_id = "my-github-auth-id" + vault_github_auth_path = "my-github-auth-path" +} +``` + +### Configure Vault integration and install a specific version of the Vault CLI + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-github/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_cli_version = "1.15.0" +} +``` diff --git a/registry/coder/modules/vault-github/main.test.ts b/registry/coder/modules/vault-github/main.test.ts new file mode 100644 index 0000000..2a2af93 --- /dev/null +++ b/registry/coder/modules/vault-github/main.test.ts @@ -0,0 +1,11 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "~test"; + +describe("vault-github", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + vault_addr: "foo", + }); +}); diff --git a/registry/coder/modules/vault-github/main.tf b/registry/coder/modules/vault-github/main.tf new file mode 100644 index 0000000..286025a --- /dev/null +++ b/registry/coder/modules/vault-github/main.tf @@ -0,0 +1,68 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12.4" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "vault_addr" { + type = string + description = "The address of the Vault server." +} + +variable "coder_github_auth_id" { + type = string + description = "The ID of the GitHub external auth." + default = "github" +} + +variable "vault_github_auth_path" { + type = string + description = "The path to the GitHub auth method." + default = "github" +} + +variable "vault_cli_version" { + type = string + description = "The version of Vault to install." + default = "latest" + validation { + condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version)) + error_message = "Vault version must be in the format 0.0.0 or latest" + } +} + +data "coder_workspace" "me" {} + +resource "coder_script" "vault" { + agent_id = var.agent_id + display_name = "Vault (GitHub)" + icon = "/icon/vault.svg" + script = templatefile("${path.module}/run.sh", { + AUTH_PATH : var.vault_github_auth_path, + GITHUB_EXTERNAL_AUTH_ID : data.coder_external_auth.github.id, + INSTALL_VERSION : var.vault_cli_version, + }) + run_on_start = true + start_blocks_login = true +} + +resource "coder_env" "vault_addr" { + agent_id = var.agent_id + name = "VAULT_ADDR" + value = var.vault_addr +} + +data "coder_external_auth" "github" { + id = var.coder_github_auth_id +} diff --git a/registry/coder/modules/vault-github/run.sh b/registry/coder/modules/vault-github/run.sh new file mode 100644 index 0000000..8ca96c0 --- /dev/null +++ b/registry/coder/modules/vault-github/run.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash + +# Convert all templated variables to shell variables +INSTALL_VERSION=${INSTALL_VERSION} +GITHUB_EXTERNAL_AUTH_ID=${GITHUB_EXTERNAL_AUTH_ID} +AUTH_PATH=${AUTH_PATH} + +fetch() { + dest="$1" + url="$2" + if command -v curl > /dev/null 2>&1; then + curl -sSL --fail "$${url}" -o "$${dest}" + elif command -v wget > /dev/null 2>&1; then + wget -O "$${dest}" "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -O "$${dest}" "$${url}" + else + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + exit 1 + fi +} + +unzip_safe() { + if command -v unzip > /dev/null 2>&1; then + command unzip "$@" + elif command -v busybox > /dev/null 2>&1; then + busybox unzip "$@" + else + printf "unzip or busybox is not installed. Please install unzip in your image.\n" + exit 1 + fi +} + +install() { + # Get the architecture of the system + ARCH=$(uname -m) + if [ "$${ARCH}" = "x86_64" ]; then + ARCH="amd64" + elif [ "$${ARCH}" = "aarch64" ]; then + ARCH="arm64" + else + printf "Unsupported architecture: $${ARCH}\n" + return 1 + fi + # Fetch the latest version of Vault if INSTALL_VERSION is 'latest' + if [ "$${INSTALL_VERSION}" = "latest" ]; then + LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1) + printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}" + if [ -z "$${LATEST_VERSION}" ]; then + printf "Failed to determine the latest Vault version.\n" + return 1 + fi + INSTALL_VERSION=$${LATEST_VERSION} + fi + + # Check if the vault CLI is installed and has the correct version + installation_needed=1 + if command -v vault > /dev/null 2>&1; then + CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ "$${CURRENT_VERSION}" = "$${INSTALL_VERSION}" ]; then + printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}" + installation_needed=0 + fi + fi + + if [ $${installation_needed} -eq 1 ]; then + # Download and install Vault + if [ -z "$${CURRENT_VERSION}" ]; then + printf "Installing Vault CLI ...\n\n" + else + printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${INSTALL_VERSION}" + fi + fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_$${ARCH}.zip" + if [ $? -ne 0 ]; then + printf "Failed to download Vault.\n" + return 1 + fi + if ! unzip_safe vault.zip; then + printf "Failed to unzip Vault.\n" + return 1 + fi + rm vault.zip + if sudo mv vault /usr/local/bin/vault 2> /dev/null; then + printf "Vault installed successfully!\n\n" + else + mkdir -p ~/.local/bin + if ! mv vault ~/.local/bin/vault; then + printf "Failed to move Vault to local bin.\n" + return 1 + fi + printf "Please add ~/.local/bin to your PATH to use vault CLI.\n" + fi + fi + return 0 +} + +TMP=$(mktemp -d) +if ! ( + cd "$TMP" + install +); then + echo "Failed to install Vault CLI." + exit 1 +fi +rm -rf "$TMP" + +# Authenticate with Vault +printf "🔑 Authenticating with Vault ...\n\n" +GITHUB_TOKEN=$(coder external-auth access-token "$${GITHUB_EXTERNAL_AUTH_ID}") +if [ $? -ne 0 ]; then + printf "Authentication with Vault failed. Please check your credentials.\n" + exit 1 +fi + +# Login to vault using the GitHub token +printf "🔑 Logging in to Vault ...\n\n" +vault login -no-print -method=github -path=/$${AUTH_PATH} token="$${GITHUB_TOKEN}" +printf "🥳 Vault authentication complete!\n\n" +printf "You can now use Vault CLI to access secrets.\n" diff --git a/registry/coder/modules/vault-jwt/README.md b/registry/coder/modules/vault-jwt/README.md new file mode 100644 index 0000000..9837f90 --- /dev/null +++ b/registry/coder/modules/vault-jwt/README.md @@ -0,0 +1,81 @@ +--- +display_name: Hashicorp Vault Integration (JWT) +description: Authenticates with Vault using a JWT from Coder's OIDC provider +icon: ../../../../.icons/vault.svg +maintainer_github: coder +partner_github: hashicorp +verified: true +tags: [helper, integration, vault, jwt, oidc] +--- + +# Hashicorp Vault Integration (JWT) + +This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces by reusing the [OIDC](https://coder.com/docs/admin/users/oidc-auth) access token from Coder's OIDC authentication method. This requires configuring the Vault [JWT/OIDC](https://developer.hashicorp.com/vault/docs/auth/jwt#configuration) auth method. + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.20" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_role = "coder" # The Vault role to use for authentication +} +``` + +Then you can use the Vault CLI in your workspaces to fetch secrets from Vault: + +```shell +vault kv get -namespace=coder -mount=secrets coder +``` + +or using the Vault API: + +```shell +curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder" +``` + +## Examples + +### Configure Vault integration with a non standard auth path (default is "jwt") + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.20" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_auth_path = "oidc" + vault_jwt_role = "coder" # The Vault role to use for authentication +} +``` + +### Map workspace owner's group to a Vault role + +```tf +data "coder_workspace_owner" "me" {} + +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.20" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_role = data.coder_workspace_owner.me.groups[0] +} +``` + +### Install a specific version of the Vault CLI + +```tf +module "vault" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vault-jwt/coder" + version = "1.0.20" + agent_id = coder_agent.example.id + vault_addr = "https://vault.example.com" + vault_jwt_role = "coder" # The Vault role to use for authentication + vault_cli_version = "1.17.5" +} +``` diff --git a/registry/coder/modules/vault-jwt/main.test.ts b/registry/coder/modules/vault-jwt/main.test.ts new file mode 100644 index 0000000..5021151 --- /dev/null +++ b/registry/coder/modules/vault-jwt/main.test.ts @@ -0,0 +1,12 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "~test"; + +describe("vault-jwt", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + vault_addr: "foo", + vault_jwt_role: "foo", + }); +}); diff --git a/registry/coder/modules/vault-jwt/main.tf b/registry/coder/modules/vault-jwt/main.tf new file mode 100644 index 0000000..adcc34d --- /dev/null +++ b/registry/coder/modules/vault-jwt/main.tf @@ -0,0 +1,64 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12.4" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "vault_addr" { + type = string + description = "The address of the Vault server." +} + +variable "vault_jwt_auth_path" { + type = string + description = "The path to the Vault JWT auth method." + default = "jwt" +} + +variable "vault_jwt_role" { + type = string + description = "The name of the Vault role to use for authentication." +} + +variable "vault_cli_version" { + type = string + description = "The version of Vault to install." + default = "latest" + validation { + condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version)) + error_message = "Vault version must be in the format 0.0.0 or latest" + } +} + +resource "coder_script" "vault" { + agent_id = var.agent_id + display_name = "Vault (GitHub)" + icon = "/icon/vault.svg" + script = templatefile("${path.module}/run.sh", { + CODER_OIDC_ACCESS_TOKEN : data.coder_workspace_owner.me.oidc_access_token, + VAULT_JWT_AUTH_PATH : var.vault_jwt_auth_path, + VAULT_JWT_ROLE : var.vault_jwt_role, + VAULT_CLI_VERSION : var.vault_cli_version, + }) + run_on_start = true + start_blocks_login = true +} + +resource "coder_env" "vault_addr" { + agent_id = var.agent_id + name = "VAULT_ADDR" + value = var.vault_addr +} + +data "coder_workspace_owner" "me" {} diff --git a/registry/coder/modules/vault-jwt/run.sh b/registry/coder/modules/vault-jwt/run.sh new file mode 100644 index 0000000..ef45884 --- /dev/null +++ b/registry/coder/modules/vault-jwt/run.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +# Convert all templated variables to shell variables +VAULT_CLI_VERSION=${VAULT_CLI_VERSION} +VAULT_JWT_AUTH_PATH=${VAULT_JWT_AUTH_PATH} +VAULT_JWT_ROLE=${VAULT_JWT_ROLE} +CODER_OIDC_ACCESS_TOKEN=${CODER_OIDC_ACCESS_TOKEN} + +fetch() { + dest="$1" + url="$2" + if command -v curl > /dev/null 2>&1; then + curl -sSL --fail "$${url}" -o "$${dest}" + elif command -v wget > /dev/null 2>&1; then + wget -O "$${dest}" "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -O "$${dest}" "$${url}" + else + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + exit 1 + fi +} + +unzip_safe() { + if command -v unzip > /dev/null 2>&1; then + command unzip "$@" + elif command -v busybox > /dev/null 2>&1; then + busybox unzip "$@" + else + printf "unzip or busybox is not installed. Please install unzip in your image.\n" + exit 1 + fi +} + +install() { + # Get the architecture of the system + ARCH=$(uname -m) + if [ "$${ARCH}" = "x86_64" ]; then + ARCH="amd64" + elif [ "$${ARCH}" = "aarch64" ]; then + ARCH="arm64" + else + printf "Unsupported architecture: $${ARCH}\n" + return 1 + fi + # Fetch the latest version of Vault if VAULT_CLI_VERSION is 'latest' + if [ "$${VAULT_CLI_VERSION}" = "latest" ]; then + LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1) + printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}" + if [ -z "$${LATEST_VERSION}" ]; then + printf "Failed to determine the latest Vault version.\n" + return 1 + fi + VAULT_CLI_VERSION=$${LATEST_VERSION} + fi + + # Check if the vault CLI is installed and has the correct version + installation_needed=1 + if command -v vault > /dev/null 2>&1; then + CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ "$${CURRENT_VERSION}" = "$${VAULT_CLI_VERSION}" ]; then + printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}" + installation_needed=0 + fi + fi + + if [ $${installation_needed} -eq 1 ]; then + # Download and install Vault + if [ -z "$${CURRENT_VERSION}" ]; then + printf "Installing Vault CLI ...\n\n" + else + printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${VAULT_CLI_VERSION}" + fi + fetch vault.zip "https://releases.hashicorp.com/vault/$${VAULT_CLI_VERSION}/vault_$${VAULT_CLI_VERSION}_linux_$${ARCH}.zip" + if [ $? -ne 0 ]; then + printf "Failed to download Vault.\n" + return 1 + fi + if ! unzip_safe vault.zip; then + printf "Failed to unzip Vault.\n" + return 1 + fi + rm vault.zip + if sudo mv vault /usr/local/bin/vault 2> /dev/null; then + printf "Vault installed successfully!\n\n" + else + mkdir -p ~/.local/bin + if ! mv vault ~/.local/bin/vault; then + printf "Failed to move Vault to local bin.\n" + return 1 + fi + printf "Please add ~/.local/bin to your PATH to use vault CLI.\n" + fi + fi + return 0 +} + +TMP=$(mktemp -d) +if ! ( + cd "$TMP" + install +); then + echo "Failed to install Vault CLI." + exit 1 +fi +rm -rf "$TMP" + +# Authenticate with Vault +printf "🔑 Authenticating with Vault ...\n\n" +echo "$${CODER_OIDC_ACCESS_TOKEN}" | vault write auth/"$${VAULT_JWT_AUTH_PATH}"/login role="$${VAULT_JWT_ROLE}" jwt=- +printf "🥳 Vault authentication complete!\n\n" +printf "You can now use Vault CLI to access secrets.\n" diff --git a/registry/coder/modules/vault-token/README.md b/registry/coder/modules/vault-token/README.md new file mode 100644 index 0000000..96d32f4 --- /dev/null +++ b/registry/coder/modules/vault-token/README.md @@ -0,0 +1,83 @@ +--- +display_name: Hashicorp Vault Integration (Token) +description: Authenticates with Vault using Token +icon: ../../../../.icons/vault.svg +maintainer_github: coder +partner_github: hashicorp +verified: true +tags: [helper, integration, vault, token] +--- + +# Hashicorp Vault Integration (Token) + +This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces using a [Vault token](https://developer.hashicorp.com/vault/docs/auth/token). + +```tf +variable "vault_token" { + type = string + description = "The Vault token to use for authentication." + sensitive = true +} + +module "vault" { + source = "registry.coder.com/modules/vault-token/coder" + version = "1.0.7" + agent_id = coder_agent.example.id + vault_token = var.token + vault_addr = "https://vault.example.com" +} +``` + +Then you can use the Vault CLI in your workspaces to fetch secrets from Vault: + +```shell +vault kv get -namespace=coder -mount=secrets coder +``` + +or using the Vault API: + +```shell +curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder" +``` + +## Configuration + +To configure the Vault module, you must create a Vault token with the the required permissions and configure the module with the token and Vault address. + +1. Create a vault policy with read access to the secret mount you need your developers to access. + ```shell + vault policy write read-coder-secrets - < { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + vault_addr: "foo", + vault_token: "foo", + }); +}); diff --git a/registry/coder/modules/vault-token/main.tf b/registry/coder/modules/vault-token/main.tf new file mode 100644 index 0000000..94517d1 --- /dev/null +++ b/registry/coder/modules/vault-token/main.tf @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12.4" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "vault_addr" { + type = string + description = "The address of the Vault server." +} + +variable "vault_token" { + type = string + description = "The Vault token to use for authentication." + sensitive = true +} + +variable "vault_cli_version" { + type = string + description = "The version of Vault to install." + default = "latest" + validation { + condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version)) + error_message = "Vault version must be in the format 0.0.0 or latest" + } +} + +data "coder_workspace" "me" {} + +resource "coder_script" "vault" { + agent_id = var.agent_id + display_name = "Vault (Token)" + icon = "/icon/vault.svg" + script = templatefile("${path.module}/run.sh", { + INSTALL_VERSION : var.vault_cli_version, + }) + run_on_start = true + start_blocks_login = true +} + +resource "coder_env" "vault_addr" { + agent_id = var.agent_id + name = "VAULT_ADDR" + value = var.vault_addr +} + +resource "coder_env" "vault_token" { + agent_id = var.agent_id + name = "VAULT_TOKEN" + value = var.vault_token +} diff --git a/registry/coder/modules/vault-token/run.sh b/registry/coder/modules/vault-token/run.sh new file mode 100644 index 0000000..e1da6ee --- /dev/null +++ b/registry/coder/modules/vault-token/run.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +# Convert all templated variables to shell variables +INSTALL_VERSION=${INSTALL_VERSION} + +fetch() { + dest="$1" + url="$2" + if command -v curl > /dev/null 2>&1; then + curl -sSL --fail "$${url}" -o "$${dest}" + elif command -v wget > /dev/null 2>&1; then + wget -O "$${dest}" "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -O "$${dest}" "$${url}" + else + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + return 1 + fi +} + +unzip_safe() { + if command -v unzip > /dev/null 2>&1; then + command unzip "$@" + elif command -v busybox > /dev/null 2>&1; then + busybox unzip "$@" + else + printf "unzip or busybox is not installed. Please install unzip in your image.\n" + return 1 + fi +} + +install() { + # Get the architecture of the system + ARCH=$(uname -m) + if [ "$${ARCH}" = "x86_64" ]; then + ARCH="amd64" + elif [ "$${ARCH}" = "aarch64" ]; then + ARCH="arm64" + else + printf "Unsupported architecture: $${ARCH}\n" + return 1 + fi + # Fetch the latest version of Vault if INSTALL_VERSION is 'latest' + if [ "$${INSTALL_VERSION}" = "latest" ]; then + LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1) + printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}" + if [ -z "$${LATEST_VERSION}" ]; then + printf "Failed to determine the latest Vault version.\n" + return 1 + fi + INSTALL_VERSION=$${LATEST_VERSION} + fi + + # Check if the vault CLI is installed and has the correct version + installation_needed=1 + if command -v vault > /dev/null 2>&1; then + CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ "$${CURRENT_VERSION}" = "$${INSTALL_VERSION}" ]; then + printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}" + installation_needed=0 + fi + fi + + if [ $${installation_needed} -eq 1 ]; then + # Download and install Vault + if [ -z "$${CURRENT_VERSION}" ]; then + printf "Installing Vault CLI ...\n\n" + else + printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${INSTALL_VERSION}" + fi + fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_amd64.zip" + if [ $? -ne 0 ]; then + printf "Failed to download Vault.\n" + return 1 + fi + if ! unzip_safe vault.zip; then + printf "Failed to unzip Vault.\n" + return 1 + fi + rm vault.zip + if sudo mv vault /usr/local/bin/vault 2> /dev/null; then + printf "Vault installed successfully!\n\n" + else + mkdir -p ~/.local/bin + if ! mv vault ~/.local/bin/vault; then + printf "Failed to move Vault to local bin.\n" + return 1 + fi + printf "Please add ~/.local/bin to your PATH to use vault CLI.\n" + fi + fi + return 0 +} + +TMP=$(mktemp -d) +if ! ( + cd "$TMP" + install +); then + echo "Failed to install Vault CLI." + exit 1 +fi +rm -rf "$TMP" diff --git a/registry/coder/modules/vscode-desktop/README.md b/registry/coder/modules/vscode-desktop/README.md new file mode 100644 index 0000000..2714153 --- /dev/null +++ b/registry/coder/modules/vscode-desktop/README.md @@ -0,0 +1,37 @@ +--- +display_name: VS Code Desktop +description: Add a one-click button to launch VS Code Desktop +icon: ../../../../.icons/code.svg +maintainer_github: coder +verified: true +tags: [ide, vscode, helper] +--- + +# VS Code Desktop + +Add a button to open any workspace with a single click. + +Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder). + +```tf +module "vscode" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-desktop/coder" + version = "1.0.15" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "vscode" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-desktop/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` diff --git a/registry/coder/modules/vscode-desktop/main.test.ts b/registry/coder/modules/vscode-desktop/main.test.ts new file mode 100644 index 0000000..b59ef5d --- /dev/null +++ b/registry/coder/modules/vscode-desktop/main.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("vscode-desktop", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "vscode", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("adds folder", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder and open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + open_recent: "true", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder but not open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + openRecent: "false", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + open_recent: "true", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("expect order to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "vscode", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); +}); diff --git a/registry/coder/modules/vscode-desktop/main.tf b/registry/coder/modules/vscode-desktop/main.tf new file mode 100644 index 0000000..16d070b --- /dev/null +++ b/registry/coder/modules/vscode-desktop/main.tf @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "folder" { + type = string + description = "The folder to open in VS Code." + default = "" +} + +variable "open_recent" { + type = bool + description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." + default = false +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_app" "vscode" { + agent_id = var.agent_id + external = true + icon = "/icon/code.svg" + slug = "vscode" + display_name = "VS Code Desktop" + order = var.order + url = join("", [ + "vscode://coder.coder-remote/open", + "?owner=", + data.coder_workspace_owner.me.name, + "&workspace=", + data.coder_workspace.me.name, + var.folder != "" ? join("", ["&folder=", var.folder]) : "", + var.open_recent ? "&openRecent" : "", + "&url=", + data.coder_workspace.me.access_url, + "&token=$SESSION_TOKEN", + ]) +} + +output "vscode_url" { + value = coder_app.vscode.url + description = "VS Code Desktop URL." +} diff --git a/registry/coder/modules/vscode-web/README.md b/registry/coder/modules/vscode-web/README.md new file mode 100644 index 0000000..ca49d72 --- /dev/null +++ b/registry/coder/modules/vscode-web/README.md @@ -0,0 +1,86 @@ +--- +display_name: VS Code Web +description: VS Code Web - Visual Studio Code in the browser +icon: ../../../../.icons/code.svg +maintainer_github: coder +verified: true +tags: [helper, ide, vscode, web] +--- + +# VS Code Web + +Automatically install [Visual Studio Code Server](https://code.visualstudio.com/docs/remote/vscode-server) in a workspace and create an app to access it via the dashboard. + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + accept_license = true +} +``` + +![VS Code Web with GitHub Copilot and live-share](../../.images/vscode-web.gif) + +## Examples + +### Install VS Code Web to a custom folder + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + install_prefix = "/home/coder/.vscode-web" + folder = "/home/coder" + accept_license = true +} +``` + +### Install Extensions + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] + accept_license = true +} +``` + +### Pre-configure Settings + +Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file: + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + extensions = ["dracula-theme.theme-dracula"] + settings = { + "workbench.colorTheme" = "Dracula" + } + accept_license = true +} +``` + +### Pin a specific VS Code Web version + +By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases). + +```tf +module "vscode-web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/vscode-web/coder" + version = "1.0.30" + agent_id = coder_agent.example.id + commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" + accept_license = true +} +``` diff --git a/registry/coder/modules/vscode-web/main.test.ts b/registry/coder/modules/vscode-web/main.test.ts new file mode 100644 index 0000000..860fc17 --- /dev/null +++ b/registry/coder/modules/vscode-web/main.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test"; +import { runTerraformApply, runTerraformInit } from "~test"; + +describe("vscode-web", async () => { + await runTerraformInit(import.meta.dir); + + it("accept_license should be set to true", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "false", + }); + }; + expect(t).toThrow("Invalid value for variable"); + }); + + it("use_cached and offline can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "true", + use_cached: "true", + offline: "true", + }); + }; + expect(t).toThrow("Offline and Use Cached can not be used together"); + }); + + it("offline and extensions can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "true", + offline: "true", + extensions: '["1", "2"]', + }); + }; + expect(t).toThrow("Offline mode does not allow extensions to be installed"); + }); + + // More tests depend on shebang refactors +}); diff --git a/registry/coder/modules/vscode-web/main.tf b/registry/coder/modules/vscode-web/main.tf new file mode 100644 index 0000000..11e220c --- /dev/null +++ b/registry/coder/modules/vscode-web/main.tf @@ -0,0 +1,198 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "port" { + type = number + description = "The port to run VS Code Web on." + default = 13338 +} + +variable "display_name" { + type = string + description = "The display name for the VS Code Web application." + default = "VS Code Web" +} + +variable "slug" { + type = string + description = "The slug for the VS Code Web application." + default = "vscode-web" +} + +variable "folder" { + type = string + description = "The folder to open in vscode-web." + default = "" +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "log_path" { + type = string + description = "The path to log." + default = "/tmp/vscode-web.log" +} + +variable "install_prefix" { + type = string + description = "The prefix to install vscode-web to." + default = "/tmp/vscode-web" +} + +variable "commit_id" { + type = string + description = "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used." + default = "" +} + +variable "extensions" { + type = list(string) + description = "A list of extensions to install." + default = [] +} + +variable "accept_license" { + type = bool + description = "Accept the VS Code Server license. https://code.visualstudio.com/license/server" + default = false + validation { + condition = var.accept_license == true + error_message = "You must accept the VS Code license agreement by setting accept_license=true." + } +} + +variable "telemetry_level" { + type = string + description = "Set the telemetry level for VS Code Web." + default = "error" + validation { + condition = var.telemetry_level == "off" || var.telemetry_level == "crash" || var.telemetry_level == "error" || var.telemetry_level == "all" + error_message = "Incorrect value. Please set either 'off', 'crash', 'error', or 'all'." + } +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "settings" { + type = any + description = "A map of settings to apply to VS Code web." + default = {} +} + +variable "offline" { + type = bool + description = "Just run VS Code Web in the background, don't fetch it from the internet." + default = false +} + +variable "use_cached" { + type = bool + description = "Uses cached copy of VS Code Web in the background, otherwise fetches it from internet." + default = false +} + +variable "extensions_dir" { + type = string + description = "Override the directory to store extensions in." + default = "" +} + +variable "auto_install_extensions" { + type = bool + description = "Automatically install recommended extensions when VS Code Web starts." + default = false +} + +variable "subdomain" { + type = bool + description = <<-EOT + Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder. + If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible. + EOT + default = true +} + +data "coder_workspace_owner" "me" {} +data "coder_workspace" "me" {} + +resource "coder_script" "vscode-web" { + agent_id = var.agent_id + display_name = "VS Code Web" + icon = "/icon/code.svg" + script = templatefile("${path.module}/run.sh", { + PORT : var.port, + LOG_PATH : var.log_path, + INSTALL_PREFIX : var.install_prefix, + EXTENSIONS : join(",", var.extensions), + TELEMETRY_LEVEL : var.telemetry_level, + // This is necessary otherwise the quotes are stripped! + SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), + OFFLINE : var.offline, + USE_CACHED : var.use_cached, + EXTENSIONS_DIR : var.extensions_dir, + FOLDER : var.folder, + AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, + SERVER_BASE_PATH : local.server_base_path, + COMMIT_ID : var.commit_id, + }) + run_on_start = true + + lifecycle { + precondition { + condition = !var.offline || length(var.extensions) == 0 + error_message = "Offline mode does not allow extensions to be installed" + } + + precondition { + condition = !var.offline || !var.use_cached + error_message = "Offline and Use Cached can not be used together" + } + } +} + +resource "coder_app" "vscode-web" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = local.url + icon = "/icon/code.svg" + subdomain = var.subdomain + share = var.share + order = var.order + + healthcheck { + url = local.healthcheck_url + interval = 5 + threshold = 6 + } +} + +locals { + server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug) + url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}" + healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz" +} diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh new file mode 100644 index 0000000..588cec5 --- /dev/null +++ b/registry/coder/modules/vscode-web/run.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' +EXTENSIONS=("${EXTENSIONS}") +VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server" + +# Set extension directory +EXTENSION_ARG="" +if [ -n "${EXTENSIONS_DIR}" ]; then + EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" +fi + +# Set extension directory +SERVER_BASE_PATH_ARG="" +if [ -n "${SERVER_BASE_PATH}" ]; then + SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}" +fi + +run_vscode_web() { + echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..." + echo "Check logs at ${LOG_PATH}!" + "$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 & +} + +# Check if the settings file exists... +if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then + echo "⚙️ Creating settings file..." + mkdir -p ~/.vscode-server/data/Machine + echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json +fi + +# Check if vscode-server is already installed for offline or cached mode +if [ -f "$VSCODE_WEB" ]; then + if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then + echo "🥳 Found a copy of VS Code Web" + run_vscode_web + exit 0 + fi +fi +# Offline mode always expects a copy of vscode-server to be present +if [ "${OFFLINE}" = true ]; then + echo "Failed to find a copy of VS Code Web" + exit 1 +fi + +# Create install prefix +mkdir -p ${INSTALL_PREFIX} + +printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n" + +# Download and extract vscode-server +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="x64" ;; + aarch64) ARCH="arm64" ;; + *) + echo "Unsupported architecture" + exit 1 + ;; +esac + +# Check if a specific VS Code Web commit ID was provided +if [ -n "${COMMIT_ID}" ]; then + HASH="${COMMIT_ID}" +else + HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-linux-$ARCH-web | cut -d '"' -f 2) +fi +printf "$${BOLD}VS Code Web commit id version $HASH.\n" + +output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-linux-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1) + +if [ $? -ne 0 ]; then + echo "Failed to install Microsoft Visual Studio Code Server: $output" + exit 1 +fi +printf "$${BOLD}VS Code Web has been installed.\n" + +# Install each extension... +IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" +for extension in "$${EXTENSIONLIST[@]}"; do + if [ -z "$extension" ]; then + continue + fi + printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n" + output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force) + if [ $? -ne 0 ]; then + echo "Failed to install extension: $extension: $output" + fi +done + +if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then + if ! command -v jq > /dev/null; then + echo "jq is required to install extensions from a workspace file." + else + WORKSPACE_DIR="$HOME" + if [ -n "${FOLDER}" ]; then + WORKSPACE_DIR="${FOLDER}" + fi + + if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then + printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" + # Use sed to remove single-line comments before parsing with jq + extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]') + for extension in $extensions; do + $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force + done + fi + fi +fi + +run_vscode_web diff --git a/registry/coder/modules/windows-rdp/README.md b/registry/coder/modules/windows-rdp/README.md new file mode 100644 index 0000000..0d4d57b --- /dev/null +++ b/registry/coder/modules/windows-rdp/README.md @@ -0,0 +1,57 @@ +--- +display_name: Windows RDP +description: RDP Server and Web Client, powered by Devolutions Gateway +icon: ../../../../.icons/desktop.svg +maintainer_github: coder +verified: true +tags: [windows, rdp, web, desktop] +--- + +# Windows RDP + +Enable Remote Desktop + a web based client on Windows workspaces, powered by [devolutions-gateway](https://github.com/Devolutions/devolutions-gateway). + +```tf +# AWS example. See below for examples of using this module with other providers +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windows-rdp/coder" + version = "1.0.18" + agent_id = resource.coder_agent.main.id + resource_id = resource.aws_instance.dev.id +} +``` + +## Video + +[![Video](./video-thumbnails/video-thumbnail.png)](https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a4be02) + +## Examples + +### With AWS + +```tf +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windows-rdp/coder" + version = "1.0.18" + agent_id = resource.coder_agent.main.id + resource_id = resource.aws_instance.dev.id +} +``` + +### With Google Cloud + +```tf +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/windows-rdp/coder" + version = "1.0.18" + agent_id = resource.coder_agent.main.id + resource_id = resource.google_compute_instance.dev[0].id +} +``` + +## Roadmap + +- [ ] Test on Microsoft Azure. diff --git a/registry/coder/modules/windows-rdp/devolutions-patch.js b/registry/coder/modules/windows-rdp/devolutions-patch.js new file mode 100644 index 0000000..ef73645 --- /dev/null +++ b/registry/coder/modules/windows-rdp/devolutions-patch.js @@ -0,0 +1,425 @@ +// @ts-check +/** + * @file Defines the custom logic for patching in UI changes/behavior into the + * base Devolutions Gateway Angular app. + * + * Defined as a JS file to remove the need to have a separate compilation step. + * It is highly recommended that you work on this file from within VS Code so + * that you can take advantage of the @ts-check directive and get some type- + * checking still. + * + * Other notes about the weird ways this file is set up: + * - A lot of the HTML selectors in this file will look nonstandard. This is + * because they are actually custom Angular components. + * - It is strongly advised that you avoid template literals that use the + * placeholder syntax via the dollar sign. The Terraform file is treating this + * as a template file, and because it also uses a similar syntax, there's a + * risk that some values will trigger false positives. If a template literal + * must be used, be sure to use a double dollar sign to escape things. + * - All the CSS should be written via custom style tags and the !important + * directive (as much as that is a bad idea most of the time). We do not + * control the Angular app, so we have to modify things from afar to ensure + * that as Angular's internal state changes, it doesn't modify its HTML nodes + * in a way that causes our custom styles to get wiped away. + * + * @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry + * @typedef {Readonly>} FormFieldEntries + */ + +/** + * The communication protocol to set Devolutions to. + */ +const PROTOCOL = "RDP"; + +/** + * The hostname to use with Devolutions. + */ +const HOSTNAME = "localhost"; + +/** + * How often to poll the screen for the main Devolutions form. + */ +const SCREEN_POLL_INTERVAL_MS = 500; + +/** + * The fields in the Devolutions sign-in form that should be populated with + * values from the Coder workspace. + * + * All properties should be defined as placeholder templates in the form + * VALUE_NAME. The Coder module, when spun up, should then run some logic to + * replace the template slots with actual values. These values should never + * change from within JavaScript itself. + * + * @satisfies {FormFieldEntries} + */ +const formFieldEntries = { + /** @readonly */ + username: { + /** @readonly */ + querySelector: "web-client-username-control input", + + /** @readonly */ + value: "${CODER_USERNAME}", + }, + + /** @readonly */ + password: { + /** @readonly */ + querySelector: "web-client-password-control input", + + /** @readonly */ + value: "${CODER_PASSWORD}", + }, +}; + +/** + * Handles typing in the values for the input form. All values are written + * immediately, even though that would be physically impossible with a real + * keyboard. + * + * Note: this code will never break, but you might get warnings in the console + * from Angular about unexpected value changes. Angular patches over a lot of + * the built-in browser APIs to support its component change detection system. + * As part of that, it has validations for checking whether an input it + * previously had control over changed without it doing anything. + * + * But the only way to simulate a keyboard input is by setting the input's + * .value property, and then firing an input event. So basically, the inner + * value will change, which Angular won't be happy about, but then the input + * event will fire and sync everything back together. + * + * @param {HTMLInputElement} inputField + * @param {string} inputText + * @returns {Promise} + */ +function setInputValue(inputField, inputText) { + return new Promise((resolve, reject) => { + // Adding timeout for input event, even though we'll be dispatching it + // immediately, just in the off chance that something in the Angular app + // intercepts it or stops it from propagating properly + const timeoutId = window.setTimeout(() => { + reject(new Error("Input event did not get processed correctly in time.")); + }, 3_000); + + const handleSuccessfulDispatch = () => { + window.clearTimeout(timeoutId); + inputField.removeEventListener("input", handleSuccessfulDispatch); + resolve(); + }; + + inputField.addEventListener("input", handleSuccessfulDispatch); + + // Code assumes that Angular will have an event handler in place to handle + // the new event + const inputEvent = new Event("input", { + bubbles: true, + cancelable: true, + }); + + inputField.value = inputText; + inputField.dispatchEvent(inputEvent); + }); +} + +/** + * Takes a Devolutions remote session form, auto-fills it with data, and then + * submits it. + * + * The logic here is more convoluted than it should be for two main reasons: + * 1. Devolutions' HTML markup has errors. There are labels, but they aren't + * bound to the inputs they're supposed to describe. This means no easy hooks + * for selecting the elements, unfortunately. + * 2. Trying to modify the .value properties on some of the inputs doesn't + * work. Probably some combo of Angular data-binding and some inputs having + * the readonly attribute. Have to simulate user input to get around this. + * + * @param {HTMLFormElement} myForm + * @returns {Promise} + */ +async function autoSubmitForm(myForm) { + const setProtocolValue = () => { + /** @type {HTMLDivElement | null} */ + const protocolDropdownTrigger = myForm.querySelector('div[role="button"]'); + if (protocolDropdownTrigger === null) { + throw new Error("No clickable trigger for setting protocol value"); + } + + protocolDropdownTrigger.click(); + + // Can't use form as container for querying the list of dropdown options, + // because the elements don't actually exist inside the form. They're placed + // in the top level of the HTML doc, and repositioned to make it look like + // they're part of the form. Avoids CSS stacking context issues, maybe? + /** @type {HTMLLIElement | null} */ + const protocolOption = document.querySelector( + // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation + 'p-dropdownitem[ng-reflect-label="' + PROTOCOL + '"] li', + ); + + if (protocolOption === null) { + throw new Error( + "Unable to find protocol option on screen that matches desired protocol", + ); + } + + protocolOption.click(); + }; + + const setHostname = () => { + /** @type {HTMLInputElement | null} */ + const hostnameInput = myForm.querySelector("p-autocomplete#hostname input"); + + if (hostnameInput === null) { + throw new Error("Unable to find field for adding hostname"); + } + + return setInputValue(hostnameInput, HOSTNAME); + }; + + const setCoderFormFieldValues = async () => { + // The RDP form will not appear on screen unless the dropdown is set to use + // the RDP protocol + const rdpSubsection = myForm.querySelector("rdp-form"); + if (rdpSubsection === null) { + throw new Error( + "Unable to find RDP subsection. Is the value of the protocol set to RDP?", + ); + } + + for (const { value, querySelector } of Object.values(formFieldEntries)) { + /** @type {HTMLInputElement | null} */ + const input = document.querySelector(querySelector); + + if (input === null) { + throw new Error( + // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation + 'Unable to element that matches query "' + querySelector + '"', + ); + } + + await setInputValue(input, value); + } + }; + + const triggerSubmission = () => { + /** @type {HTMLButtonElement | null} */ + const submitButton = myForm.querySelector( + 'p-button[ng-reflect-type="submit"] button', + ); + + if (submitButton === null) { + throw new Error("Unable to find submission button"); + } + + if (submitButton.disabled) { + throw new Error( + "Unable to submit form because submit button is disabled. Are all fields filled out correctly?", + ); + } + + submitButton.click(); + }; + + setProtocolValue(); + await setHostname(); + await setCoderFormFieldValues(); + triggerSubmission(); +} + +/** + * Sets up logic for auto-populating the form data when the form appears on + * screen. + * + * @returns {void} + */ +function setupFormDetection() { + /** @type {HTMLFormElement | null} */ + let formValueFromLastMutation = null; + + /** @returns {void} */ + const onDynamicTabMutation = () => { + /** @type {HTMLFormElement | null} */ + const latestForm = document.querySelector("web-client-form > form"); + + // Only try to auto-fill if we went from having no form on screen to + // having a form on screen. That way, we don't accidentally override the + // form if the user is trying to customize values, and this essentially + // makes the script values function as default values + const mounted = formValueFromLastMutation === null && latestForm !== null; + if (mounted) { + autoSubmitForm(latestForm); + } + + formValueFromLastMutation = latestForm; + }; + + /** @type {number | undefined} */ + let pollingId = undefined; + + /** @returns {void} */ + const checkScreenForDynamicTab = () => { + const dynamicTab = document.querySelector("web-client-dynamic-tab"); + + // Keep polling until the main content container is on screen + if (dynamicTab === null) { + return; + } + + window.clearInterval(pollingId); + + // Call the mutation callback manually, to ensure it runs at least once + onDynamicTabMutation(); + + // Having the mutation observer is kind of an extra safety net that isn't + // really expected to run that often. Most of the content in the dynamic + // tab is being rendered through Canvas, which won't trigger any mutations + // that the observer can detect + const dynamicTabObserver = new MutationObserver(onDynamicTabMutation); + dynamicTabObserver.observe(dynamicTab, { + subtree: true, + childList: true, + }); + }; + + pollingId = window.setInterval( + checkScreenForDynamicTab, + SCREEN_POLL_INTERVAL_MS, + ); +} + +/** + * Sets up custom styles for hiding default Devolutions elements that Coder + * users shouldn't need to care about. + * + * @returns {void} + */ +function setupAlwaysOnStyles() { + const styleId = "coder-patch--styles-always-on"; + // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation + const existingContainer = document.querySelector("#" + styleId); + if (existingContainer) { + return; + } + + const styleContainer = document.createElement("style"); + styleContainer.id = styleId; + styleContainer.innerHTML = ` + /* app-menu corresponds to the sidebar of the default view. */ + app-menu { + display: none !important; + } + `; + + document.head.appendChild(styleContainer); +} + +/** + * This ensures that the Devolutions login form (which by default, always shows + * up on screen when the app first launches) stays visually hidden from the user + * when they open Devolutions via the Coder module. + * + * The form will still be filled out automatically and submitted in the + * background via the rest of the logic in this file, so this function is mainly + * to help avoid screen flickering and make the overall experience feel a little + * more polished (even though it's just one giant hack). + * + * @returns {void} + */ +function hideFormForInitialSubmission() { + const styleId = "coder-patch--styles-initial-submission"; + const cssOpacityVariableName = "--coder-opacity-multiplier"; + + /** @type {HTMLStyleElement | null} */ + // biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation + let styleContainer = document.querySelector("#" + styleId); + if (!styleContainer) { + styleContainer = document.createElement("style"); + styleContainer.id = styleId; + styleContainer.innerHTML = ` + /* + Have to use opacity instead of visibility, because the element still + needs to be interactive via the script so that it can be auto-filled. + */ + :root { + /* + Can be 0 or 1. Start off invisible to avoid risks of UI flickering, + but the rest of the function should be in charge of making the form + container visible again if something goes wrong during setup. + + Double dollar sign needed to avoid Terraform script false positives + */ + $${cssOpacityVariableName}: 0; + } + + /* + web-client-form is the container for the main session form, while + the div is for the dropdown that is used for selecting the protocol. + The dropdown is not inside of the form for CSS styling reasons, so we + need to select both. + */ + web-client-form, + body > div.p-overlay { + /* + Double dollar sign needed to avoid Terraform script false positives + */ + opacity: calc(100% * var($${cssOpacityVariableName})) !important; + } + `; + + document.head.appendChild(styleContainer); + } + + // The root node being undefined should be physically impossible (if it's + // undefined, the browser itself is busted), but we need to do a type check + // here so that the rest of the function doesn't need to do type checks over + // and over. + const rootNode = document.querySelector(":root"); + if (!(rootNode instanceof HTMLHtmlElement)) { + // Remove the container entirely because if the browser is busted, who knows + // if the CSS variables can be applied correctly. Better to have something + // be a bit more ugly/painful to use, than have it be impossible to use + styleContainer.remove(); + return; + } + + // It's safe to make the form visible preemptively because Devolutions + // outputs the Windows view through an HTML canvas that it overlays on top + // of the rest of the app. Even if the form isn't hidden at the style level, + // it will still be covered up. + const restoreOpacity = () => { + rootNode.style.setProperty(cssOpacityVariableName, "1"); + }; + + // If this file gets more complicated, it might make sense to set up the + // timeout and event listener so that if one triggers, it cancels the other, + // but having restoreOpacity run more than once is a no-op for right now. + // Not a big deal if these don't get cleaned up. + + // Have the form automatically reappear no matter what, so that if something + // does break, the user isn't left out to dry + window.setTimeout(restoreOpacity, 5_000); + + /** @type {HTMLFormElement | null} */ + const form = document.querySelector("web-client-form > form"); + form?.addEventListener( + "submit", + () => { + // Not restoring opacity right away just to give the HTML canvas a little + // bit of time to get spun up and cover up the main form + window.setTimeout(restoreOpacity, 1_000); + }, + { once: true }, + ); +} + +// Always safe to call these immediately because even if the Angular app isn't +// loaded by the time the function gets called, the CSS will always be globally +// available for when Angular is finally ready +setupAlwaysOnStyles(); +hideFormForInitialSubmission(); + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setupFormDetection); +} else { + setupFormDetection(); +} diff --git a/registry/coder/modules/windows-rdp/main.test.ts b/registry/coder/modules/windows-rdp/main.test.ts new file mode 100644 index 0000000..01a7e46 --- /dev/null +++ b/registry/coder/modules/windows-rdp/main.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "bun:test"; +import { + type TerraformState, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +type TestVariables = Readonly<{ + agent_id: string; + resource_id: string; + share?: string; + admin_username?: string; + admin_password?: string; +}>; + +function findWindowsRdpScript(state: TerraformState): string | null { + for (const resource of state.resources) { + const isRdpScriptResource = + resource.type === "coder_script" && resource.name === "windows-rdp"; + + if (!isRdpScriptResource) { + continue; + } + + for (const instance of resource.instances) { + if ( + instance.attributes.display_name === "windows-rdp" && + typeof instance.attributes.script === "string" + ) { + return instance.attributes.script; + } + } + } + + return null; +} + +/** + * @todo It would be nice if we had a way to verify that the Devolutions root + * HTML file is modified to include the import for the patched Coder script, + * but the current test setup doesn't really make that viable + */ +describe("Web RDP", async () => { + await runTerraformInit(import.meta.dir); + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + resource_id: "bar", + }); + + it("Has the PowerShell script install Devolutions Gateway", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + resource_id: "bar", + }); + + const lines = findWindowsRdpScript(state) + ?.split("\n") + .filter(Boolean) + .map((line) => line.trim()); + + expect(lines).toEqual( + expect.arrayContaining([ + '$moduleName = "DevolutionsGateway"', + // Devolutions does versioning in the format year.minor.patch + expect.stringMatching(/^\$moduleVersion = "\d{4}\.\d+\.\d+"$/), + "Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force", + ]), + ); + }); + + it("Injects Terraform's username and password into the JS patch file", async () => { + /** + * Using a regex as a quick-and-dirty way to get at the username and + * password values. + * + * Tried going through the trouble of extracting out the form entries + * variable from the main output, converting it from Prettier/JS-based JSON + * text to universal JSON text, and exposing it as a parsed JSON value. That + * got to be a bit too much, though. + * + * Regex is a little bit more verbose and pedantic than normal. Want to + * have some basic safety nets for validating the structure of the form + * entries variable after the JS file has had values injected. Even with all + * the wildcard classes set to lazy mode, we want to make sure that they + * don't overshoot and grab too much content. + * + * Written and tested via Regex101 + * @see {@link https://regex101.com/r/UMgQpv/2} + */ + const formEntryValuesRe = + /^const formFieldEntries = \{$.*?^\s+username: \{$.*?^\s*?querySelector.*?,$.*?^\s*value: "(?.+?)",$.*?password: \{$.*?^\s+querySelector: .*?,$.*?^\s*value: "(?.+?)",$.*?^};$/ms; + + // Test that things work with the default username/password + const defaultState = await runTerraformApply( + import.meta.dir, + { + agent_id: "foo", + resource_id: "bar", + }, + ); + + const defaultRdpScript = findWindowsRdpScript(defaultState); + expect(defaultRdpScript).toBeString(); + + const defaultResultsGroup = + formEntryValuesRe.exec(defaultRdpScript ?? "")?.groups ?? {}; + + expect(defaultResultsGroup.username).toBe("Administrator"); + expect(defaultResultsGroup.password).toBe("coderRDP!"); + + // Test that custom usernames/passwords are also forwarded correctly + const customAdminUsername = "crouton"; + const customAdminPassword = "VeryVeryVeryVeryVerySecurePassword97!"; + const customizedState = await runTerraformApply( + import.meta.dir, + { + agent_id: "foo", + resource_id: "bar", + admin_username: customAdminUsername, + admin_password: customAdminPassword, + }, + ); + + const customRdpScript = findWindowsRdpScript(customizedState); + expect(customRdpScript).toBeString(); + + const customResultsGroup = + formEntryValuesRe.exec(customRdpScript ?? "")?.groups ?? {}; + + expect(customResultsGroup.username).toBe(customAdminUsername); + expect(customResultsGroup.password).toBe(customAdminPassword); + }); +}); diff --git a/registry/coder/modules/windows-rdp/main.tf b/registry/coder/modules/windows-rdp/main.tf new file mode 100644 index 0000000..10ece09 --- /dev/null +++ b/registry/coder/modules/windows-rdp/main.tf @@ -0,0 +1,86 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "resource_id" { + type = string + description = "The ID of the primary Coder resource (e.g. VM)." +} + +variable "admin_username" { + type = string + default = "Administrator" +} + +variable "admin_password" { + type = string + default = "coderRDP!" + sensitive = true +} + +resource "coder_script" "windows-rdp" { + agent_id = var.agent_id + display_name = "windows-rdp" + icon = "/icon/desktop.svg" + + script = templatefile("${path.module}/powershell-installation-script.tftpl", { + admin_username = var.admin_username + admin_password = var.admin_password + + # Wanted to have this be in the powershell template file, but Terraform + # doesn't allow recursive calls to the templatefile function. Have to feed + # results of the JS template replace into the powershell template + patch_file_contents = templatefile("${path.module}/devolutions-patch.js", { + CODER_USERNAME = var.admin_username + CODER_PASSWORD = var.admin_password + }) + }) + + run_on_start = true +} + +resource "coder_app" "windows-rdp" { + agent_id = var.agent_id + share = var.share + slug = "web-rdp" + display_name = "Web RDP" + url = "http://localhost:7171" + icon = "/icon/desktop.svg" + subdomain = true + + healthcheck { + url = "http://localhost:7171" + interval = 5 + threshold = 15 + } +} + +resource "coder_app" "rdp-docs" { + agent_id = var.agent_id + display_name = "Local RDP" + slug = "rdp-docs" + icon = "https://raw.githubusercontent.com/matifali/logos/main/windows.svg" + url = "https://coder.com/docs/ides/remote-desktops#rdp-desktop" + external = true +} diff --git a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl new file mode 100644 index 0000000..1b7ab48 --- /dev/null +++ b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl @@ -0,0 +1,85 @@ +function Set-AdminPassword { + param ( + [string]$adminPassword + ) + # Set admin password + Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force) + # Enable admin user + Get-LocalUser -Name "${admin_username}" | Enable-LocalUser +} + +function Configure-RDP { + # Enable RDP + New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 -PropertyType DWORD -Force + # Disable NLA + New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "UserAuthentication" -Value 0 -PropertyType DWORD -Force + New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "SecurityLayer" -Value 1 -PropertyType DWORD -Force + # Enable RDP through Windows Firewall + Enable-NetFirewallRule -DisplayGroup "Remote Desktop" +} + +function Install-DevolutionsGateway { +# Define the module name and version +$moduleName = "DevolutionsGateway" +$moduleVersion = "2024.1.5" + +# Install the module with the specified version for all users +# This requires administrator privileges +try { + # Install-PackageProvider is required for AWS. Need to set command to + # terminate on failure so that try/catch actually triggers + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop + Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force +} +catch { + # If the first command failed, assume that we're on GCP and run + # Install-Module only + Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force +} + +# Construct the module path for system-wide installation +$moduleBasePath = "C:\Windows\system32\config\systemprofile\Documents\PowerShell\Modules\$moduleName\$moduleVersion" +$modulePath = Join-Path -Path $moduleBasePath -ChildPath "$moduleName.psd1" + +# Import the module using the full path +Import-Module $modulePath +Install-DGatewayPackage + +# Configure Devolutions Gateway +$Hostname = "localhost" +$HttpListener = New-DGatewayListener 'http://*:7171' 'http://*:7171' +$WebApp = New-DGatewayWebAppConfig -Enabled $true -Authentication None +$ConfigParams = @{ + Hostname = $Hostname + Listeners = @($HttpListener) + WebApp = $WebApp +} +Set-DGatewayConfig @ConfigParams +New-DGatewayProvisionerKeyPair -Force + +# Configure and start the Windows service +Set-Service 'DevolutionsGateway' -StartupType 'Automatic' +Start-Service 'DevolutionsGateway' +} + +function Patch-Devolutions-HTML { +$root = "C:\Program Files\Devolutions\Gateway\webapp\client" +$devolutionsHtml = "$root\index.html" +$patch = '' + +# Always copy the file in case we change it. +@' +${patch_file_contents} +'@ | Set-Content "$root\coder.js" + +# Only inject the src if we have not before. +$isPatched = Select-String -Path "$devolutionsHtml" -Pattern "$patch" -SimpleMatch +if ($isPatched -eq $null) { + (Get-Content $devolutionsHtml).Replace('', "$patch") | Set-Content $devolutionsHtml +} +} + +Set-AdminPassword -adminPassword "${admin_password}" +Configure-RDP +Install-DevolutionsGateway +Patch-Devolutions-HTML diff --git a/registry/coder/modules/windows-rdp/tsconfig.json b/registry/coder/modules/windows-rdp/tsconfig.json new file mode 100644 index 0000000..dd84b11 --- /dev/null +++ b/registry/coder/modules/windows-rdp/tsconfig.json @@ -0,0 +1,25 @@ +{ + // Even though this Coder module doesn't contain any TypeScript, it's still + // incredibly helpful to include a custom tsconfig file here to ensure that + // the raw, unprocessed JavaScript we send doesn't use features that are too + // modern, to maximize browser compatibility + "extends": ["../../../../tsconfig.json"], + "compilerOptions": { + // Not using ES6, because ES2018 gives some features that make testing a + // little bit easier. That's still a large net that catches most of our + // target audience, though + "target": "ES2018", + + // Have to still use ESNext module for the testing setup; otherwise the + // tests will break from the `import.meta` references. + "module": "ESNext", + + "paths": { + // 2025-04-16 - This seems to be a Bun-specific bug, where extending a + // tsconfig file causes all its paths to be forgotten. The VSCode LSP + // recognizes the path just fine without this, but Bun needs the mapping + // to be redefined + "~test": ["../../../../test/test.ts"] + } + } +} diff --git a/registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png b/registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png new file mode 100644 index 0000000..f37d65d Binary files /dev/null and b/registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png differ diff --git a/registry/thezoker/README.md b/registry/thezoker/README.md new file mode 100644 index 0000000..ba4ebf2 --- /dev/null +++ b/registry/thezoker/README.md @@ -0,0 +1,7 @@ +--- +display_name: TheZoker +bio: I'm a master computer science student at the TU munich and a webdesigner. +github: TheZoker +website: https://gareis.io/ +status: community +--- diff --git a/registry/thezoker/modules/nodejs/README.md b/registry/thezoker/modules/nodejs/README.md new file mode 100644 index 0000000..d4cc7e5 --- /dev/null +++ b/registry/thezoker/modules/nodejs/README.md @@ -0,0 +1,61 @@ +--- +display_name: Node.js +description: Install Node.js via nvm +icon: ../../../../.icons/node.svg +maintainer_github: TheZoker +verified: false +tags: [helper] +--- + +# nodejs + +Automatically installs [Node.js](https://github.com/nodejs/node) via [nvm](https://github.com/nvm-sh/nvm). It can also install multiple versions of node and set a default version. If no options are specified, the latest version is installed. + +```tf +module "nodejs" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/nodejs/coder" + version = "1.0.10" + agent_id = coder_agent.example.id +} +``` + +## Install multiple versions + +This installs multiple versions of Node.js: + +```tf +module "nodejs" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/nodejs/coder" + version = "1.0.10" + agent_id = coder_agent.example.id + node_versions = [ + "18", + "20", + "node" + ] + default_node_version = "20" +} +``` + +## Full example + +A example with all available options: + +```tf +module "nodejs" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/nodejs/coder" + version = "1.0.10" + agent_id = coder_agent.example.id + nvm_version = "v0.39.7" + nvm_install_prefix = "/opt/nvm" + node_versions = [ + "16", + "18", + "node" + ] + default_node_version = "16" +} +``` diff --git a/registry/thezoker/modules/nodejs/main.test.ts b/registry/thezoker/modules/nodejs/main.test.ts new file mode 100644 index 0000000..14f8a06 --- /dev/null +++ b/registry/thezoker/modules/nodejs/main.test.ts @@ -0,0 +1,12 @@ +import { describe } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "~test"; + +describe("nodejs", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + // More tests depend on shebang refactors +}); diff --git a/registry/thezoker/modules/nodejs/main.tf b/registry/thezoker/modules/nodejs/main.tf new file mode 100644 index 0000000..9c9c5c7 --- /dev/null +++ b/registry/thezoker/modules/nodejs/main.tf @@ -0,0 +1,52 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "nvm_version" { + type = string + description = "The version of nvm to install." + default = "master" +} + +variable "nvm_install_prefix" { + type = string + description = "The prefix to install nvm to (relative to $HOME)." + default = ".nvm" +} + +variable "node_versions" { + type = list(string) + description = "A list of Node.js versions to install." + default = ["node"] +} + +variable "default_node_version" { + type = string + description = "The default Node.js version" + default = "node" +} + +resource "coder_script" "nodejs" { + agent_id = var.agent_id + display_name = "Node.js:" + script = templatefile("${path.module}/run.sh", { + NVM_VERSION : var.nvm_version, + INSTALL_PREFIX : var.nvm_install_prefix, + NODE_VERSIONS : join(",", var.node_versions), + DEFAULT : var.default_node_version, + }) + run_on_start = true + start_blocks_login = true +} diff --git a/registry/thezoker/modules/nodejs/run.sh b/registry/thezoker/modules/nodejs/run.sh new file mode 100644 index 0000000..78e940a --- /dev/null +++ b/registry/thezoker/modules/nodejs/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +NVM_VERSION='${NVM_VERSION}' +NODE_VERSIONS='${NODE_VERSIONS}' +INSTALL_PREFIX='${INSTALL_PREFIX}' +DEFAULT='${DEFAULT}' +BOLD='\033[0;1m' +CODE='\033[36;40;1m' +RESET='\033[0m' + +printf "$${BOLD}Installing nvm!$${RESET}\n" + +export NVM_DIR="$HOME/$${INSTALL_PREFIX}/nvm" +mkdir -p "$NVM_DIR" + +script="$(curl -sS -o- "https://raw.githubusercontent.com/nvm-sh/nvm/$${NVM_VERSION}/install.sh" 2>&1)" +if [ $? -ne 0 ]; then + echo "Failed to download nvm installation script: $script" + exit 1 +fi + +output="$(bash <<< "$script" 2>&1)" +if [ $? -ne 0 ]; then + echo "Failed to install nvm: $output" + exit 1 +fi + +printf "🥳 nvm has been installed\n\n" + +# Set up nvm for the rest of the script. +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + +# Install each node version... +IFS=',' read -r -a VERSIONLIST <<< "$${NODE_VERSIONS}" +for version in "$${VERSIONLIST[@]}"; do + if [ -z "$version" ]; then + continue + fi + printf "🛠️ Installing node version $${CODE}$version$${RESET}...\n" + output=$(nvm install "$version" 2>&1) + if [ $? -ne 0 ]; then + echo "Failed to install version: $version: $output" + exit 1 + fi +done + +# Set default if provided +if [ -n "$${DEFAULT}" ]; then + printf "🛠️ Setting default node version $${CODE}$DEFAULT$${RESET}...\n" + output=$(nvm alias default $DEFAULT 2>&1) +fi diff --git a/registry/whizus/.images/exoscale-custom.png b/registry/whizus/.images/exoscale-custom.png new file mode 100644 index 0000000..3646c8c Binary files /dev/null and b/registry/whizus/.images/exoscale-custom.png differ diff --git a/registry/whizus/.images/exoscale-exclude.png b/registry/whizus/.images/exoscale-exclude.png new file mode 100644 index 0000000..40683c1 Binary files /dev/null and b/registry/whizus/.images/exoscale-exclude.png differ diff --git a/registry/whizus/.images/exoscale-instance-custom.png b/registry/whizus/.images/exoscale-instance-custom.png new file mode 100644 index 0000000..04373e3 Binary files /dev/null and b/registry/whizus/.images/exoscale-instance-custom.png differ diff --git a/registry/whizus/.images/exoscale-instance-exclude.png b/registry/whizus/.images/exoscale-instance-exclude.png new file mode 100644 index 0000000..1261b24 Binary files /dev/null and b/registry/whizus/.images/exoscale-instance-exclude.png differ diff --git a/registry/whizus/.images/exoscale-instance-types.png b/registry/whizus/.images/exoscale-instance-types.png new file mode 100644 index 0000000..9158830 Binary files /dev/null and b/registry/whizus/.images/exoscale-instance-types.png differ diff --git a/registry/whizus/.images/exoscale-zones.png b/registry/whizus/.images/exoscale-zones.png new file mode 100644 index 0000000..b78cd01 Binary files /dev/null and b/registry/whizus/.images/exoscale-zones.png differ diff --git a/registry/whizus/README.md b/registry/whizus/README.md new file mode 100644 index 0000000..7b6dec3 --- /dev/null +++ b/registry/whizus/README.md @@ -0,0 +1,9 @@ +--- +display_name: WhizUs +bio: WhizUs is your premier choice for DevOps, Kubernetes, and Cloud Native consulting. Based in Vienna we combine our expert solutions with a strong commitment to the community. Explore automation, scalability and drive success through collaboration. +github: WhizUs +linkedin: https://www.linkedin.com/company/whizus +website: https://www.whizus.com/ +support_email: office@whizus.com +status: community +--- diff --git a/registry/whizus/modules/exoscale-instance-type/README.md b/registry/whizus/modules/exoscale-instance-type/README.md new file mode 100644 index 0000000..68e725f --- /dev/null +++ b/registry/whizus/modules/exoscale-instance-type/README.md @@ -0,0 +1,117 @@ +--- +display_name: exoscale-instance-type +description: A parameter with human readable exoscale instance names +icon: ../../../../.icons/exoscale.svg +maintainer_github: WhizUs +verified: false +tags: [helper, parameter, instances, exoscale] +--- + +# exoscale-instance-type + +A parameter with all Exoscale instance types. This allows developers to select +their desired virtual machine for the workspace. + +Customize the preselected parameter value: + +```tf +module "exoscale-instance-type" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-instance-type/coder" + version = "1.0.12" + default = "standard.medium" +} + +resource "exoscale_compute_instance" "instance" { + type = module.exoscale-instance-type.value + # ... +} + +resource "coder_metadata" "workspace_info" { + item { + key = "instance type" + value = module.exoscale-instance-type.name + } +} +``` + +![Exoscale instance types](../../.images/exoscale-instance-types.png) + +## Examples + +### Customize type + +Change the display name a type using the corresponding maps: + +```tf +module "exoscale-instance-type" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-instance-type/coder" + version = "1.0.12" + default = "standard.medium" + + custom_names = { + "standard.medium" : "Mittlere Instanz" # German translation + } + + custom_descriptions = { + "standard.medium" : "4 GB Arbeitsspeicher, 2 Kerne, 10 - 400 GB Festplatte" # German translation + } +} + +resource "exoscale_compute_instance" "instance" { + type = module.exoscale-instance-type.value + # ... +} + +resource "coder_metadata" "workspace_info" { + item { + key = "instance type" + value = module.exoscale-instance-type.name + } +} +``` + +![Exoscale instance types Custom](../../.images/exoscale-instance-custom.png) + +### Use category and exclude type + +Show only gpu1 types + +```tf +module "exoscale-instance-type" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-instance-type/coder" + version = "1.0.12" + default = "gpu.large" + type_category = ["gpu"] + exclude = [ + "gpu2.small", + "gpu2.medium", + "gpu2.large", + "gpu2.huge", + "gpu3.small", + "gpu3.medium", + "gpu3.large", + "gpu3.huge" + ] +} + +resource "exoscale_compute_instance" "instance" { + type = module.exoscale-instance-type.value + # ... +} + +resource "coder_metadata" "workspace_info" { + item { + key = "instance type" + value = module.exoscale-instance-type.name + } +} +``` + +![Exoscale instance types category and exclude](../../.images/exoscale-instance-exclude.png) + +## Related templates + +A related exoscale template will be provided soon. diff --git a/registry/whizus/modules/exoscale-instance-type/main.test.ts b/registry/whizus/modules/exoscale-instance-type/main.test.ts new file mode 100644 index 0000000..8a63cbf --- /dev/null +++ b/registry/whizus/modules/exoscale-instance-type/main.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("exoscale-instance-type", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "gpu3.huge", + type_category: `["gpu", "cpu"]`, + }); + expect(state.outputs.value.value).toBe("gpu3.huge"); + }); + + it("fails because of wrong categroy definition", async () => { + expect(async () => { + await runTerraformApply(import.meta.dir, { + default: "gpu3.huge", + // type_category: ["standard"] is standard + }); + }).toThrow('default value "gpu3.huge" must be defined as one of options'); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/whizus/modules/exoscale-instance-type/main.tf b/registry/whizus/modules/exoscale-instance-type/main.tf new file mode 100644 index 0000000..65d3729 --- /dev/null +++ b/registry/whizus/modules/exoscale-instance-type/main.tf @@ -0,0 +1,286 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "display_name" { + default = "Exoscale instance type" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "Select the exoscale instance type to use for the workspace. Check out the pricing page for more information: https://www.exoscale.com/pricing" + description = "The description of the parameter." + type = string +} + +variable "default" { + default = "" + description = "The default instance type to use if no type is specified. One of [\"standard.micro\", \"standard.tiny\", \"standard.small\", \"standard.medium\", \"standard.large\", \"standard.extra\", \"standard.huge\", \"standard.mega\", \"standard.titan\", \"standard.jumbo\", \"standard.colossus\", \"cpu.extra\", \"cpu.huge\", \"cpu.mega\", \"cpu.titan\", \"memory.extra\", \"memory.huge\", \"memory.mega\", \"memory.titan\", \"storage.extra\", \"storage.huge\", \"storage.mega\", \"storage.titan\", \"storage.jumbo\", \"gpu.small\", \"gpu.medium\", \"gpu.large\", \"gpu.huge\", \"gpu2.small\", \"gpu2.medium\", \"gpu2.large\", \"gpu2.huge\", \"gpu3.small\", \"gpu3.medium\", \"gpu3.large\", \"gpu3.huge\"]" + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for instance type IDs." + type = map(string) +} +variable "custom_descriptions" { + default = {} + description = "A map of custom descriptions for instance type IDs." + type = map(string) +} + +variable "type_category" { + default = ["standard"] + description = "A list of instance type categories the user is allowed to choose. One of [\"standard\", \"cpu\", \"memory\", \"storage\", \"gpu\"]" + type = list(string) +} + +variable "exclude" { + default = [] + description = "A list of instance type IDs to exclude. One of [\"standard.micro\", \"standard.tiny\", \"standard.small\", \"standard.medium\", \"standard.large\", \"standard.extra\", \"standard.huge\", \"standard.mega\", \"standard.titan\", \"standard.jumbo\", \"standard.colossus\", \"cpu.extra\", \"cpu.huge\", \"cpu.mega\", \"cpu.titan\", \"memory.extra\", \"memory.huge\", \"memory.mega\", \"memory.titan\", \"storage.extra\", \"storage.huge\", \"storage.mega\", \"storage.titan\", \"storage.jumbo\", \"gpu.small\", \"gpu.medium\", \"gpu.large\", \"gpu.huge\", \"gpu2.small\", \"gpu2.medium\", \"gpu2.large\", \"gpu2.huge\", \"gpu3.small\", \"gpu3.medium\", \"gpu3.large\", \"gpu3.huge\"]" + type = list(string) +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + # https://www.exoscale.com/pricing/ + + standard_instances = [ + { + value = "standard.micro", + name = "Standard Micro", + description = "512 MB RAM, 1 Core, 10 - 200 GB Disk" + }, + { + value = "standard.tiny", + name = "Standard Tiny", + description = "1 GB RAM, 1 Core, 10 - 400 GB Disk" + }, + { + value = "standard.small", + name = "Standard Small", + description = "2 GB RAM, 2 Cores, 10 - 400 GB Disk" + }, + { + value = "standard.medium", + name = "Standard Medium", + description = "4 GB RAM, 2 Cores, 10 - 400 GB Disk" + }, + { + value = "standard.large", + name = "Standard Large", + description = "8 GB RAM, 4 Cores, 10 - 400 GB Disk" + }, + { + value = "standard.extra", + name = "Standard Extra", + description = "rge", + description = "16 GB RAM, 4 Cores, 10 - 800 GB Disk" + }, + { + value = "standard.huge", + name = "Standard Huge", + description = "32 GB RAM, 8 Cores, 10 - 800 GB Disk" + }, + { + value = "standard.mega", + name = "Standard Mega", + description = "64 GB RAM, 12 Cores, 10 - 800 GB Disk" + }, + { + value = "standard.titan", + name = "Standard Titan", + description = "128 GB RAM, 16 Cores, 10 - 1.6 TB Disk" + }, + { + value = "standard.jumbo", + name = "Standard Jumbo", + description = "256 GB RAM, 24 Cores, 10 - 1.6 TB Disk" + }, + { + value = "standard.colossus", + name = "Standard Colossus", + description = "320 GB RAM, 40 Cores, 10 - 1.6 TB Disk" + } + ] + cpu_instances = [ + { + value = "cpu.extra", + name = "CPU Extra-Large", + description = "16 GB RAM, 8 Cores, 10 - 800 GB Disk" + }, + { + value = "cpu.huge", + name = "CPU Huge", + description = "32 GB RAM, 16 Cores, 10 - 800 GB Disk" + }, + { + value = "cpu.mega", + name = "CPU Mega", + description = "64 GB RAM, 32 Cores, 10 - 800 GB Disk" + }, + { + value = "cpu.titan", + name = "CPU Titan", + description = "128 GB RAM, 40 Cores, 0.1 - 1.6 TB Disk" + } + ] + memory_instances = [ + { + value = "memory.extra", + name = "Memory Extra-Large", + description = "16 GB RAM, 2 Cores, 10 - 800 GB Disk" + }, + { + value = "memory.huge", + name = "Memory Huge", + description = "32 GB RAM, 4 Cores, 10 - 800 GB Disk" + }, + { + value = "memory.mega", + name = "Memory Mega", + description = "64 GB RAM, 8 Cores, 10 - 800 GB Disk" + }, + { + value = "memory.titan", + name = "Memory Titan", + description = "128 GB RAM, 12 Cores, 0.1 - 1.6 TB Disk" + } + ] + storage_instances = [ + { + value = "storage.extra", + name = "Storage Extra-Large", + description = "16 GB RAM, 4 Cores, 1 - 2 TB Disk" + }, + { + value = "storage.huge", + name = "Storage Huge", + description = "32 GB RAM, 8 Cores, 2 - 3 TB Disk" + }, + { + value = "storage.mega", + name = "Storage Mega", + description = "64 GB RAM, 12 Cores, 3 - 5 TB Disk" + }, + { + value = "storage.titan", + name = "Storage Titan", + description = "128 GB RAM, 16 Cores, 5 - 10 TB Disk" + }, + { + value = "storage.jumbo", + name = "Storage Jumbo", + description = "225 GB RAM, 24 Cores, 10 - 15 TB Disk" + } + ] + gpu_instances = [ + { + value = "gpu.small", + name = "GPU1 Small", + description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk" + }, + { + value = "gpu.medium", + name = "GPU1 Medium", + description = "90 GB RAM, 16 Cores, 2 GPU, 0.1 - 1.2 TB Disk" + }, + { + value = "gpu.large", + name = "GPU1 Large", + description = "120 GB RAM, 24 Cores, 3 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu.huge", + name = "GPU1 Huge", + description = "225 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu2.small", + name = "GPU2 Small", + description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk" + }, + { + value = "gpu2.medium", + name = "GPU2 Medium", + description = "90 GB RAM, 16 Cores, 2 GPU, 0.1 - 1.2 TB Disk" + }, + { + value = "gpu2.large", + name = "GPU2 Large", + description = "120 GB RAM, 24 Cores, 3 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu2.huge", + name = "GPU2 Huge", + description = "225 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu3.small", + name = "GPU3 Small", + description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk" + }, + { + value = "gpu3.medium", + name = "GPU3 Medium", + description = "120 GB RAM, 24 Cores, 2 GPU, 0.1 - 1.2 TB Disk" + }, + { + value = "gpu3.large", + name = "GPU3 Large", + description = "224 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk" + }, + { + value = "gpu3.huge", + name = "GPU3 Huge", + description = "448 GB RAM, 96 Cores, 8 GPU, 0.1 - 1.6 TB Disk" + } + ] +} + +data "coder_parameter" "instance_type" { + name = "exoscale_instance_type" + display_name = var.display_name + description = var.description + default = var.default == "" ? null : var.default + order = var.coder_parameter_order + mutable = var.mutable + dynamic "option" { + for_each = [for k, v in concat( + contains(var.type_category, "standard") ? local.standard_instances : [], + contains(var.type_category, "cpu") ? local.cpu_instances : [], + contains(var.type_category, "memory") ? local.memory_instances : [], + contains(var.type_category, "storage") ? local.storage_instances : [], + contains(var.type_category, "gpu") ? local.gpu_instances : [] + ) : v if !(contains(var.exclude, v.value))] + content { + name = try(var.custom_names[option.value.value], option.value.name) + description = try(var.custom_descriptions[option.value.value], option.value.description) + value = option.value.value + } + } +} + +output "value" { + value = data.coder_parameter.instance_type.value +} diff --git a/registry/whizus/modules/exoscale-zone/README.md b/registry/whizus/modules/exoscale-zone/README.md new file mode 100644 index 0000000..34a3161 --- /dev/null +++ b/registry/whizus/modules/exoscale-zone/README.md @@ -0,0 +1,100 @@ +--- +display_name: exoscale-zone +description: A parameter with human zone names and icons +icon: ../../../../.icons/exoscale.svg +maintainer_github: WhizUs +verified: false +tags: [helper, parameter, zones, regions, exoscale] +--- + +# exoscale-zone + +A parameter with all Exoscale zones. This allows developers to select +the zone closest to them. + +Customize the preselected parameter value: + +```tf +module "exoscale-zone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-zone/coder" + version = "1.0.12" + default = "ch-dk-2" +} + + +data "exoscale_compute_template" "my_template" { + zone = module.exoscale-zone.value + name = "Linux Ubuntu 22.04 LTS 64-bit" +} + +resource "exoscale_compute_instance" "instance" { + zone = module.exoscale-zone.value + # ... +} +``` + +![Exoscale Zones](../../../.images/exoscale-zones.png) + +## Examples + +### Customize zones + +Change the display name and icon for a zone using the corresponding maps: + +```tf +module "exoscale-zone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/exoscale-zone/coder" + version = "1.0.12" + default = "at-vie-1" + + custom_names = { + "at-vie-1" : "Home Vienna" + } + + custom_icons = { + "at-vie-1" : "/emojis/1f3e0.png" + } +} + +data "exoscale_compute_template" "my_template" { + zone = module.exoscale-zone.value + name = "Linux Ubuntu 22.04 LTS 64-bit" +} + +resource "exoscale_compute_instance" "instance" { + zone = module.exoscale-zone.value + # ... +} +``` + +![Exoscale Custom](../../../.images/exoscale-custom.png) + +### Exclude regions + +Hide the Switzerland zones Geneva and Zurich + +```tf +module "exoscale-zone" { + source = "registry.coder.com/modules/exoscale-zone/coder" + version = "1.0.12" + exclude = ["ch-gva-2", "ch-dk-2"] +} + +data "exoscale_compute_template" "my_template" { + zone = module.exoscale-zone.value + name = "Linux Ubuntu 22.04 LTS 64-bit" +} + +resource "exoscale_compute_instance" "instance" { + zone = module.exoscale-zone.value + # ... +} +``` + +![Exoscale Exclude](../../../.images/exoscale-exclude.png) + +## Related templates + +An exoscale sample template will be delivered soon. diff --git a/registry/whizus/modules/exoscale-zone/main.test.ts b/registry/whizus/modules/exoscale-zone/main.test.ts new file mode 100644 index 0000000..236b6e4 --- /dev/null +++ b/registry/whizus/modules/exoscale-zone/main.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("exoscale-zone", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "at-vie-1", + }); + expect(state.outputs.value.value).toBe("at-vie-1"); + }); + + it("set custom order for coder_parameter", async () => { + const order = 99; + const state = await runTerraformApply(import.meta.dir, { + coder_parameter_order: order.toString(), + }); + expect(state.resources).toHaveLength(1); + expect(state.resources[0].instances[0].attributes.order).toBe(order); + }); +}); diff --git a/registry/whizus/modules/exoscale-zone/main.tf b/registry/whizus/modules/exoscale-zone/main.tf new file mode 100644 index 0000000..090acb4 --- /dev/null +++ b/registry/whizus/modules/exoscale-zone/main.tf @@ -0,0 +1,116 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "display_name" { + default = "Exoscale Region" + description = "The display name of the parameter." + type = string +} + +variable "description" { + default = "The region to deploy workspace infrastructure." + description = "The description of the parameter." + type = string +} + +variable "default" { + default = "" + description = "The default region to use if no region is specified." + type = string +} + +variable "mutable" { + default = false + description = "Whether the parameter can be changed after creation." + type = bool +} + +variable "custom_names" { + default = {} + description = "A map of custom display names for region IDs." + type = map(string) +} + +variable "custom_icons" { + default = {} + description = "A map of custom icons for region IDs." + type = map(string) +} + +variable "exclude" { + default = [] + description = "A list of region IDs to exclude." + type = list(string) +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +locals { + # This is a static list because the zones don't change _that_ + # frequently and including the `exoscale_zones` data source requires + # the provider, which requires a zone. + # https://www.exoscale.com/datacenters/ + zones = { + "de-fra-1" = { + name = "Frankfurt - Germany" + icon = "/emojis/1f1e9-1f1ea.png" + } + "at-vie-1" = { + name = "Vienna 1 - Austria" + icon = "/emojis/1f1e6-1f1f9.png" + } + "at-vie-2" = { + name = "Vienna 2 - Austria" + icon = "/emojis/1f1e6-1f1f9.png" + } + "ch-gva-2" = { + name = "Geneva - Switzerland" + icon = "/emojis/1f1e8-1f1ed.png" + } + "ch-dk-2" = { + name = "Zurich - Switzerland" + icon = "/emojis/1f1e8-1f1ed.png" + } + "bg-sof-1" = { + name = "Sofia - Bulgaria" + icon = "/emojis/1f1e7-1f1ec.png" + } + "de-muc-1" = { + name = "Munich - Germany" + icon = "/emojis/1f1e9-1f1ea.png" + } + } +} + +data "coder_parameter" "zone" { + name = "exoscale_zone" + display_name = var.display_name + description = var.description + default = var.default == "" ? null : var.default + order = var.coder_parameter_order + mutable = var.mutable + dynamic "option" { + for_each = { for k, v in local.zones : k => v if !(contains(var.exclude, k)) } + content { + name = try(var.custom_names[option.key], option.value.name) + icon = try(var.custom_icons[option.key], option.value.icon) + value = option.key + } + } +} + +output "value" { + value = data.coder_parameter.zone.value +} \ No newline at end of file diff --git a/setup.ts b/setup.ts new file mode 100644 index 0000000..c6c85b7 --- /dev/null +++ b/setup.ts @@ -0,0 +1,49 @@ +import { readableStreamToText, spawn } from "bun"; +import { afterAll } from "bun:test"; + +async function removeStatefiles(): Promise { + const process = spawn([ + "find", + ".", + "-type", + "f", + "-o", + "-name", + "*.tfstate", + "-o", + "-name", + "*.tfstate.lock.info", + "-delete", + ]); + await process.exited; +} + +async function removeOldContainers(): Promise { + let process = spawn([ + "docker", + "ps", + "-a", + "-q", + "--filter", + "label=modules-test", + ]); + let containerIDsRaw = await readableStreamToText(process.stdout); + let exitCode = await process.exited; + if (exitCode !== 0) { + throw new Error(containerIDsRaw); + } + containerIDsRaw = containerIDsRaw.trim(); + if (containerIDsRaw === "") { + return; + } + process = spawn(["docker", "rm", "-f", ...containerIDsRaw.split("\n")]); + const stdout = await readableStreamToText(process.stdout); + exitCode = await process.exited; + if (exitCode !== 0) { + throw new Error(stdout); + } +} + +afterAll(async () => { + await Promise.all([removeStatefiles(), removeOldContainers()]); +}); diff --git a/test/test.ts b/test/test.ts new file mode 100644 index 0000000..ab3727e --- /dev/null +++ b/test/test.ts @@ -0,0 +1,279 @@ +import { readableStreamToText, spawn } from "bun"; +import { expect, it } from "bun:test"; +import { readFile, unlink } from "node:fs/promises"; + +export const runContainer = async ( + image: string, + init = "sleep infinity", +): Promise => { + const proc = spawn([ + "docker", + "run", + "--rm", + "-d", + "--label", + "modules-test=true", + "--network", + "host", + "--entrypoint", + "sh", + image, + "-c", + init, + ]); + + const containerID = await readableStreamToText(proc.stdout); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(containerID); + } + return containerID.trim(); +}; + +/** + * Finds the only "coder_script" resource in the given state and runs it in a + * container. + */ +export const executeScriptInContainer = 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 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, + }; +}; + +export const execContainer = async ( + id: string, + cmd: string[], +): Promise<{ + exitCode: number; + stderr: string; + stdout: string; +}> => { + const proc = spawn(["docker", "exec", id, ...cmd], { + stderr: "pipe", + stdout: "pipe", + }); + const [stderr, stdout] = await Promise.all([ + readableStreamToText(proc.stderr), + readableStreamToText(proc.stdout), + ]); + const exitCode = await proc.exited; + return { + exitCode, + stderr, + stdout, + }; +}; + +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +type TerraformStateResource = { + type: string; + name: string; + provider: string; + + instances: [ + { + attributes: Record; + }, + ]; +}; + +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. + */ +export const findResourceInstance = ( + state: TerraformState, + type: T, + name?: string, +): ResourceInstance => { + const resource = state.resources.find( + (resource) => + resource.type === type && (name ? resource.name === name : true), + ); + if (!resource) { + throw new Error(`Resource ${type} not found`); + } + if (resource.instances.length !== 1) { + throw new Error( + `Resource ${type} has ${resource.instances.length} instances`, + ); + } + + 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 = ( + dir: string, + vars: Readonly, +) => { + // Ensures that all required variables are provided. + it("required variables", async () => { + await runTerraformApply(dir, vars); + }); + + const varNames = Object.keys(vars); + for (const varName of varNames) { + // Ensures that every variable provided is required! + 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`, + ); + return; + } + throw new Error(`${varName} is not a required variable!`); + }); + } +}; + +/** + * Runs terraform apply in the given directory with the given variables. It is + * fine to run in parallel with other instances of this function, as it uses a + * random state file. + */ +export const runTerraformApply = async ( + dir: string, + vars: Readonly, + customEnv?: Record, +): Promise => { + const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`; + + const childEnv: Record = { + ...process.env, + ...(customEnv ?? {}), + }; + + // This is a fix for when you try to run the tests from a Coder workspace. + // When process.env is destructured into the object, it can sometimes have + // workspace-specific values, which causes the resulting URL to be different + // from what the tests have classically expected. + childEnv.CODER_AGENT_URL = undefined; + childEnv.CODER_WORKSPACE_NAME = undefined; + + for (const [key, value] of Object.entries(vars) as [string, JsonValue][]) { + if (value !== null) { + childEnv[`TF_VAR_${key}`] = String(value); + } + } + + const proc = spawn( + [ + "terraform", + "apply", + "-compact-warnings", + "-input=false", + "-auto-approve", + "-state", + "-no-color", + stateFile, + ], + { + cwd: dir, + env: childEnv, + stderr: "pipe", + stdout: "pipe", + }, + ); + + const text = await readableStreamToText(proc.stderr); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(text); + } + + const content = await readFile(stateFile, "utf8"); + await unlink(stateFile); + return JSON.parse(content); +}; + +/** + * Runs terraform init in the given directory. + */ +export const runTerraformInit = async (dir: string) => { + const proc = spawn(["terraform", "init"], { + cwd: dir, + }); + const text = await readableStreamToText(proc.stdout); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(text); + } +}; + +export const createJSONResponse = (obj: object, statusCode = 200): Response => { + return new Response(JSON.stringify(obj), { + headers: { + "Content-Type": "application/json", + }, + status: statusCode, + }); +}; + +export const writeCoder = async (id: string, script: string) => { + const exec = await execContainer(id, [ + "sh", + "-c", + `echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`, + ]); + expect(exec.exitCode).toBe(0); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..83efe80 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "esnext", + "strict": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "types": ["bun-types"], + + "paths": { + // Not the biggest fan of relative paths in TypeScript projects, but it + // does make things easier for non-Coder contributors to get tests + // imported and set up + "~test": ["./test/test.ts"] + } + } +}