diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..5dc2e84b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ +## Description + + + +--- + +## Type of Change + +- [ ] New module +- [ ] Bug fix +- [ ] Feature/enhancement +- [ ] Documentation +- [ ] Other + +--- + +## Module Information + + + +**Path:** `registry/[namespace]/modules/[module-name]` +**New version:** `v1.0.0` +**Breaking change:** [ ] Yes [ ] No + +--- + +## Testing & Validation + +- [ ] Tests pass (`bun test`) +- [ ] Code formatted (`bun run fmt`) +- [ ] Changes tested locally + +--- + +## Related Issues + + + +Closes # diff --git a/.github/scripts/version-bump.sh b/.github/scripts/version-bump.sh new file mode 100755 index 00000000..095d1279 --- /dev/null +++ b/.github/scripts/version-bump.sh @@ -0,0 +1,238 @@ +#!/bin/bash + +# Version Bump Script +# Usage: ./version-bump.sh [base_ref] +# bump_type: patch, minor, or major +# base_ref: base reference for diff (default: origin/main) + +set -euo pipefail + +usage() { + echo "Usage: $0 [base_ref]" + echo " bump_type: patch, minor, or major" + echo " base_ref: base reference for diff (default: origin/main)" + echo "" + echo "Examples:" + echo " $0 patch # Update versions with patch bump" + echo " $0 minor # Update versions with minor bump" + echo " $0 major # Update versions with major bump" + exit 1 +} + +validate_version() { + local version="$1" + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Invalid version format: '$version'. Expected X.Y.Z format." >&2 + return 1 + fi + return 0 +} + +bump_version() { + local current_version="$1" + local bump_type="$2" + + IFS='.' read -r major minor patch <<< "$current_version" + + if ! [[ "$major" =~ ^[0-9]+$ ]] || ! [[ "$minor" =~ ^[0-9]+$ ]] || ! [[ "$patch" =~ ^[0-9]+$ ]]; then + echo "❌ Version components must be numeric: major='$major' minor='$minor' patch='$patch'" >&2 + return 1 + fi + + case "$bump_type" in + "patch") + echo "$major.$minor.$((patch + 1))" + ;; + "minor") + echo "$major.$((minor + 1)).0" + ;; + "major") + echo "$((major + 1)).0.0" + ;; + *) + echo "❌ Invalid bump type: '$bump_type'. Expected patch, minor, or major." >&2 + return 1 + ;; + esac +} + +update_readme_version() { + local readme_path="$1" + local namespace="$2" + local module_name="$3" + local new_version="$4" + + if [ ! -f "$readme_path" ]; then + return 1 + fi + + local module_source="registry.coder.com/${namespace}/${module_name}/coder" + if grep -q "source.*${module_source}" "$readme_path"; then + echo "Updating version references for $namespace/$module_name in $readme_path" + awk -v module_source="$module_source" -v new_version="$new_version" ' + /source.*=.*/ { + if ($0 ~ module_source) { + in_target_module = 1 + } else { + in_target_module = 0 + } + } + /version.*=.*"/ { + if (in_target_module) { + gsub(/version[[:space:]]*=[[:space:]]*"[^"]*"/, "version = \"" new_version "\"") + in_target_module = 0 + } + } + { print } + ' "$readme_path" > "${readme_path}.tmp" && mv "${readme_path}.tmp" "$readme_path" + return 0 + elif grep -q 'version\s*=\s*"' "$readme_path"; then + echo "⚠️ Found version references but no module source match for $namespace/$module_name" + return 1 + fi + + return 1 +} + +main() { + if [ $# -lt 1 ] || [ $# -gt 2 ]; then + usage + fi + + local bump_type="$1" + local base_ref="${2:-origin/main}" + + case "$bump_type" in + "patch" | "minor" | "major") ;; + + *) + echo "❌ Invalid bump type: '$bump_type'. Expected patch, minor, or major." >&2 + exit 1 + ;; + esac + + echo "🔍 Detecting modified modules..." + + local changed_files + changed_files=$(git diff --name-only "${base_ref}"...HEAD) + local modules + modules=$(echo "$changed_files" | grep -E '^registry/[^/]+/modules/[^/]+/' | cut -d'/' -f1-4 | sort -u) + + if [ -z "$modules" ]; then + echo "❌ No modules detected in changes" + exit 1 + fi + + echo "Found modules:" + echo "$modules" + echo "" + + local bumped_modules="" + local updated_readmes="" + local untagged_modules="" + local has_changes=false + + while IFS= read -r module_path; do + if [ -z "$module_path" ]; then continue; fi + + local namespace + namespace=$(echo "$module_path" | cut -d'/' -f2) + local module_name + module_name=$(echo "$module_path" | cut -d'/' -f4) + + echo "📦 Processing: $namespace/$module_name" + + local latest_tag + latest_tag=$(git tag -l "release/${namespace}/${module_name}/v*" | sort -V | tail -1) + local readme_path="$module_path/README.md" + local current_version + + if [ -z "$latest_tag" ]; then + if [ -f "$readme_path" ] && grep -q 'version\s*=\s*"' "$readme_path"; then + local readme_version + readme_version=$(grep 'version\s*=\s*"' "$readme_path" | head -1 | sed 's/.*version\s*=\s*"\([^"]*\)".*/\1/') + echo "No git tag found, but README shows version: $readme_version" + + if ! validate_version "$readme_version"; then + echo "Starting from v1.0.0 instead" + current_version="1.0.0" + else + current_version="$readme_version" + untagged_modules="$untagged_modules\n- $namespace/$module_name (README: v$readme_version)" + fi + else + echo "No existing tags or version references found for $namespace/$module_name, starting from v1.0.0" + current_version="1.0.0" + fi + else + current_version=$(echo "$latest_tag" | sed 's/.*\/v//') + echo "Found git tag: $latest_tag (v$current_version)" + fi + + echo "Current version: $current_version" + + if ! validate_version "$current_version"; then + exit 1 + fi + + local new_version + new_version=$(bump_version "$current_version" "$bump_type") + + echo "New version: $new_version" + + if update_readme_version "$readme_path" "$namespace" "$module_name" "$new_version"; then + updated_readmes="$updated_readmes\n- $namespace/$module_name" + has_changes=true + fi + + bumped_modules="$bumped_modules\n- $namespace/$module_name: v$current_version → v$new_version" + echo "" + + done <<< "$modules" + + # Always run formatter to ensure consistent formatting + echo "🔧 Running formatter to ensure consistent formatting..." + if command -v bun >/dev/null 2>&1; then + bun fmt >/dev/null 2>&1 || echo "⚠️ Warning: bun fmt failed, but continuing..." + else + echo "⚠️ Warning: bun not found, skipping formatting" + fi + echo "" + + echo "📋 Summary:" + echo "Bump Type: $bump_type" + echo "" + echo "Modules Updated:" + echo -e "$bumped_modules" + echo "" + + if [ -n "$updated_readmes" ]; then + echo "READMEs Updated:" + echo -e "$updated_readmes" + echo "" + fi + + if [ -n "$untagged_modules" ]; then + echo "⚠️ Modules Without Git Tags:" + echo -e "$untagged_modules" + echo "These modules were versioned based on README content. Consider creating proper release tags after merging." + echo "" + fi + + if [ "$has_changes" = true ]; then + echo "✅ Version bump completed successfully!" + echo "📝 README files have been updated with new versions." + echo "" + echo "Next steps:" + echo "1. Review the changes: git diff" + echo "2. Commit the changes: git add . && git commit -m 'chore: bump module versions ($bump_type)'" + echo "3. Push the changes: git push" + exit 0 + else + echo "ℹ️ No README files were updated (no version references found matching module sources)." + echo "Version calculations completed, but no files were modified." + exit 0 + fi +} + +main "$@" diff --git a/.github/typos.toml b/.github/typos.toml index 5889c7d7..f27257a2 100644 --- a/.github/typos.toml +++ b/.github/typos.toml @@ -1,4 +1,7 @@ [default.extend-words] muc = "muc" # For Munich location code Hashi = "Hashi" -HashiCorp = "HashiCorp" \ No newline at end of file +HashiCorp = "HashiCorp" + +[files] +extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bbbdc06a..a881a05e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -48,7 +48,7 @@ jobs: - name: Validate formatting run: bun fmt:ci - name: Check for typos - uses: crate-ci/typos@v1.32.0 + uses: crate-ci/typos@v1.33.1 with: config: .github/typos.toml validate-readme-files: diff --git a/.github/workflows/deploy-registry.yaml b/.github/workflows/deploy-registry.yaml index ac3e9d6e..61d47aa1 100644 --- a/.github/workflows/deploy-registry.yaml +++ b/.github/workflows/deploy-registry.yaml @@ -2,8 +2,6 @@ name: deploy-registry on: push: - branches: - - main tags: # Matches release/// # (e.g., "release/whizus/exoscale-zone/v1.0.13") @@ -29,7 +27,6 @@ jobs: - name: Set up Google Cloud SDK uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a - name: Deploy to dev.registry.coder.com - run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch dev + run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch main - name: Deploy to registry.coder.com - run: | - gcloud builds triggers run 106610ff-41fb-4bd0-90a2-7643583fb9c0 --branch main + run: gcloud builds triggers run 106610ff-41fb-4bd0-90a2-7643583fb9c0 --tag production diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 00000000..0343b707 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,24 @@ +name: golangci-lint +on: + push: + branches: + - main + - master + pull_request: + +permissions: + contents: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.1 \ No newline at end of file diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml new file mode 100644 index 00000000..b492ffc5 --- /dev/null +++ b/.github/workflows/version-bump.yaml @@ -0,0 +1,120 @@ +name: Version Bump + +on: + pull_request: + types: [labeled] + paths: + - "registry/**/modules/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + version-bump: + if: github.event.label.name == 'version:patch' || github.event.label.name == 'version:minor' || github.event.label.name == 'version:major' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set up Terraform + uses: coder/coder/.github/actions/setup-tf@main + + - name: Install dependencies + run: bun install + + - name: Extract bump type from label + id: bump-type + run: | + case "${{ github.event.label.name }}" in + "version:patch") + echo "type=patch" >> $GITHUB_OUTPUT + ;; + "version:minor") + echo "type=minor" >> $GITHUB_OUTPUT + ;; + "version:major") + echo "type=major" >> $GITHUB_OUTPUT + ;; + *) + echo "Invalid version label: ${{ github.event.label.name }}" + exit 1 + ;; + esac + + - name: Check version bump requirements + id: version-check + run: | + output_file=$(mktemp) + if ./.github/scripts/version-bump.sh "${{ steps.bump-type.outputs.type }}" origin/main > "$output_file" 2>&1; then + echo "Script completed successfully" + else + echo "Script failed" + cat "$output_file" + exit 1 + fi + + { + echo "output<> $GITHUB_OUTPUT + + cat "$output_file" + + if git diff --quiet; then + echo "versions_up_to_date=true" >> $GITHUB_OUTPUT + echo "✅ All module versions are already up to date" + else + echo "versions_up_to_date=false" >> $GITHUB_OUTPUT + echo "❌ Module versions need to be updated" + echo "Files that would be changed:" + git diff --name-only + echo "" + echo "Diff preview:" + git diff + + git checkout . + git clean -fd + + exit 1 + fi + + - name: Comment on PR - Failure + if: failure() && steps.version-check.outputs.versions_up_to_date == 'false' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const output = `${{ steps.version-check.outputs.output }}`; + const bumpType = `${{ steps.bump-type.outputs.type }}`; + + let comment = `## ❌ Version Bump Validation Failed\n\n`; + comment += `**Bump Type:** \`${bumpType}\`\n\n`; + comment += `Module versions need to be updated but haven't been bumped yet.\n\n`; + comment += `**Required Actions:**\n`; + comment += `1. Run the version bump script locally: \`./.github/scripts/version-bump.sh ${bumpType}\`\n`; + comment += `2. Commit the changes: \`git add . && git commit -m "chore: bump module versions (${bumpType})"\`\n`; + comment += `3. Push the changes: \`git push\`\n\n`; + comment += `### Script Output:\n\`\`\`\n${output}\n\`\`\`\n\n`; + comment += `> Please update the module versions and push the changes to continue.`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..055d4ebb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,213 @@ +version: "2" +linters: + default: none + enable: + - asciicheck + - bidichk + - bodyclose + - dogsled + - dupl + - errcheck + - errname + - errorlint + - exhaustruct + - forcetypeassert + - gocognit + - gocritic + - godot + - gomodguard + - gosec + - govet + - importas + - ineffassign + - makezero + - misspell + - nestif + - nilnil + # - noctx + # - paralleltest + - revive + - staticcheck + # - tparallel + - unconvert + - unused + settings: + dupl: + threshold: 412 + godot: + scope: all + capital: true + exhaustruct: + include: + - httpmw\.\w+ + - github.com/coder/coder/v2/coderd/database\.[^G][^e][^t]\w+Params + gocognit: + min-complexity: 300 + goconst: + min-len: 4 + min-occurrences: 3 + gocritic: + enabled-checks: + - badLock + - badRegexp + - boolExprSimplify + - builtinShadow + - builtinShadowDecl + - commentedOutImport + - deferUnlambda + - dupImport + - dynamicFmtString + - emptyDecl + - emptyFallthrough + - emptyStringTest + - evalOrder + - externalErrorReassign + - filepathJoin + - hexLiteral + - httpNoBody + - importShadow + - indexAlloc + - initClause + - methodExprCall + - nestingReduce + - nilValReturn + - preferFilepathJoin + - rangeAppendAll + - regexpPattern + - redundantSprint + - regexpSimplify + - ruleguard + - sliceClear + - sortSlice + - sprintfQuotedString + - sqlQuery + - stringConcatSimplify + - stringXbytes + - todoCommentWithoutDetail + - tooManyResultsChecker + - truncateCmp + - typeAssertChain + - typeDefFirst + - unlabelStmt + - weakCond + - whyNoLint + settings: + ruleguard: + failOn: all + rules: ${base-path}/scripts/rules.go + gosec: + excludes: + - G601 + govet: + disable: + - loopclosure + importas: + no-unaliased: true + misspell: + locale: US + ignore-rules: + - trialer + nestif: + min-complexity: 20 + revive: + severity: warning + rules: + - name: atomic + - name: bare-return + - name: blank-imports + - name: bool-literal-in-expr + - name: call-to-gc + - name: confusing-results + - name: constant-logical-expr + - name: context-as-argument + - name: context-keys-type + # - name: deep-exit + - name: defer + - name: dot-imports + - name: duplicated-imports + - name: early-return + - name: empty-block + - name: empty-lines + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: exported + - name: flag-parameter + - name: get-return + - name: identical-branches + - name: if-return + - name: import-shadowing + - name: increment-decrement + - name: indent-error-flow + - name: modifies-value-receiver + - name: package-comments + - name: range + - name: receiver-naming + - name: redefines-builtin-id + - name: string-of-int + - name: struct-tag + - name: superfluous-else + - name: time-naming + - name: unconditional-recursion + - name: unexported-naming + - name: unexported-return + - name: unhandled-error + - name: unnecessary-stmt + - name: unreachable-code + - name: unused-parameter + - name: unused-receiver + - name: var-declaration + - name: var-naming + - name: waitgroup-by-value + staticcheck: + checks: + - all + - SA4006 # Detects redundant assignments + - SA4009 # Detects redundant variable declarations + - SA1019 + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - errcheck + - exhaustruct + - forcetypeassert + path: _test\.go + - linters: + - exhaustruct + path: scripts/* + - linters: + - ALL + path: scripts/rules.go + paths: + - scripts/rules.go + - coderd/database/dbmem + - node_modules + - .git + - third_party$ + - builtin$ + - examples$ +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + fix: true +formatters: + enable: + - goimports + - gofmt + exclusions: + generated: lax + paths: + - scripts/rules.go + - coderd/database/dbmem + - node_modules + - .git + - third_party$ + - builtin$ + - examples$ diff --git a/.icons/airflow.svg b/.icons/airflow.svg index 46300fec..06b18bee 100644 --- a/.icons/airflow.svg +++ b/.icons/airflow.svg @@ -1,19 +1,18 @@ - - - - - - - - - - - - + + + + + + + + + + + - - + + diff --git a/.icons/amazon-q.svg b/.icons/amazon-q.svg index a797165b..4a9b3262 100644 --- a/.icons/amazon-q.svg +++ b/.icons/amazon-q.svg @@ -1,268 +1,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + diff --git a/.icons/box-emoji.svg b/.icons/box-emoji.svg new file mode 100644 index 00000000..a2595599 --- /dev/null +++ b/.icons/box-emoji.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.icons/coder-white.svg b/.icons/coder-white.svg deleted file mode 100644 index 3bb941d9..00000000 --- a/.icons/coder-white.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/.icons/coder.svg b/.icons/coder.svg new file mode 100644 index 00000000..60d7eff6 --- /dev/null +++ b/.icons/coder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/digital-ocean.svg b/.icons/digital-ocean.svg new file mode 100644 index 00000000..6f10b237 --- /dev/null +++ b/.icons/digital-ocean.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/.icons/docker.svg b/.icons/docker.svg new file mode 100644 index 00000000..78e549ef --- /dev/null +++ b/.icons/docker.svg @@ -0,0 +1,3 @@ + + + diff --git a/.icons/jupyter.svg b/.icons/jupyter.svg index bc94e3db..38350dfe 100644 --- a/.icons/jupyter.svg +++ b/.icons/jupyter.svg @@ -1,90 +1,14 @@ - -Group.svg -Created using Figma 0.90 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/.icons/kubernetes.svg b/.icons/kubernetes.svg new file mode 100644 index 00000000..42bb9229 --- /dev/null +++ b/.icons/kubernetes.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.icons/lxc.svg b/.icons/lxc.svg new file mode 100644 index 00000000..0e8e118f --- /dev/null +++ b/.icons/lxc.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.icons/nomad.svg b/.icons/nomad.svg new file mode 100644 index 00000000..b4dc91c7 --- /dev/null +++ b/.icons/nomad.svg @@ -0,0 +1,2 @@ + + diff --git a/.icons/zed.svg b/.icons/zed.svg new file mode 100644 index 00000000..06b5c183 --- /dev/null +++ b/.icons/zed.svg @@ -0,0 +1,3 @@ + + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2b990ce..8d63b42d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,294 +1,319 @@ -# Contributing +# Contributing to the Coder Registry -## Getting started +Welcome! This guide covers how to contribute to the Coder Registry, whether you're creating a new module or improving an existing one. -This repo uses two main runtimes to verify the correctness of a module/template before it is published: +## What is the Coder Registry? -- [Bun](https://bun.sh/) – Used to run tests for each module/template to validate overall functionality and correctness of Terraform output -- [Go](https://go.dev/) – Used to validate all README files in the directory. The README content is used to populate [the Registry website](https://registry.coder.com). +The Coder Registry is a collection of Terraform modules that extend Coder workspaces with development tools like VS Code, Cursor, JetBrains IDEs, and more. -### Installing Bun +## Types of Contributions -To install Bun, you can run this command on Linux/MacOS: +- **[New Modules](#creating-a-new-module)** - Add support for a new tool or functionality +- **[Existing Modules](#contributing-to-existing-modules)** - Fix bugs, add features, or improve documentation +- **[Bug Reports](#reporting-issues)** - Report problems or request features -```shell +## Setup + +### Prerequisites + +- Basic Terraform knowledge (for module development) +- Terraform installed ([installation guide](https://developer.hashicorp.com/terraform/install)) +- Docker (for running tests) + +### Install Dependencies + +Install Bun: + +```bash curl -fsSL https://bun.sh/install | bash ``` -Or this command on Windows: +Install project dependencies: -```shell -powershell -c "irm bun.sh/install.ps1 | iex" +```bash +bun install ``` -Follow the instructions to ensure that Bun is available globally. Once Bun is installed, install all necessary dependencies from the root of the repo: +### Understanding Namespaces -Via NPM: +All modules are organized under `/registry/[namespace]/modules/`. Each contributor gets their own namespace (e.g., `/registry/your-username/modules/`). If a namespace is taken, choose a different unique namespace, but you can still use any display name on the Registry website. -```shell -npm i +### Images and Icons + +- **Namespace avatars**: Must be named `avatar.png` or `avatar.svg` in `/registry/[namespace]/.images/` +- **Module screenshots/demos**: Use `/registry/[namespace]/.images/` for module-specific images +- **Module icons**: Use the shared `/.icons/` directory at the root for module icons + +--- + +## Creating a New Module + +### 1. Create Your Namespace (First Time Only) + +If you're a new contributor, create your namespace: + +```bash +mkdir -p registry/[your-username] +mkdir -p registry/[your-username]/.images ``` -Via PNPM: +#### Add Your Avatar -```shell -pnpm i +Every namespace must have an avatar. We recommend using your GitHub avatar: + +1. Download your GitHub avatar from `https://github.com/[your-username].png` +2. Save it as `avatar.png` in `registry/[your-username]/.images/` +3. This gives you a properly sized, square image that's already familiar to the community + +The avatar must be: + +- Named exactly `avatar.png` or `avatar.svg` +- Square image (recommended: 400x400px minimum) +- Supported formats: `.png` or `.svg` only + +#### Create Your Namespace README + +Create `registry/[your-username]/README.md`: + +```markdown +--- +display_name: "Your Name" +bio: "Brief description of who you are and what you do" +avatar_url: "./.images/avatar.png" +github: "your-username" +linkedin: "https://www.linkedin.com/in/your-username" # Optional +website: "https://yourwebsite.com" # Optional +support_email: "you@example.com" # Optional +status: "community" +--- + +# Your Name + +Brief description of who you are and what you do. ``` -This repo does not support Yarn. +> **Note**: The `avatar_url` must point to `./.images/avatar.png` or `./.images/avatar.svg`. -### Installing Go (optional) +### 2. Generate Module Files -This step can be skipped if you are not working on any of the README validation logic. The validation will still run as part of CI. - -[Navigate to the official Go Installation page](https://go.dev/doc/install), and install the correct version for your operating system. - -Once Go has been installed, verify the installation via: - -```shell -go version +```bash +./scripts/new_module.sh [your-username]/[module-name] +cd registry/[your-username]/modules/[module-name] ``` -## Namespaces +This script generates: -All Coder resources are scoped to namespaces placed at the top level of the `/registry` directory. Any modules or templates must be placed inside a namespace to be accepted as a contribution. For example, all modules created by CoderEmployeeBob would be placed under `/registry/coderemployeebob/modules`, with a subdirectory for each individual module the user has published. +- `main.tf` - Terraform configuration template +- `README.md` - Documentation template with frontmatter +- `run.sh` - Script for module execution (can be deleted if not required) -If a namespace is already taken, you will need to create a different, unique namespace, but will still be able to choose any display name. (The display name is shown in the Registry website. More info below.) +### 3. Build Your Module -### Namespace (contributor profile) README files +1. **Edit `main.tf`** to implement your module's functionality +2. **Update `README.md`** with: + - Accurate description and usage examples + - Correct icon path (usually `../../../../.icons/your-icon.svg`) + - Proper tags that describe your module +3. **Create `main.test.ts`** to test your module +4. **Add any scripts** or additional files your module needs -More information about contributor profile README files can be found below. +### 4. Test and Submit -### Images +```bash +# Test your module +bun test -t 'module-name' -Any images needed for either the main namespace directory or a module/template can be placed in a relative `/images` directory at the top of the namespace directory. (e.g., CoderEmployeeBob can have a `/registry/coderemployeebob/images` directory, that can be referenced by the main README file, as well as a README file in `/registry/coderemployeebob/modules/custom_module/README.md`.) This is to minimize the risk of file name conflicts between different users as they add images to help illustrate parts of their README files. +# Format code +bun fmt -## Coder modules - -### Adding a new module - -> [!WARNING] -> These instructions cannot be followed just yet; the script referenced will be made available shortly. Contributors looking to add modules early will need to create all directories manually. - -Once Bun (and possibly Go) have been installed, clone the Coder Registry repository. From there, you can run this script to make it easier to start contributing a new module or template: - -```shell -./new.sh USER_NAMESPACE/NAME_OF_NEW_MODULE +# Commit and create PR +git add . +git commit -m "Add [module-name] module" +git push origin your-branch ``` -You can also create a module file manually by creating the necessary files and directories. +> **Important**: It is your responsibility to implement tests for every new module. Test your module locally before opening a PR. The testing suite requires Docker containers with the `--network=host` flag, which typically requires running tests on Linux (this flag doesn't work with Docker Desktop on macOS/Windows). macOS users can use [Colima](https://github.com/abiosoft/colima) or [OrbStack](https://orbstack.dev/) instead of Docker Desktop. -### The composition of a Coder module +--- -Each Coder Module must contain the following files: +## Contributing to Existing Modules -- A `main.tf` file that defines the main Terraform-based functionality -- A `main.test.ts` file that is used to validate that the module works as expected -- A `README.md` file containing required information (listed below) +### 1. Find the Module -You are free to include any additional files in the module, as needed by the module. For example, the [Windows RDP module](https://github.com/coder/registry/tree/main/registry/coder/modules/windows-rdp) contains additional files for injecting specific functionality into a Coder Workspace. - -> [!NOTE] -> Some legacy modules do not have test files defined just yet. This will be addressed soon. - -### The `main.tf` file - -This file defines all core Terraform functionality, to be mixed into your Coder workspaces. More information about [Coder's use of Terraform can be found here](https://coder.com/docs/admin/templates/extending-templates/modules), and [general information about the Terraform language can be found in the official documentation](https://developer.hashicorp.com/terraform/docs). - -### The structure of a module README - -Validation criteria for module README files is listed below. - -### Testing a Module - -> [!IMPORTANT] -> It is the responsibility of the module author to implement tests for every new module they wish to contribute. It is expected the author has tested the module locally before opening a PR. Feel free to reference existing test files to get an idea for how to set them up. - -All general-purpose test helpers for validating Terraform can be found in the top-level `/testing` directory. The helpers run `terraform apply` on modules that use variables, testing the script output against containers. - -When writing a test file, you can import the test utilities via the `~test` import alias: - -```ts -// This works regardless of how deeply-nested your test file is in the file -// structure -import { - runTerraformApply, - runTerraformInit, - testRequiredVariables, -} from "~test"; +```bash +find registry -name "*[module-name]*" -type d ``` -> [!NOTE] -> The testing suite must be able to run docker containers with the `--network=host` flag. This typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS or Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop. +### 2. Make Your Changes -#### Running tests +**For bug fixes:** -You can run all tests by running this command from the root of the Registry directory: +- Reproduce the issue +- Fix the code in `main.tf` +- Add/update tests +- Update documentation if needed -```shell +**For new features:** + +- Add new variables with sensible defaults +- Implement the feature +- Add tests for new functionality +- Update README with new variables + +**For documentation:** + +- Fix typos and unclear explanations +- Add missing variable documentation +- Improve usage examples + +### 3. Test Your Changes + +```bash +# Test a specific module +bun test -t 'module-name' + +# Test all modules bun test ``` -Note that running _all_ tests can take some time, so you likely don't want to be running this command as part of your core development loop. +### 4. Maintain Backward Compatibility -To run specific tests, you can use the `-t` flag, which accepts a filepath regex: +- New variables should have default values +- Don't break existing functionality +- Test that minimal configurations still work -```shell -bun test -t '' +--- + +## Submitting Changes + +1. **Fork and branch:** + + ```bash + git checkout -b fix/module-name-issue + ``` + +2. **Commit with clear messages:** + + ```bash + git commit -m "Fix version parsing in module-name" + ``` + +3. **Open PR with:** + - Clear title describing the change + - What you changed and why + - Any breaking changes + +### Using PR Templates + +We have different PR templates for different types of contributions. GitHub will show you options to choose from, or you can manually select: + +- **New Module**: Use `?template=new_module.md` +- **Bug Fix**: Use `?template=bug_fix.md` +- **Feature**: Use `?template=feature.md` +- **Documentation**: Use `?template=documentation.md` + +Example: `https://github.com/coder/registry/compare/main...your-branch?template=new_module.md` + +--- + +## Requirements + +### Every Module Must Have + +- `main.tf` - Terraform code +- `main.test.ts` - Working tests +- `README.md` - Documentation with frontmatter + +### README Frontmatter + +Module README frontmatter must include: + +```yaml +--- +display_name: "Module Name" # Required - Name shown on Registry website +description: "What it does" # Required - Short description +icon: "../../../../.icons/tool.svg" # Required - Path to icon file +verified: false # Optional - Set by maintainers only +tags: ["tag1", "tag2"] # Required - Array of descriptive tags +--- ``` -To ensure that the module runs predictably in local development, you can update the Terraform source as follows: +### README Requirements -```tf -module "example" { - # You may need to remove the 'version' field, it is incompatible with some sources. - source = "git::https://github.com//.git//?ref=" -} +All README files must follow these rules: + +- Must have frontmatter section with proper YAML +- Exactly one h1 header directly below frontmatter +- When increasing header levels, increment by one each time +- Use `tf` instead of `hcl` for code blocks + +### Best Practices + +- Use descriptive variable names and descriptions +- Include helpful comments +- Test all functionality +- Follow existing code patterns in the module + +--- + +## Versioning Guidelines + +When you modify a module, you need to update its version number in the README. Understanding version numbers helps you describe the impact of your changes: + +- **Patch** (1.2.3 → 1.2.4): Bug fixes +- **Minor** (1.2.3 → 1.3.0): New features, adding inputs +- **Major** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing types) + +### Updating Module Versions + +If your changes require a version bump, use the version bump script: + +```bash +# For bug fixes +./.github/scripts/version-bump.sh patch + +# For new features +./.github/scripts/version-bump.sh minor + +# For breaking changes +./.github/scripts/version-bump.sh major ``` -## Updating README files +The script will: -This repo uses Go to validate each README file. If you are working with the README files at all (i.e., creating them, modifying them), it is strongly recommended that you install Go (installation instructions mentioned above), so that the files can be validated locally. +1. Detect which modules you've modified +2. Calculate the new version number +3. Update all version references in the module's README +4. Show you a summary of changes -### Validating all README files +**Important**: Only run the version bump script if your changes require a new release. Documentation-only changes don't need version updates. -To validate all README files throughout the entire repo, you can run the following: +--- -```shell -go build ./cmd/readmevalidation && ./readmevalidation -``` +## Reporting Issues -The resulting binary is already part of the `.gitignore` file, but you can remove it with: +When reporting bugs, include: -```shell -rm ./readmevalidation -``` +- Module name and version +- Expected vs actual behavior +- Minimal reproduction case +- Error messages +- Environment details (OS, Terraform version) -### README validation criteria +--- -The following criteria exists for two reasons: +## Getting Help -1. Content accessibility -2. Having content be designed in a way that's easy for the Registry site build step to use +- **Examples**: Check `/registry/coder/modules/` for well-structured modules +- **Issues**: Open an issue for technical problems +- **Community**: Reach out to the Coder community for questions -#### General README requirements +## Common Pitfalls -- There must be a frontmatter section. -- There must be exactly one h1 header, and it must be at the very top, directly below the frontmatter. -- The README body (if it exists) must start with an h1 header. No other content (including GitHub-Flavored Markdown alerts) is allowed to be placed above it. -- When increasing the level of a header, the header's level must be incremented by one each time. -- Any `.hcl` code snippets must be labeled as `.tf` snippets instead +1. **Missing frontmatter** in README +2. **No tests** or broken tests +3. **Hardcoded values** instead of variables +4. **Breaking changes** without defaults +5. **Not running** `bun fmt` before submitting - ```txt - \`\`\`tf - Content - \`\`\` - ``` - -#### Namespace (contributor profile) criteria - -In addition to the general criteria, all README files must have the following: - -- Frontmatter metadata with support for the following fields: - - - `display_name` (required string) – The name to use when displaying your user profile in the Coder Registry site. - - `bio` (optional string) – A short description of who you are. - - `github` (optional string) – Your GitHub handle. - - `avatar_url` (optional string) – A relative/absolute URL pointing to your avatar for the Registry site. It is strongly recommended that you commit avatar images to this repo and reference them via a relative URL. - - `linkedin` (optional string) – A URL pointing to your LinkedIn page. - - `support_email` (optional string) – An email for users to reach you at if they need help with a published module/template. - - `status` (string union) – If defined, this must be one of `"community"`, `"partner"`, or `"official"`. `"community"` should be used for the majority of external contributions. `"partner"` is for companies who have a formal business partnership with Coder. `"official"` should be used only by Coder employees. - -- The README body (the content that goes directly below the frontmatter) is allowed to be empty, but if it isn't, it must follow all the rules above. - -You are free to customize the body of a contributor profile however you like, adding any number of images or information. Its content will never be rendered in the Registry website. - -Additional information can be placed in the README file below the content listed above, using any number of headers. - -Additional image/video assets can be placed in the same user namespace directory where that user's main content lives. - -#### Module criteria - -In addition to the general criteria, all README files must have the following: - -- Frontmatter that describes metadata for the module: - - `display_name` (required string) – This is the name displayed on the Coder Registry website - - `description` (required string) – A short description of the module, which is displayed on the Registry website - - `icon` (required string) – A relative/absolute URL pointing to the icon to display for the module in the Coder Registry website. - - `verified` (optional boolean) – Indicates whether the module has been officially verified by Coder. Please do not set this without approval from a Coder employee. - - `tags` (required string array) – A list of metadata tags to describe the module. Used in the Registry site for search and navigation functionality. - - `maintainer_github` (deprecated string) – The name of the creator of the module. This field exists for backwards compatibility with previous versions of the Registry, but going forward, the value will be inferred from the namespace directory. - - `partner_github` (deprecated string) - The name of any additional creators for a module. This field exists for backwards compatibility with previous versions of the Registry, but should not ever be used going forward. -- The following content directly under the h1 header (without another header between them): - - - A description of what the module does - - A Terraform snippet for letting other users import the functionality - - ```tf - module "cursor" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/cursor/coder" - version = "1.0.19" - agent_id = coder_agent.example.id - } - ``` - -Additional information can be placed in the README file below the content listed above, using any number of headers. - -Additional image/video assets can be placed in one of two places: - -1. In the same user namespace directory where that user's main content lives -2. If the image is an icon, it can be placed in the top-level `.icons` directory (this is done because a lot of modules will be based off the same products) - -## Releases - -The release process involves the following steps: - -### 1. Create and merge a new PR - -- Create a PR with your module changes -- Get your PR reviewed, approved, and merged into the `main` branch - -### 2. Prepare Release (Maintainer Task) - -After merging to `main`, a maintainer will: - -- Check out the merge commit: - - ```shell - git checkout MERGE_COMMIT_ID - ``` - -- Create annotated tags for each module that was changed: - - ```shell - git tag -a "release/$namespace/$module/v$version" -m "Release $namespace/$module v$version" - ``` - -- Push the tags to origin: - - ```shell - git push origin release/$namespace/$module/v$version - ``` - -For example, to release version 1.0.14 of the coder/aider module: - -```shell -git tag -a "release/coder/aider/v1.0.14" -m "Release coder/aider v1.0.14" -git push origin release/coder/aider/v1.0.14 -``` - -### Version Numbers - -Version numbers should follow semantic versioning: - -- **Patch version** (1.2.3 → 1.2.4): Bug fixes -- **Minor version** (1.2.3 → 1.3.0): New features, adding inputs, deprecating inputs -- **Major version** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing input types) - -### 3. Publishing to Coder Registry - -After tags are pushed, the changes will be published to [registry.coder.com](https://registry.coder.com). - -> [!NOTE] -> Some data in registry.coder.com is fetched on demand from this repository's `main` branch. This data should update almost immediately after a release, while other changes will take some time to propagate. +Happy contributing! 🚀 diff --git a/MAINTAINER.md b/MAINTAINER.md new file mode 100644 index 00000000..69d1a657 --- /dev/null +++ b/MAINTAINER.md @@ -0,0 +1,103 @@ +# Maintainer Guide + +Quick reference for maintaining the Coder Registry repository. + +## Setup + +Install Go for README validation: + +```bash +# macOS +brew install go + +# Linux +sudo apt install golang-go +``` + +## Daily Tasks + +### Review PRs + +Check that PRs have: + +- [ ] All required files (`main.tf`, `main.test.ts`, `README.md`) +- [ ] Proper frontmatter in README +- [ ] Working tests (`bun test`) +- [ ] Formatted code (`bun run fmt`) +- [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`) + +#### Version Guidelines + +When reviewing PRs, ensure the version change follows semantic versioning: + +- **Patch** (1.2.3 → 1.2.4): Bug fixes +- **Minor** (1.2.3 → 1.3.0): New features, adding inputs +- **Major** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing types) + +PRs should clearly indicate the version change (e.g., `v1.2.3 → v1.2.4`). + +### Validate READMEs + +```bash +go build ./cmd/readmevalidation && ./readmevalidation +``` + +## Releases + +### Create Release Tags + +After merging a PR: + +1. Get the new version from the PR (shown as `old → new`) +2. Checkout the merge commit and create the tag: + +```bash +# Checkout the merge commit +git checkout MERGE_COMMIT_ID + +# Create and push the release tag using the version from the PR +git tag -a "release/$namespace/$module/v$version" -m "Release $namespace/$module v$version" +git push origin release/$namespace/$module/v$version +``` + +Example: If PR shows `v1.2.3 → v1.2.4`, use `v1.2.4` in the tag. + +### Publishing + +Changes are automatically published to [registry.coder.com](https://registry.coder.com) after tags are pushed. + +## README Requirements + +### Module Frontmatter (Required) + +```yaml +display_name: "Module Name" +description: "What it does" +icon: "../../../../.icons/tool.svg" +maintainer_github: "username" +partner_github: "partner-name" # Optional - For official partner modules +verified: false # Optional - Set by maintainers only +tags: ["tag1", "tag2"] +``` + +### Namespace Frontmatter (Required) + +```yaml +display_name: "Your Name" +bio: "Brief description of who you are and what you do" +avatar_url: "./.images/avatar.png" +github: "username" +linkedin: "https://www.linkedin.com/in/username" # Optional +website: "https://yourwebsite.com" # Optional +support_email: "you@example.com" # Optional +status: "community" # or "partner", "official" +``` + +## Common Issues + +- **README validation fails**: Check YAML syntax, ensure h1 header after frontmatter +- **Tests fail**: Ensure Docker with `--network=host`, check Terraform syntax +- **Wrong file structure**: Use `./scripts/new_module.sh` for new modules +- **Missing namespace avatar**: Must be `avatar.png` or `avatar.svg` in `.images/` directory + +That's it. Keep it simple. diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 98a953c0..d919350f 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -2,9 +2,8 @@ package main import ( "bufio" + "context" "errors" - "fmt" - "log" "net/url" "os" "path" @@ -12,10 +11,18 @@ import ( "slices" "strings" + "golang.org/x/xerrors" "gopkg.in/yaml.v3" ) -var supportedResourceTypes = []string{"modules", "templates"} +var ( + supportedResourceTypes = []string{"modules", "templates"} + + // TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but + // realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's + // structured. Just validating whether it *can* be parsed as Terraform would be a big improvement. + terraformVersionRe = regexp.MustCompile(`^\s*\bversion\s+=`) +) type coderResourceFrontmatter struct { Description string `yaml:"description"` @@ -27,7 +34,7 @@ type coderResourceFrontmatter struct { // coderResourceReadme represents a README describing a Terraform resource used // to help create Coder workspaces. As of 2025-04-15, this encapsulates both -// Coder Modules and Coder Templates +// Coder Modules and Coder Templates. type coderResourceReadme struct { resourceType string filePath string @@ -37,61 +44,60 @@ type coderResourceReadme struct { func validateCoderResourceDisplayName(displayName *string) error { if displayName != nil && *displayName == "" { - return errors.New("if defined, display_name must not be empty string") + return xerrors.New("if defined, display_name must not be empty string") } return nil } func validateCoderResourceDescription(description string) error { if description == "" { - return errors.New("frontmatter description cannot be empty") + return xerrors.New("frontmatter description cannot be empty") } return nil } -func validateCoderResourceIconURL(iconURL string) []error { - problems := []error{} +func isPermittedRelativeURL(checkURL string) bool { + // Would normally be skittish about having relative paths like this, but it should be safe because we have + // guarantees about the structure of the repo, and where this logic will run. + return strings.HasPrefix(checkURL, "./") || strings.HasPrefix(checkURL, "/") || strings.HasPrefix(checkURL, "../../../../.icons") +} +func validateCoderResourceIconURL(iconURL string) []error { if iconURL == "" { - problems = append(problems, errors.New("icon URL cannot be empty")) - return problems + return []error{xerrors.New("icon URL cannot be empty")} } - isAbsoluteURL := !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") - if isAbsoluteURL { + errs := []error{} + + // If the URL does not have a relative path. + if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") { if _, err := url.ParseRequestURI(iconURL); err != nil { - problems = append(problems, errors.New("absolute icon URL is not correctly formatted")) + errs = append(errs, xerrors.New("absolute icon URL is not correctly formatted")) } if strings.Contains(iconURL, "?") { - problems = append(problems, errors.New("icon URLs cannot contain query parameters")) + errs = append(errs, xerrors.New("icon URLs cannot contain query parameters")) } - return problems + return errs } - // Would normally be skittish about having relative paths like this, but it - // should be safe because we have guarantees about the structure of the - // repo, and where this logic will run - isPermittedRelativeURL := strings.HasPrefix(iconURL, "./") || - strings.HasPrefix(iconURL, "/") || - strings.HasPrefix(iconURL, "../../../../.icons") - if !isPermittedRelativeURL { - problems = append(problems, fmt.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL)) + // If the URL has a relative path. + if !isPermittedRelativeURL(iconURL) { + errs = append(errs, xerrors.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL)) } - return problems + return errs } func validateCoderResourceTags(tags []string) error { if tags == nil { - return errors.New("provided tags array is nil") + return xerrors.New("provided tags array is nil") } if len(tags) == 0 { return nil } - // All of these tags are used for the module/template filter controls in the - // Registry site. Need to make sure they can all be placed in the browser - // URL without issue + // All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they + // can all be placed in the browser URL without issue. invalidTags := []string{} for _, t := range tags { if t != url.QueryEscape(t) { @@ -100,21 +106,16 @@ func validateCoderResourceTags(tags []string) error { } if len(invalidTags) != 0 { - return fmt.Errorf("found invalid tags (tags that cannot be used for filter state in the Registry website): [%s]", strings.Join(invalidTags, ", ")) + return xerrors.Errorf("found invalid tags (tags that cannot be used for filter state in the Registry website): [%s]", strings.Join(invalidTags, ", ")) } return nil } -// Todo: This is a holdover from the validation logic used by the Coder Modules -// repo. It gives us some assurance, but realistically, we probably want to -// parse any Terraform code snippets, and make some deeper guarantees about how -// it's structured. Just validating whether it *can* be parsed as Terraform -// would be a big improvement. -var terraformVersionRe = regexp.MustCompile("^\\s*\\bversion\\s+=") - func validateCoderResourceReadmeBody(body string) []error { - trimmed := strings.TrimSpace(body) var errs []error + + trimmed := strings.TrimSpace(body) + // TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test. errs = append(errs, validateReadmeBody(trimmed)...) foundParagraph := false @@ -130,9 +131,8 @@ func validateCoderResourceReadmeBody(body string) []error { lineNum++ nextLine := lineScanner.Text() - // Code assumes that invalid headers would've already been handled by - // the base validation function, so we don't need to check deeper if the - // first line isn't an h1 + // Code assumes that invalid headers would've already been handled by the base validation function, so we don't + // need to check deeper if the first line isn't an h1. if lineNum == 1 { if !strings.HasPrefix(nextLine, "# ") { break @@ -147,7 +147,7 @@ func validateCoderResourceReadmeBody(body string) []error { terraformCodeBlockCount++ } if strings.HasPrefix(nextLine, "```hcl") { - errs = append(errs, errors.New("all .hcl language references must be converted to .tf")) + errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf")) } continue } @@ -159,35 +159,33 @@ func validateCoderResourceReadmeBody(body string) []error { continue } - // Code assumes that we can treat this case as the end of the "h1 - // section" and don't need to process any further lines + // Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines. if lineNum > 1 && strings.HasPrefix(nextLine, "#") { break } - // Code assumes that if we've reached this point, the only other options - // are: (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset - // references made via [] syntax + // Code assumes that if we've reached this point, the only other options are: + // (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax. trimmedLine := strings.TrimSpace(nextLine) isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<") foundParagraph = foundParagraph || isParagraph } if terraformCodeBlockCount == 0 { - errs = append(errs, errors.New("did not find Terraform code block within h1 section")) + errs = append(errs, xerrors.New("did not find Terraform code block within h1 section")) } else { if terraformCodeBlockCount > 1 { - errs = append(errs, errors.New("cannot have more than one Terraform code block in h1 section")) + errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section")) } if !foundTerraformVersionRef { - errs = append(errs, errors.New("did not find Terraform code block that specifies 'version' field")) + errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field")) } } if !foundParagraph { - errs = append(errs, errors.New("did not find paragraph within h1 section")) + errs = append(errs, xerrors.New("did not find paragraph within h1 section")) } if isInsideCodeBlock { - errs = append(errs, errors.New("code blocks inside h1 section do not all terminate before end of file")) + errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file")) } return errs @@ -220,12 +218,12 @@ func validateCoderResourceReadme(rm coderResourceReadme) []error { func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) { fm, body, err := separateFrontmatter(rm.rawText) if err != nil { - return coderResourceReadme{}, fmt.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err) + return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err) } yml := coderResourceFrontmatter{} if err := yaml.Unmarshal([]byte(fm), &yml); err != nil { - return coderResourceReadme{}, fmt.Errorf("%q: failed to parse: %v", rm.filePath, err) + return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err) } return coderResourceReadme{ @@ -250,21 +248,21 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin } if len(yamlParsingErrs) != 0 { return nil, validationPhaseError{ - phase: validationPhaseReadmeParsing, + phase: validationPhaseReadme, errors: yamlParsingErrs, } } yamlValidationErrors := []error{} for _, readme := range resources { - errors := validateCoderResourceReadme(readme) - if len(errors) > 0 { - yamlValidationErrors = append(yamlValidationErrors, errors...) + errs := validateCoderResourceReadme(readme) + if len(errs) > 0 { + yamlValidationErrors = append(yamlValidationErrors, errs...) } } if len(yamlValidationErrors) != 0 { return nil, validationPhaseError{ - phase: validationPhaseReadmeParsing, + phase: validationPhaseReadme, errors: yamlValidationErrors, } } @@ -273,8 +271,8 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin } // Todo: Need to beef up this function by grabbing each image/video URL from -// the body's AST -func validateCoderResourceRelativeUrls(resources map[string]coderResourceReadme) error { +// the body's AST. +func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error { return nil } @@ -321,7 +319,7 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) { if len(errs) != 0 { return nil, validationPhaseError{ - phase: validationPhaseFileLoad, + phase: validationPhaseFile, errors: errs, } } @@ -330,7 +328,7 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) { func validateAllCoderResourceFilesOfType(resourceType string) error { if !slices.Contains(supportedResourceTypes, resourceType) { - return fmt.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", ")) + return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", ")) } allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType) @@ -338,17 +336,16 @@ func validateAllCoderResourceFilesOfType(resourceType string) error { return err } - log.Printf("Processing %d README files\n", len(allReadmeFiles)) + logger.Info(context.Background(), "rocessing README files", "num_files", len(allReadmeFiles)) resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles) if err != nil { return err } - log.Printf("Processed %d README files as valid Coder resources with type %q", len(resources), resourceType) + logger.Info(context.Background(), "rocessed README files as valid Coder resources", "num_files", len(resources), "type", resourceType) - err = validateCoderResourceRelativeUrls(resources) - if err != nil { + if err := validateCoderResourceRelativeURLs(resources); err != nil { return err } - log.Printf("All relative URLs for %s READMEs are valid\n", resourceType) + logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType) return nil } diff --git a/cmd/readmevalidation/contributors.go b/cmd/readmevalidation/contributors.go index daee82c6..aeab7ffa 100644 --- a/cmd/readmevalidation/contributors.go +++ b/cmd/readmevalidation/contributors.go @@ -1,30 +1,27 @@ package main import ( - "errors" - "fmt" - "log" + "context" "net/url" "os" "path" "slices" "strings" + "golang.org/x/xerrors" "gopkg.in/yaml.v3" ) var validContributorStatuses = []string{"official", "partner", "community"} type contributorProfileFrontmatter struct { - DisplayName string `yaml:"display_name"` - Bio string `yaml:"bio"` - ContributorStatus string `yaml:"status"` - // Script assumes that if avatar URL is nil, the Registry site build step - // will backfill the value with the user's GitHub avatar URL - AvatarURL *string `yaml:"avatar"` - LinkedinURL *string `yaml:"linkedin"` - WebsiteURL *string `yaml:"website"` - SupportEmail *string `yaml:"support_email"` + DisplayName string `yaml:"display_name"` + Bio string `yaml:"bio"` + ContributorStatus string `yaml:"status"` + AvatarURL *string `yaml:"avatar"` + LinkedinURL *string `yaml:"linkedin"` + WebsiteURL *string `yaml:"website"` + SupportEmail *string `yaml:"support_email"` } type contributorProfileReadme struct { @@ -35,7 +32,7 @@ type contributorProfileReadme struct { func validateContributorDisplayName(displayName string) error { if displayName == "" { - return fmt.Errorf("missing display_name") + return xerrors.New("missing display_name") } return nil @@ -47,12 +44,15 @@ func validateContributorLinkedinURL(linkedinURL *string) error { } if _, err := url.ParseRequestURI(*linkedinURL); err != nil { - return fmt.Errorf("linkedIn URL %q is not valid: %v", *linkedinURL, err) + return xerrors.Errorf("linkedIn URL %q is not valid: %v", *linkedinURL, err) } return nil } +// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate +// that this is correct without actually sending an email, especially because some contributors are individual developers +// and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure. func validateContributorSupportEmail(email *string) []error { if email == nil { return nil @@ -60,34 +60,30 @@ func validateContributorSupportEmail(email *string) []error { errs := []error{} - // Can't 100% validate that this is correct without actually sending - // an email, and especially with some contributors being individual - // developers, we don't want to do that on every single run of the CI - // pipeline. Best we can do is verify the general structure username, server, ok := strings.Cut(*email, "@") if !ok { - errs = append(errs, fmt.Errorf("email address %q is missing @ symbol", *email)) + errs = append(errs, xerrors.Errorf("email address %q is missing @ symbol", *email)) return errs } if username == "" { - errs = append(errs, fmt.Errorf("email address %q is missing username", *email)) + errs = append(errs, xerrors.Errorf("email address %q is missing username", *email)) } domain, tld, ok := strings.Cut(server, ".") if !ok { - errs = append(errs, fmt.Errorf("email address %q is missing period for server segment", *email)) + errs = append(errs, xerrors.Errorf("email address %q is missing period for server segment", *email)) return errs } if domain == "" { - errs = append(errs, fmt.Errorf("email address %q is missing domain", *email)) + errs = append(errs, xerrors.Errorf("email address %q is missing domain", *email)) } if tld == "" { - errs = append(errs, fmt.Errorf("email address %q is missing top-level domain", *email)) + errs = append(errs, xerrors.Errorf("email address %q is missing top-level domain", *email)) } if strings.Contains(*email, "?") { - errs = append(errs, errors.New("email is not allowed to contain query parameters")) + errs = append(errs, xerrors.New("email is not allowed to contain query parameters")) } return errs @@ -99,7 +95,7 @@ func validateContributorWebsite(websiteURL *string) error { } if _, err := url.ParseRequestURI(*websiteURL); err != nil { - return fmt.Errorf("linkedIn URL %q is not valid: %v", *websiteURL, err) + return xerrors.Errorf("linkedIn URL %q is not valid: %v", *websiteURL, err) } return nil @@ -107,35 +103,32 @@ func validateContributorWebsite(websiteURL *string) error { func validateContributorStatus(status string) error { if !slices.Contains(validContributorStatuses, status) { - return fmt.Errorf("contributor status %q is not valid", status) + return xerrors.Errorf("contributor status %q is not valid", status) } return nil } -// Can't validate the image actually leads to a valid resource in a pure -// function, but can at least catch obvious problems +// Can't validate the image actually leads to a valid resource in a pure function, but can at least catch obvious problems. func validateContributorAvatarURL(avatarURL *string) []error { if avatarURL == nil { return nil } - errs := []error{} if *avatarURL == "" { - errs = append(errs, errors.New("avatar URL must be omitted or non-empty string")) - return errs + return []error{xerrors.New("avatar URL must be omitted or non-empty string")} } - // Have to use .Parse instead of .ParseRequestURI because this is the - // one field that's allowed to be a relative URL + errs := []error{} + // Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL. if _, err := url.Parse(*avatarURL); err != nil { - errs = append(errs, fmt.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL)) + errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL)) } if strings.Contains(*avatarURL, "?") { - errs = append(errs, errors.New("avatar URL is not allowed to contain search parameters")) + errs = append(errs, xerrors.New("avatar URL is not allowed to contain search parameters")) } - matched := false + var matched bool for _, ff := range supportedAvatarFileFormats { matched = strings.HasSuffix(*avatarURL, ff) if matched { @@ -145,7 +138,7 @@ func validateContributorAvatarURL(avatarURL *string) []error { if !matched { segments := strings.Split(*avatarURL, ".") fileExtension := segments[len(segments)-1] - errs = append(errs, fmt.Errorf("avatar URL '.%s' does not end in a supported file format: [%s]", fileExtension, strings.Join(supportedAvatarFileFormats, ", "))) + errs = append(errs, xerrors.Errorf("avatar URL '.%s' does not end in a supported file format: [%s]", fileExtension, strings.Join(supportedAvatarFileFormats, ", "))) } return errs @@ -180,12 +173,12 @@ func validateContributorReadme(rm contributorProfileReadme) []error { func parseContributorProfile(rm readme) (contributorProfileReadme, error) { fm, _, err := separateFrontmatter(rm.rawText) if err != nil { - return contributorProfileReadme{}, fmt.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err) + return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err) } yml := contributorProfileFrontmatter{} if err := yaml.Unmarshal([]byte(fm), &yml); err != nil { - return contributorProfileReadme{}, fmt.Errorf("%q: failed to parse: %v", rm.filePath, err) + return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err) } return contributorProfileReadme{ @@ -206,29 +199,28 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil } if prev, alreadyExists := profilesByNamespace[p.namespace]; alreadyExists { - yamlParsingErrors = append(yamlParsingErrors, fmt.Errorf("%q: namespace %q conflicts with namespace from %q", p.filePath, p.namespace, prev.filePath)) + yamlParsingErrors = append(yamlParsingErrors, xerrors.Errorf("%q: namespace %q conflicts with namespace from %q", p.filePath, p.namespace, prev.filePath)) continue } profilesByNamespace[p.namespace] = p } if len(yamlParsingErrors) != 0 { return nil, validationPhaseError{ - phase: validationPhaseReadmeParsing, + phase: validationPhaseReadme, errors: yamlParsingErrors, } } yamlValidationErrors := []error{} for _, p := range profilesByNamespace { - errors := validateContributorReadme(p) - if len(errors) > 0 { + if errors := validateContributorReadme(p); len(errors) > 0 { yamlValidationErrors = append(yamlValidationErrors, errors...) continue } } if len(yamlValidationErrors) != 0 { return nil, validationPhaseError{ - phase: validationPhaseReadmeParsing, + phase: validationPhaseReadme, errors: yamlValidationErrors, } } @@ -244,12 +236,13 @@ func aggregateContributorReadmeFiles() ([]readme, error) { allReadmeFiles := []readme{} errs := []error{} + dirPath := "" for _, e := range dirEntries { - dirPath := path.Join(rootRegistryPath, e.Name()) if !e.IsDir() { continue } + dirPath = path.Join(rootRegistryPath, e.Name()) readmePath := path.Join(dirPath, "README.md") rmBytes, err := os.ReadFile(readmePath) if err != nil { @@ -264,7 +257,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) { if len(errs) != 0 { return nil, validationPhaseError{ - phase: validationPhaseFileLoad, + phase: validationPhaseFile, errors: errs, } } @@ -272,34 +265,31 @@ func aggregateContributorReadmeFiles() ([]readme, error) { return allReadmeFiles, nil } -func validateContributorRelativeUrls(contributors map[string]contributorProfileReadme) error { - // This function only validates relative avatar URLs for now, but it can be - // beefed up to validate more in the future - errs := []error{} +func validateContributorRelativeURLs(contributors map[string]contributorProfileReadme) error { + // This function only validates relative avatar URLs for now, but it can be beefed up to validate more in the future. + var errs []error for _, con := range contributors { - // If the avatar URL is missing, we'll just assume that the Registry - // site build step will take care of filling in the data properly + // If the avatar URL is missing, we'll just assume that the Registry site build step will take care of filling + // in the data properly. if con.frontmatter.AvatarURL == nil { continue } - isRelativeURL := strings.HasPrefix(*con.frontmatter.AvatarURL, ".") || - strings.HasPrefix(*con.frontmatter.AvatarURL, "/") - if !isRelativeURL { + if !strings.HasPrefix(*con.frontmatter.AvatarURL, ".") || !strings.HasPrefix(*con.frontmatter.AvatarURL, "/") { continue } - if strings.HasPrefix(*con.frontmatter.AvatarURL, "..") { - errs = append(errs, fmt.Errorf("%q: relative avatar URLs cannot be placed outside a user's namespaced directory", con.filePath)) + isAvatarInApprovedSpot := strings.HasPrefix(*con.frontmatter.AvatarURL, "./.images/") || + strings.HasPrefix(*con.frontmatter.AvatarURL, ".images/") + if !isAvatarInApprovedSpot { + errs = append(errs, xerrors.Errorf("%q: relative avatar URLs cannot be placed outside a user's namespaced directory", con.filePath)) continue } - absolutePath := strings.TrimSuffix(con.filePath, "README.md") + - *con.frontmatter.AvatarURL - _, err := os.ReadFile(absolutePath) - if err != nil { - errs = append(errs, fmt.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, *con.frontmatter.AvatarURL)) + absolutePath := strings.TrimSuffix(con.filePath, "README.md") + *con.frontmatter.AvatarURL + if _, err := os.ReadFile(absolutePath); err != nil { + errs = append(errs, xerrors.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, absolutePath)) } } @@ -307,7 +297,7 @@ func validateContributorRelativeUrls(contributors map[string]contributorProfileR return nil } return validationPhaseError{ - phase: validationPhaseAssetCrossReference, + phase: validationPhaseCrossReference, errors: errs, } } @@ -318,19 +308,18 @@ func validateAllContributorFiles() error { return err } - log.Printf("Processing %d README files\n", len(allReadmeFiles)) + logger.Info(context.Background(), "processing README files", "num_files", len(allReadmeFiles)) contributors, err := parseContributorFiles(allReadmeFiles) if err != nil { return err } - log.Printf("Processed %d README files as valid contributor profiles", len(contributors)) + logger.Info(context.Background(), "processed README files as valid contributor profiles", "num_contributors", len(contributors)) - err = validateContributorRelativeUrls(contributors) - if err != nil { + if err := validateContributorRelativeURLs(contributors); err != nil { return err } - log.Println("All relative URLs for READMEs are valid") + logger.Info(context.Background(), "all relative URLs for READMEs are valid") - log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath) + logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath) return nil } diff --git a/cmd/readmevalidation/errors.go b/cmd/readmevalidation/errors.go index d9dbb179..bc7890aa 100644 --- a/cmd/readmevalidation/errors.go +++ b/cmd/readmevalidation/errors.go @@ -1,11 +1,13 @@ package main -import "fmt" +import ( + "fmt" -// validationPhaseError represents an error that occurred during a specific -// phase of README validation. It should be used to collect ALL validation -// errors that happened during a specific phase, rather than the first one -// encountered. + "golang.org/x/xerrors" +) + +// validationPhaseError represents an error that occurred during a specific phase of README validation. It should be +// used to collect ALL validation errors that happened during a specific phase, rather than the first one encountered. type validationPhaseError struct { phase validationPhase errors []error @@ -24,5 +26,5 @@ func (vpe validationPhaseError) Error() string { } func addFilePathToError(filePath string, err error) error { - return fmt.Errorf("%q: %v", filePath, err) + return xerrors.Errorf("%q: %v", filePath, err) } diff --git a/cmd/readmevalidation/main.go b/cmd/readmevalidation/main.go index 6f33f745..cce66df9 100644 --- a/cmd/readmevalidation/main.go +++ b/cmd/readmevalidation/main.go @@ -6,25 +6,28 @@ package main import ( - "fmt" - "log" + "context" "os" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/sloghuman" ) -func main() { - log.Println("Starting README validation") +var logger = slog.Make(sloghuman.Sink(os.Stdout)) - // If there are fundamental problems with how the repo is structured, we - // can't make any guarantees that any further validations will be relevant - // or accurate - repoErr := validateRepoStructure() - if repoErr != nil { - log.Println(repoErr) +func main() { + logger.Info(context.Background(), "starting README validation") + + // If there are fundamental problems with how the repo is structured, we can't make any guarantees that any further + // validations will be relevant or accurate. + err := validateRepoStructure() + if err != nil { + logger.Error(context.Background(), "error when validating the repo structure", "error", err.Error()) os.Exit(1) } var errs []error - err := validateAllContributorFiles() + err = validateAllContributorFiles() if err != nil { errs = append(errs, err) } @@ -34,11 +37,11 @@ func main() { } if len(errs) == 0 { - log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath) + logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath) os.Exit(0) } for _, err := range errs { - fmt.Println(err) + logger.Error(context.Background(), err.Error()) } os.Exit(1) } diff --git a/cmd/readmevalidation/readmefiles.go b/cmd/readmevalidation/readmefiles.go index 29676527..c55806a8 100644 --- a/cmd/readmevalidation/readmefiles.go +++ b/cmd/readmevalidation/readmefiles.go @@ -2,35 +2,65 @@ package main import ( "bufio" - "errors" "fmt" "regexp" "strings" + + "golang.org/x/xerrors" ) -const rootRegistryPath = "./registry" +// validationPhase represents a specific phase during README validation. It is expected that each phase is discrete, and +// errors during one will prevent a future phase from starting. +type validationPhase string -var supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"} +const ( + rootRegistryPath = "./registry" -// readme represents a single README file within the repo (usually within the -// top-level "/registry" directory). + // --- validationPhases --- + // validationPhaseStructure indicates when the entire Registry + // directory is being verified for having all files be placed in the file + // system as expected. + validationPhaseStructure validationPhase = "File structure validation" + + // ValidationPhaseFile indicates when README files are being read from + // the file system. + validationPhaseFile validationPhase = "Filesystem reading" + + // ValidationPhaseReadme indicates when a README's frontmatter is + // being parsed as YAML. This phase does not include YAML validation. + validationPhaseReadme validationPhase = "README parsing" + + // ValidationPhaseCrossReference indicates when a README's frontmatter + // is having all its relative URLs be validated for whether they point to + // valid resources. + validationPhaseCrossReference validationPhase = "Cross-referencing relative asset URLs" + // --- end of validationPhases ---. +) + +var ( + supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"} + // Matches markdown headers, must be at the beginning of a line, such as "# " or "### ". + readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`) +) + +// readme represents a single README file within the repo (usually within the top-level "/registry" directory). type readme struct { filePath string rawText string } -// separateFrontmatter attempts to separate a README file's frontmatter content -// from the main README body, returning both values in that order. It does not -// validate whether the structure of the frontmatter is valid (i.e., that it's +// separateFrontmatter attempts to separate a README file's frontmatter content from the main README body, returning +// both values in that order. It does not validate whether the structure of the frontmatter is valid (i.e., that it's // structured as YAML). -func separateFrontmatter(readmeText string) (string, string, error) { +func separateFrontmatter(readmeText string) (readmeFrontmatter string, readmeBody string, err error) { if readmeText == "" { - return "", "", errors.New("README is empty") + return "", "", xerrors.New("README is empty") } const fence = "---" - fm := "" - body := "" + + var fm strings.Builder + var body strings.Builder fenceCount := 0 lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText))) @@ -40,48 +70,43 @@ func separateFrontmatter(readmeText string) (string, string, error) { fenceCount++ continue } - // Break early if the very first line wasn't a fence, because then we - // know for certain that the README has problems + // Break early if the very first line wasn't a fence, because then we know for certain that the README has problems. if fenceCount == 0 { break } - // It should be safe to trim each line of the frontmatter on a per-line - // basis, because there shouldn't be any extra meaning attached to the - // indentation. The same does NOT apply to the README; best we can do is - // gather all the lines, and then trim around it + // It should be safe to trim each line of the frontmatter on a per-line basis, because there shouldn't be any + // extra meaning attached to the indentation. The same does NOT apply to the README; best we can do is gather + // all the lines and then trim around it. if inReadmeBody := fenceCount >= 2; inReadmeBody { - body += nextLine + "\n" + fmt.Fprintf(&body, "%s\n", nextLine) } else { - fm += strings.TrimSpace(nextLine) + "\n" + fmt.Fprintf(&fm, "%s\n", strings.TrimSpace(nextLine)) } } if fenceCount < 2 { - return "", "", errors.New("README does not have two sets of frontmatter fences") + return "", "", xerrors.New("README does not have two sets of frontmatter fences") } - if fm == "" { - return "", "", errors.New("readme has frontmatter fences but no frontmatter content") + if fm.Len() == 0 { + return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content") } - return fm, strings.TrimSpace(body), nil + return fm.String(), strings.TrimSpace(body.String()), nil } -var readmeHeaderRe = regexp.MustCompile("^(#{1,})(\\s*)") - -// Todo: This seems to work okay for now, but the really proper way of doing -// this is by parsing this as an AST, and then checking the resulting nodes +// TODO: This seems to work okay for now, but the really proper way of doing this is by parsing this as an AST, and then +// checking the resulting nodes. func validateReadmeBody(body string) []error { trimmed := strings.TrimSpace(body) if trimmed == "" { - return []error{errors.New("README body is empty")} + return []error{xerrors.New("README body is empty")} } - // If the very first line of the README, there's a risk that the rest of the - // validation logic will break, since we don't have many guarantees about - // how the README is actually structured + // If the very first line of the README doesn't start with an ATX-style H1 header, there's a risk that the rest of the + // validation logic will break, since we don't have many guarantees about how the README is actually structured. if !strings.HasPrefix(trimmed, "# ") { - return []error{errors.New("README body must start with ATX-style h1 header (i.e., \"# \")")} + return []error{xerrors.New("README body must start with ATX-style h1 header (i.e., \"# \")")} } var errs []error @@ -93,9 +118,8 @@ func validateReadmeBody(body string) []error { for lineScanner.Scan() { nextLine := lineScanner.Text() - // Have to check this because a lot of programming languages support # - // comments (including Terraform), and without any context, there's no - // way to tell the difference between a markdown header and code comment + // Have to check this because a lot of programming languages support # comments (including Terraform), and + // without any context, there's no way to tell the difference between a markdown header and code comment. if strings.HasPrefix(nextLine, "```") { isInCodeBlock = !isInCodeBlock continue @@ -109,9 +133,9 @@ func validateReadmeBody(body string) []error { continue } - spaceAfterHeader := headerGroups[2] - if spaceAfterHeader == "" { - errs = append(errs, errors.New("header does not have space between header characters and main header text")) + // In the Markdown spec it is mandatory to have a space following the header # symbol(s). + if headerGroups[2] == "" { + errs = append(errs, xerrors.New("header does not have space between header characters and main header text")) } nextHeaderLevel := len(headerGroups[1]) @@ -121,58 +145,26 @@ func validateReadmeBody(body string) []error { continue } - // If we have obviously invalid headers, it's not really safe to keep - // proceeding with the rest of the content + // If we have obviously invalid headers, it's not really safe to keep proceeding with the rest of the content. if nextHeaderLevel == 1 { - errs = append(errs, errors.New("READMEs cannot contain more than h1 header")) + errs = append(errs, xerrors.New("READMEs cannot contain more than h1 header")) break } if nextHeaderLevel > 6 { - errs = append(errs, fmt.Errorf("README/HTML files cannot have headers exceed level 6 (found level %d)", nextHeaderLevel)) + errs = append(errs, xerrors.Errorf("README/HTML files cannot have headers exceed level 6 (found level %d)", nextHeaderLevel)) break } - // This is something we need to enforce for accessibility, not just for - // the Registry website, but also when users are viewing the README - // files in the GitHub web view + // This is something we need to enforce for accessibility, not just for the Registry website, but also when + // users are viewing the README files in the GitHub web view. if nextHeaderLevel > latestHeaderLevel && nextHeaderLevel != (latestHeaderLevel+1) { - errs = append(errs, fmt.Errorf("headers are not allowed to increase more than 1 level at a time")) + errs = append(errs, xerrors.New("headers are not allowed to increase more than 1 level at a time")) continue } - // As long as the above condition passes, there's no problems with - // going up a header level or going down 1+ header levels + // As long as the above condition passes, there's no problems with going up a header level or going down 1+ header levels. latestHeaderLevel = nextHeaderLevel } return errs } - -// validationPhase represents a specific phase during README validation. It is -// expected that each phase is discrete, and errors during one will prevent a -// future phase from starting. -type validationPhase string - -const ( - // validationPhaseFileStructureValidation indicates when the entire Registry - // directory is being verified for having all files be placed in the file - // system as expected. - validationPhaseFileStructureValidation validationPhase = "File structure validation" - - // validationPhaseFileLoad indicates when README files are being read from - // the file system - validationPhaseFileLoad = "Filesystem reading" - - // validationPhaseReadmeParsing indicates when a README's frontmatter is - // being parsed as YAML. This phase does not include YAML validation. - validationPhaseReadmeParsing = "README parsing" - - // validationPhaseReadmeValidation indicates when a README's frontmatter is - // being validated as proper YAML with expected keys. - validationPhaseReadmeValidation = "README validation" - - // validationPhaseAssetCrossReference indicates when a README's frontmatter - // is having all its relative URLs be validated for whether they point to - // valid resources. - validationPhaseAssetCrossReference = "Cross-referencing relative asset URLs" -) diff --git a/cmd/readmevalidation/repostructure.go b/cmd/readmevalidation/repostructure.go index 11bd920d..1841d79b 100644 --- a/cmd/readmevalidation/repostructure.go +++ b/cmd/readmevalidation/repostructure.go @@ -2,69 +2,60 @@ package main import ( "errors" - "fmt" "os" "path" "slices" "strings" + + "golang.org/x/xerrors" ) -var supportedUserNameSpaceDirectories = append(supportedResourceTypes[:], ".icons", ".images") +var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images") func validateCoderResourceSubdirectory(dirPath string) []error { - errs := []error{} - subDir, err := os.Stat(dirPath) if err != nil { - // It's valid for a specific resource directory not to exist. It's just - // that if it does exist, it must follow specific rules + // It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules. if !errors.Is(err, os.ErrNotExist) { - errs = append(errs, addFilePathToError(dirPath, err)) + return []error{addFilePathToError(dirPath, err)} } - return errs } if !subDir.IsDir() { - errs = append(errs, fmt.Errorf("%q: path is not a directory", dirPath)) - return errs + return []error{xerrors.Errorf("%q: path is not a directory", dirPath)} } files, err := os.ReadDir(dirPath) if err != nil { - errs = append(errs, addFilePathToError(dirPath, err)) - return errs + return []error{addFilePathToError(dirPath, err)} } + + errs := []error{} for _, f := range files { - // The .coder subdirectories are sometimes generated as part of Bun - // tests. These subdirectories will never be committed to the repo, but - // in the off chance that they don't get cleaned up properly, we want to - // skip over them + // The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be + // committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them. if !f.IsDir() || f.Name() == ".coder" { continue } resourceReadmePath := path.Join(dirPath, f.Name(), "README.md") - _, err := os.Stat(resourceReadmePath) - if err != nil { + if _, err := os.Stat(resourceReadmePath); err != nil { if errors.Is(err, os.ErrNotExist) { - errs = append(errs, fmt.Errorf("%q: 'README.md' does not exist", resourceReadmePath)) + errs = append(errs, xerrors.Errorf("%q: 'README.md' does not exist", resourceReadmePath)) } else { errs = append(errs, addFilePathToError(resourceReadmePath, err)) } } mainTerraformPath := path.Join(dirPath, f.Name(), "main.tf") - _, err = os.Stat(mainTerraformPath) - if err != nil { + if _, err := os.Stat(mainTerraformPath); err != nil { if errors.Is(err, os.ErrNotExist) { - errs = append(errs, fmt.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath)) + errs = append(errs, xerrors.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath)) } else { errs = append(errs, addFilePathToError(mainTerraformPath, err)) } } - } - return errs } @@ -78,13 +69,12 @@ func validateRegistryDirectory() []error { for _, d := range userDirs { dirPath := path.Join(rootRegistryPath, d.Name()) if !d.IsDir() { - allErrs = append(allErrs, fmt.Errorf("detected non-directory file %q at base of main Registry directory", dirPath)) + allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath)) continue } contributorReadmePath := path.Join(dirPath, "README.md") - _, err := os.Stat(contributorReadmePath) - if err != nil { + if _, err := os.Stat(contributorReadmePath); err != nil { allErrs = append(allErrs, err) } @@ -95,8 +85,7 @@ func validateRegistryDirectory() []error { } for _, f := range files { - // Todo: Decide if there's anything more formal that we want to - // ensure about non-directories scoped to user namespaces + // TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces. if !f.IsDir() { continue } @@ -105,13 +94,12 @@ func validateRegistryDirectory() []error { filePath := path.Join(dirPath, segment) if !slices.Contains(supportedUserNameSpaceDirectories, segment) { - allErrs = append(allErrs, fmt.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", "))) + allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", "))) continue } if slices.Contains(supportedResourceTypes, segment) { - errs := validateCoderResourceSubdirectory(filePath) - if len(errs) != 0 { + if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 { allErrs = append(allErrs, errs...) } } @@ -122,20 +110,19 @@ func validateRegistryDirectory() []error { } func validateRepoStructure() error { - var problems []error - if errs := validateRegistryDirectory(); len(errs) != 0 { - problems = append(problems, errs...) + var errs []error + if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 { + errs = append(errs, vrdErrs...) } - _, err := os.Stat("./.icons") - if err != nil { - problems = append(problems, errors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)")) + if _, err := os.Stat("./.icons"); err != nil { + errs = append(errs, xerrors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)")) } - if len(problems) != 0 { + if len(errs) != 0 { return validationPhaseError{ - phase: validationPhaseFileStructureValidation, - errors: problems, + phase: validationPhaseStructure, + errors: errs, } } return nil diff --git a/examples/modules/README.md b/examples/modules/README.md index 80d99231..9d095f29 100644 --- a/examples/modules/README.md +++ b/examples/modules/README.md @@ -15,7 +15,7 @@ tags: [helper] module "MODULE_NAME" { count = data.coder_workspace.me.start_count source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder" - version = "1.0.2" + version = "1.0.0" } ``` @@ -31,7 +31,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/): module "MODULE_NAME" { count = data.coder_workspace.me.start_count source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder" - version = "1.0.2" + version = "1.0.0" agent_id = coder_agent.example.id extensions = [ "dracula-theme.theme-dracula" @@ -49,7 +49,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte module "MODULE_NAME" { count = data.coder_workspace.me.start_count source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder" - version = "1.0.2" + version = "1.0.0" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -65,7 +65,7 @@ Run code-server in the background, don't fetch it from GitHub: ```tf module "MODULE_NAME" { source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder" - version = "1.0.2" + version = "1.0.0" agent_id = coder_agent.example.id offline = true } diff --git a/examples/modules/main.tf b/examples/modules/main.tf index 910320e3..628eb1da 100644 --- a/examples/modules/main.tf +++ b/examples/modules/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } @@ -105,4 +105,3 @@ data "coder_parameter" "MODULE_NAME" { } } } - diff --git a/go.mod b/go.mod index e4074228..7444cfa8 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,25 @@ module coder.com/coder-registry go 1.23.2 -require gopkg.in/yaml.v3 v3.0.1 +require ( + cdr.dev/slog v1.6.1 + github.com/quasilyte/go-ruleguard/dsl v0.3.22 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.7.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + go.opentelemetry.io/otel v1.16.0 // indirect + go.opentelemetry.io/otel/trace v1.16.0 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/sys v0.10.0 // indirect + golang.org/x/term v0.10.0 // indirect +) diff --git a/go.sum b/go.sum index a62c313c..6ef0b6b6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,79 @@ +cdr.dev/slog v1.6.1 h1:IQjWZD0x6//sfv5n+qEhbu3wBkmtBQY5DILXNvMaIv4= +cdr.dev/slog v1.6.1/go.mod h1:eHEYQLaZvxnIAXC+XdTSNLb/kgA/X2RVSF72v5wsxEI= +cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY= +cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/logging v1.7.0 h1:CJYxlNNNNAMkHp9em/YEXcfJg+rPDg7YfwoRpMU+t5I= +cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= +cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI= +cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= +github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= +go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= +go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo= +go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= +go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE= +go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4= +go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= +go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg= +google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108= +google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU= +google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o= +google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw= +google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/images/coder-agent-bar.png b/images/coder-agent-bar.png index b58aa207..b4c4f154 100644 Binary files a/images/coder-agent-bar.png and b/images/coder-agent-bar.png differ diff --git a/registry/coder/.images/amazon-dcv-windows.png b/registry/coder/.images/amazon-dcv-windows.png index 5dd2deef..8672afc8 100644 Binary files a/registry/coder/.images/amazon-dcv-windows.png and b/registry/coder/.images/amazon-dcv-windows.png differ diff --git a/registry/coder/.images/amazon-q.png b/registry/coder/.images/amazon-q.png index a0d1b1ad..1fffcfdb 100644 Binary files a/registry/coder/.images/amazon-q.png and b/registry/coder/.images/amazon-q.png differ diff --git a/registry/coder/.images/avatar.svg b/registry/coder/.images/avatar.svg new file mode 100644 index 00000000..60d7eff6 --- /dev/null +++ b/registry/coder/.images/avatar.svg @@ -0,0 +1,4 @@ + + + + diff --git a/registry/coder/.images/aws-custom.png b/registry/coder/.images/aws-custom.png index e43f616d..d100c427 100644 Binary files a/registry/coder/.images/aws-custom.png 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 index d4c60f22..fbd32515 100644 Binary files a/registry/coder/.images/aws-exclude.png 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 index 4a79efee..1c9ece45 100644 Binary files a/registry/coder/.images/aws-regions.png 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 index 47be4fe9..218d9e7d 100644 Binary files a/registry/coder/.images/azure-custom.png 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 index 963ce09c..060e8926 100644 Binary files a/registry/coder/.images/azure-default.png 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 index 8a4cd01d..7f076bc6 100644 Binary files a/registry/coder/.images/azure-exclude.png 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 index a085450c..a652e069 100644 Binary files a/registry/coder/.images/coder-login.png and b/registry/coder/.images/coder-login.png differ diff --git a/registry/coder/.images/filebrowser.png b/registry/coder/.images/filebrowser.png index 8a8bbf3c..db742fd5 100644 Binary files a/registry/coder/.images/filebrowser.png and b/registry/coder/.images/filebrowser.png differ diff --git a/registry/coder/.images/flyio-basic.png b/registry/coder/.images/flyio-basic.png index 4cd21a2f..6052df90 100644 Binary files a/registry/coder/.images/flyio-basic.png 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 index 4ca25a49..bfa0a38a 100644 Binary files a/registry/coder/.images/flyio-custom.png 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 index f7b07111..7a55e7a1 100644 Binary files a/registry/coder/.images/flyio-filtered.png 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 index 1e0c362d..ab8b4fa9 100644 Binary files a/registry/coder/.images/gcp-regions.png 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 index 55f24a77..5c7b2fd7 100644 Binary files a/registry/coder/.images/git-config-params.png 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 index e5e9cf29..99e5b20c 100644 Binary files a/registry/coder/.images/hcp-vault-secrets-credentials.png 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 index 75807f67..22404ea6 100644 Binary files a/registry/coder/.images/jetbrains-gateway.png 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 index cd897fc7..e13e73af 100644 Binary files a/registry/coder/.images/jfrog-oauth.png and b/registry/coder/.images/jfrog-oauth.png differ diff --git a/registry/coder/.images/jfrog.png b/registry/coder/.images/jfrog.png index 330dad21..1a888c38 100644 Binary files a/registry/coder/.images/jfrog.png and b/registry/coder/.images/jfrog.png differ diff --git a/registry/coder/.images/jupyter-notebook.png b/registry/coder/.images/jupyter-notebook.png index dad85cc0..64d15e63 100644 Binary files a/registry/coder/.images/jupyter-notebook.png and b/registry/coder/.images/jupyter-notebook.png differ diff --git a/registry/coder/.images/jupyterlab.png b/registry/coder/.images/jupyterlab.png index 3a0451c8..309a8a51 100644 Binary files a/registry/coder/.images/jupyterlab.png and b/registry/coder/.images/jupyterlab.png differ diff --git a/registry/coder/.images/vault-login.png b/registry/coder/.images/vault-login.png index f2814b35..bad265b8 100644 Binary files a/registry/coder/.images/vault-login.png 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 index e3e40a4c..4a9b2c31 100644 Binary files a/registry/coder/.images/vscode-desktop.png and b/registry/coder/.images/vscode-desktop.png differ diff --git a/registry/coder/README.md b/registry/coder/README.md index 13d5df2f..1462cd87 100644 --- a/registry/coder/README.md +++ b/registry/coder/README.md @@ -2,6 +2,7 @@ display_name: Coder bio: Coder provisions cloud development environments via Terraform, supporting Linux, macOS, Windows, X86, ARM, Kubernetes and more. github: coder +avatar: ./.images/avatar.svg linkedin: https://www.linkedin.com/company/coderhq website: https://www.coder.com status: official diff --git a/registry/coder/modules/aider/README.md b/registry/coder/modules/aider/README.md index a7fc9cce..d358dd67 100644 --- a/registry/coder/modules/aider/README.md +++ b/registry/coder/modules/aider/README.md @@ -14,7 +14,7 @@ Run [Aider](https://aider.chat) AI pair programming in your workspace. This modu ```tf module "aider" { source = "registry.coder.com/coder/aider/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id } ``` @@ -69,7 +69,7 @@ variable "anthropic_api_key" { module "aider" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/aider/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id ai_api_key = var.anthropic_api_key } @@ -94,7 +94,7 @@ variable "openai_api_key" { module "aider" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/aider/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id use_tmux = true ai_provider = "openai" @@ -115,7 +115,7 @@ variable "custom_api_key" { module "aider" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/aider/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id ai_provider = "custom" custom_env_var_name = "MY_CUSTOM_API_KEY" @@ -132,7 +132,7 @@ You can extend Aider's capabilities by adding custom extensions: module "aider" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/aider/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id ai_api_key = var.anthropic_api_key @@ -211,7 +211,7 @@ data "coder_parameter" "ai_prompt" { module "aider" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/aider/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id ai_api_key = var.anthropic_api_key task_prompt = data.coder_parameter.ai_prompt.value diff --git a/registry/coder/modules/aider/main.tf b/registry/coder/modules/aider/main.tf index 77639df3..e1f2eccd 100644 --- a/registry/coder/modules/aider/main.tf +++ b/registry/coder/modules/aider/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } @@ -24,6 +24,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "icon" { type = string description = "The icon to use for the app." @@ -224,17 +230,17 @@ resource "coder_script" "aider" { } echo "Setting up Aider AI pair programming..." - + if [ "${var.use_screen}" = "true" ] && [ "${var.use_tmux}" = "true" ]; then echo "Error: Both use_screen and use_tmux cannot be enabled at the same time." exit 1 fi - + mkdir -p "${var.folder}" if [ "$(uname)" = "Linux" ]; then echo "Checking dependencies for Linux..." - + if [ "${var.use_tmux}" = "true" ]; then if ! command_exists tmux; then echo "Installing tmux for persistent sessions..." @@ -296,7 +302,7 @@ resource "coder_script" "aider" { if [ "${var.install_aider}" = "true" ]; then echo "Installing Aider..." - + if ! command_exists python3 || ! command_exists pip3; then echo "Installing Python dependencies required for Aider..." if command -v apt-get >/dev/null 2>&1; then @@ -319,37 +325,37 @@ resource "coder_script" "aider" { else echo "Python is already installed, skipping installation." fi - + if ! command_exists aider; then curl -LsSf https://aider.chat/install.sh | sh fi - + if [ -f "$HOME/.bashrc" ]; then if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.bashrc"; then echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.bashrc" fi fi - + if [ -f "$HOME/.zshrc" ]; then if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.zshrc"; then echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.zshrc" fi fi - + fi - + if [ -n "${local.encoded_post_install_script}" ]; then echo "Running post-install script..." echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh chmod +x /tmp/post_install.sh /tmp/post_install.sh fi - + if [ "${var.experiment_report_tasks}" = "true" ]; then echo "Configuring Aider to report tasks via Coder MCP..." - + mkdir -p "$HOME/.config/aider" - + cat > "$HOME/.config/aider/config.yml" << EOL ${trimspace(local.combined_extensions)} EOL @@ -357,29 +363,29 @@ EOL fi echo "Starting persistent Aider session..." - + touch "$HOME/.aider.log" - + export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - + export PATH="$HOME/bin:$PATH" - + if [ "${var.use_tmux}" = "true" ]; then if [ -n "${var.task_prompt}" ]; then echo "Running Aider with message in tmux session..." - + # Configure tmux for shared sessions if [ ! -f "$HOME/.tmux.conf" ]; then echo "Creating ~/.tmux.conf with shared session settings..." echo "set -g mouse on" > "$HOME/.tmux.conf" fi - + if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then echo "Adding 'set -g mouse on' to ~/.tmux.conf..." echo "set -g mouse on" >> "$HOME/.tmux.conf" fi - + echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}" tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"" echo "Aider task started in tmux session '${var.session_name}'. Check the UI for progress." @@ -389,12 +395,12 @@ EOL echo "Creating ~/.tmux.conf with shared session settings..." echo "set -g mouse on" > "$HOME/.tmux.conf" fi - + if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then echo "Adding 'set -g mouse on' to ~/.tmux.conf..." echo "set -g mouse on" >> "$HOME/.tmux.conf" fi - + echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}" tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${var.system_prompt}\"" echo "Tmux session '${var.session_name}' started. Access it by clicking the Aider button." @@ -402,12 +408,12 @@ EOL else if [ -n "${var.task_prompt}" ]; then echo "Running Aider with message in screen session..." - + if [ ! -f "$HOME/.screenrc" ]; then echo "Creating ~/.screenrc and adding multiuser settings..." echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" fi - + if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then echo "Adding 'multiuser on' to ~/.screenrc..." echo "multiuser on" >> "$HOME/.screenrc" @@ -417,7 +423,7 @@ EOL echo "Adding 'acladd $(whoami)' to ~/.screenrc..." echo "acladd $(whoami)" >> "$HOME/.screenrc" fi - + echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}" screen -U -dmS ${var.session_name} bash -c " cd ${var.folder} @@ -426,15 +432,15 @@ EOL aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\" /bin/bash " - + echo "Aider task started in screen session '${var.session_name}'. Check the UI for progress." else - + if [ ! -f "$HOME/.screenrc" ]; then echo "Creating ~/.screenrc and adding multiuser settings..." echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" fi - + if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then echo "Adding 'multiuser on' to ~/.screenrc..." echo "multiuser on" >> "$HOME/.screenrc" @@ -444,7 +450,7 @@ EOL echo "Adding 'acladd $(whoami)' to ~/.screenrc..." echo "acladd $(whoami)" >> "$HOME/.screenrc" fi - + echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}" screen -U -dmS ${var.session_name} bash -c " cd ${var.folder} @@ -456,7 +462,7 @@ EOL echo "Screen session '${var.session_name}' started. Access it by clicking the Aider button." fi fi - + echo "Aider setup complete!" EOT run_on_start = true @@ -471,12 +477,12 @@ resource "coder_app" "aider_cli" { command = <<-EOT #!/bin/bash set -e - + export PATH="$HOME/bin:$HOME/.local/bin:$PATH" - + export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - + if [ "${var.use_tmux}" = "true" ]; then if tmux has-session -t ${var.session_name} 2>/dev/null; then echo "Attaching to existing Aider tmux session..." @@ -499,4 +505,5 @@ resource "coder_app" "aider_cli" { fi EOT order = var.order + group = var.group } diff --git a/registry/coder/modules/amazon-dcv-windows/README.md b/registry/coder/modules/amazon-dcv-windows/README.md index 86c6e407..cd8fb394 100644 --- a/registry/coder/modules/amazon-dcv-windows/README.md +++ b/registry/coder/modules/amazon-dcv-windows/README.md @@ -19,7 +19,7 @@ Enable DCV Server and Web Client on Windows workspaces. module "dcv" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/amazon-dcv-windows/coder" - version = "1.0.24" + version = "1.1.0" agent_id = resource.coder_agent.main.id } diff --git a/registry/coder/modules/amazon-dcv-windows/main.tf b/registry/coder/modules/amazon-dcv-windows/main.tf index 90058af3..223e3b78 100644 --- a/registry/coder/modules/amazon-dcv-windows/main.tf +++ b/registry/coder/modules/amazon-dcv-windows/main.tf @@ -4,11 +4,23 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } +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 "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "agent_id" { type = string description = "The ID of a Coder agent." @@ -45,6 +57,8 @@ resource "coder_app" "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 + order = var.order + group = var.group } resource "coder_script" "install-dcv" { diff --git a/registry/coder/modules/amazon-q/README.md b/registry/coder/modules/amazon-q/README.md index 0713602e..e4fe0837 100644 --- a/registry/coder/modules/amazon-q/README.md +++ b/registry/coder/modules/amazon-q/README.md @@ -14,7 +14,7 @@ Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's A ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id # Required: see below for how to generate experiment_auth_tarball = var.amazon_q_auth_tarball @@ -82,7 +82,7 @@ module "amazon-q" { ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id experiment_auth_tarball = var.amazon_q_auth_tarball experiment_use_tmux = true @@ -94,7 +94,7 @@ module "amazon-q" { ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id experiment_auth_tarball = var.amazon_q_auth_tarball experiment_report_tasks = true @@ -106,7 +106,7 @@ module "amazon-q" { ```tf module "amazon-q" { source = "registry.coder.com/coder/amazon-q/coder" - version = "1.0.1" + version = "1.1.0" agent_id = coder_agent.example.id experiment_auth_tarball = var.amazon_q_auth_tarball experiment_pre_install_script = "echo Pre-install!" @@ -114,25 +114,6 @@ module "amazon-q" { } ``` -## Variables - -| Name | Required | Default | Description | -| -------------------------------- | -------- | ------------------------ | ----------------------------------------------------------------------------------------------- | -| `agent_id` | Yes | — | The ID of a Coder agent. | -| `experiment_auth_tarball` | Yes | — | Base64-encoded, zstd-compressed tarball of a pre-authenticated Amazon Q config directory. | -| `install_amazon_q` | No | `true` | Whether to install Amazon Q. | -| `amazon_q_version` | No | `latest` | Version to install. | -| `experiment_use_screen` | No | `false` | Use GNU screen for background operation. | -| `experiment_use_tmux` | No | `false` | Use tmux for background operation. | -| `experiment_report_tasks` | No | `false` | Enable task reporting to Coder. | -| `experiment_pre_install_script` | No | `null` | Custom script to run before install. | -| `experiment_post_install_script` | No | `null` | Custom script to run after install. | -| `icon` | No | `/icon/amazon-q.svg` | The icon to use for the app. | -| `folder` | No | `/home/coder` | The folder to run Amazon Q in. | -| `order` | No | `null` | The order determines the position of app in the UI presentation. | -| `system_prompt` | No | See [main.tf](./main.tf) | The system prompt to use for Amazon Q. This should instruct the agent how to do task reporting. | -| `ai_prompt` | No | See [main.tf](./main.tf) | The initial task prompt to send to Amazon Q. | - ## Notes - Only one of `experiment_use_screen` or `experiment_use_tmux` can be true at a time. diff --git a/registry/coder/modules/amazon-q/main.tf b/registry/coder/modules/amazon-q/main.tf index 52cc6fdf..851aa062 100644 --- a/registry/coder/modules/amazon-q/main.tf +++ b/registry/coder/modules/amazon-q/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } @@ -24,6 +24,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "icon" { type = string description = "The icon to use for the app." @@ -213,7 +219,7 @@ resource "coder_script" "amazon_q" { fi if [ "${var.experiment_report_tasks}" = "true" ]; then - echo "Configuring Amazon Q to report tasks via Coder MCP..." + echo "Configuring Amazon Q to report tasks via Coder MCP..." mkdir -p ~/.aws/amazonq echo "${local.encoded_mcp_json}" | base64 -d > ~/.aws/amazonq/mcp.json echo "Created the ~/.aws/amazonq/mcp.json configuration file" @@ -227,19 +233,19 @@ resource "coder_script" "amazon_q" { if [ "${var.experiment_use_tmux}" = "true" ]; then echo "Running Amazon Q in the background with tmux..." - + if ! command_exists tmux; then echo "Error: tmux is not installed. Please install tmux manually." exit 1 fi touch "$HOME/.amazon-q.log" - + export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - + tmux new-session -d -s amazon-q -c "${var.folder}" "q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log" && exec bash" - + tmux send-keys -t amazon-q "${local.full_prompt}" sleep 5 tmux send-keys -t amazon-q Enter @@ -247,7 +253,7 @@ resource "coder_script" "amazon_q" { if [ "${var.experiment_use_screen}" = "true" ]; then echo "Running Amazon Q in the background..." - + if ! command_exists screen; then echo "Error: screen is not installed. Please install screen manually." exit 1 @@ -259,7 +265,7 @@ resource "coder_script" "amazon_q" { echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.amazon-q.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/.amazon-q.log" echo "multiuser on" >> "$HOME/.screenrc" @@ -271,7 +277,7 @@ resource "coder_script" "amazon_q" { fi export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - + screen -U -dmS amazon-q bash -c ' cd ${var.folder} q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log @@ -326,4 +332,6 @@ resource "coder_app" "amazon_q" { fi EOT icon = var.icon + order = var.order + group = var.group } diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 6ba18bf2..d9ec5f18 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -4,7 +4,7 @@ description: Run Claude Code in your workspace icon: ../../../../.icons/claude.svg maintainer_github: coder verified: true -tags: [agent, claude-code] +tags: [agent, claude-code, ai] --- # Claude Code @@ -14,7 +14,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "1.2.1" + version = "2.0.1" agent_id = coder_agent.example.id folder = "/home/coder" install_claude_code = true @@ -22,10 +22,14 @@ module "claude-code" { } ``` +> **Security Notice**: This module uses the [`--dangerously-skip-permissions`](https://docs.anthropic.com/en/docs/claude-code/cli-usage#cli-flags) flag when running Claude Code. This flag +> bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While +> this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as +> the user running it. Use this module _only_ in trusted environments and be aware of the security implications. + ## Prerequisites - Node.js and npm must be installed in your workspace to install Claude Code -- Either `screen` or `tmux` must be installed in your workspace to run Claude Code in the background - You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. @@ -43,8 +47,6 @@ The `codercom/oss-dogfood:latest` container image can be used for testing on con > Join our [Discord channel](https://discord.gg/coder) or > [contact us](https://coder.com/contact) to get help or share feedback. -Your workspace must have either `screen` or `tmux` installed to use this. - ```tf variable "anthropic_api_key" { type = string @@ -83,26 +85,25 @@ resource "coder_agent" "main" { module "claude-code" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/claude-code/coder" - version = "1.1.0" + version = "2.0.1" agent_id = coder_agent.example.id folder = "/home/coder" install_claude_code = true - claude_code_version = "0.2.57" + claude_code_version = "1.0.40" # Enable experimental features - experiment_use_screen = true # Or use experiment_use_tmux = true to use tmux instead experiment_report_tasks = true } ``` ## Run standalone -Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it directly without using screen or any task reporting to the Coder UI. +Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it without any task reporting to the Coder UI. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "1.2.1" + version = "2.0.1" agent_id = coder_agent.example.id folder = "/home/coder" install_claude_code = true @@ -112,3 +113,7 @@ module "claude-code" { icon = "https://registry.npmmirror.com/@lobehub/icons-static-png/1.24.0/files/dark/claude-color.png" } ``` + +## Troubleshooting + +The module will create log files in the workspace's `~/.claude-module` directory. If you run into any issues, look at them for more information. diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts new file mode 100644 index 00000000..d9538d45 --- /dev/null +++ b/registry/coder/modules/claude-code/main.test.ts @@ -0,0 +1,322 @@ +import { + test, + afterEach, + expect, + describe, + setDefaultTimeout, + beforeAll, +} from "bun:test"; +import path from "path"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + writeCoder, + writeFileContainer, +} from "~test"; + +let cleanupFunctions: (() => Promise)[] = []; + +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; + +// Cleanup logic depends on the fact that bun's built-in test runner +// runs tests sequentially. +// https://bun.sh/docs/test/discovery#execution-order +// Weird things would happen if tried to run tests in parallel. +// One test could clean up resources that another test was still using. +afterEach(async () => { + // reverse the cleanup functions so that they are run in the correct order + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +const setupContainer = async ({ + image, + vars, +}: { + image?: string; + vars?: Record; +} = {}) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ...vars, + }); + const coderScript = findResourceInstance(state, "coder_script"); + const id = await runContainer(image ?? "codercom/enterprise-node:latest"); + registerCleanup(() => removeContainer(id)); + return { id, coderScript }; +}; + +const loadTestFile = async (...relativePath: string[]) => { + return await Bun.file( + path.join(import.meta.dir, "testdata", ...relativePath), + ).text(); +}; + +const writeExecutable = async ({ + containerId, + filePath, + content, +}: { + containerId: string; + filePath: string; + content: string; +}) => { + await writeFileContainer(containerId, filePath, content, { + user: "root", + }); + await execContainer( + containerId, + ["bash", "-c", `chmod 755 ${filePath}`], + ["--user", "root"], + ); +}; + +const writeAgentAPIMockControl = async ({ + containerId, + content, +}: { + containerId: string; + content: string; +}) => { + await writeFileContainer(containerId, "/tmp/agentapi-mock.control", content, { + user: "coder", + }); +}; + +interface SetupProps { + skipAgentAPIMock?: boolean; + skipClaudeMock?: boolean; +} + +const projectDir = "/home/coder/project"; + +const setup = async (props?: SetupProps): Promise<{ id: string }> => { + const { id, coderScript } = await setupContainer({ + vars: { + experiment_report_tasks: "true", + install_agentapi: props?.skipAgentAPIMock ? "true" : "false", + install_claude_code: "false", + agentapi_version: "preview", + folder: projectDir, + }, + }); + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + // the module script assumes that there is a coder executable in the PATH + await writeCoder(id, await loadTestFile("coder-mock.js")); + if (!props?.skipAgentAPIMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/agentapi", + content: await loadTestFile("agentapi-mock.js"), + }); + } + if (!props?.skipClaudeMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/claude", + content: await loadTestFile("claude-mock.js"), + }); + } + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: coderScript.script, + }); + return { id }; +}; + +const expectAgentAPIStarted = async (id: string) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `curl -fs -o /dev/null "http://localhost:3284/status"`, + ]); + if (resp.exitCode !== 0) { + console.log("agentapi not started"); + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); +}; + +const execModuleScript = async (id: string) => { + const resp = await execContainer(id, [ + "bash", + "-c", + `set -o errexit; set -o pipefail; cd /home/coder && ./script.sh 2>&1 | tee /home/coder/script.log`, + ]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + return resp; +}; + +// increase the default timeout to 60 seconds +setDefaultTimeout(60 * 1000); + +// we don't run these tests in CI because they take too long and make network +// calls. they are dedicated for local development. +describe("claude-code", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + // test that the script runs successfully if claude starts without any errors + test("happy-path", async () => { + const { id } = await setup(); + + const resp = await execContainer(id, [ + "bash", + "-c", + "sudo /home/coder/script.sh", + ]); + expect(resp.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + }); + + // test that the script removes lastSessionId from the .claude.json file + test("last-session-id-removed", async () => { + const { id } = await setup(); + + await writeFileContainer( + id, + "/home/coder/.claude.json", + JSON.stringify({ + projects: { + [projectDir]: { + lastSessionId: "123", + }, + }, + }), + ); + + const catResp = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude.json", + ]); + expect(catResp.exitCode).toBe(0); + expect(catResp.stdout).toContain("lastSessionId"); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + + const catResp2 = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude.json", + ]); + expect(catResp2.exitCode).toBe(0); + expect(catResp2.stdout).not.toContain("lastSessionId"); + }); + + // test that the script handles a .claude.json file that doesn't contain + // a lastSessionId field + test("last-session-id-not-found", async () => { + const { id } = await setup(); + + await writeFileContainer( + id, + "/home/coder/.claude.json", + JSON.stringify({ + projects: { + "/home/coder": {}, + }, + }), + ); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + + const catResp = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude-module/agentapi-start.log", + ]); + expect(catResp.exitCode).toBe(0); + expect(catResp.stdout).toContain( + "No lastSessionId found in .claude.json - nothing to do", + ); + }); + + // test that if claude fails to run with the --continue flag and returns a + // no conversation found error, then the module script retries without the flag + test("no-conversation-found", async () => { + const { id } = await setup(); + await writeAgentAPIMockControl({ + containerId: id, + content: "no-conversation-found", + }); + // check that mocking works + const respAgentAPI = await execContainer(id, [ + "bash", + "-c", + "agentapi --continue", + ]); + expect(respAgentAPI.exitCode).toBe(1); + expect(respAgentAPI.stderr).toContain("No conversation found to continue"); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + }); + + test("install-agentapi", async () => { + const { id } = await setup({ skipAgentAPIMock: true }); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + await expectAgentAPIStarted(id); + const respAgentAPI = await execContainer(id, [ + "bash", + "-c", + "agentapi --version", + ]); + expect(respAgentAPI.exitCode).toBe(0); + }); + + // the coder binary should be executed with specific env vars + // that are set by the module script + test("coder-env-vars", async () => { + const { id } = await setup(); + + const respModuleScript = await execModuleScript(id); + expect(respModuleScript.exitCode).toBe(0); + + const respCoderMock = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/coder-mock-output.json", + ]); + if (respCoderMock.exitCode !== 0) { + console.log(respCoderMock.stdout); + console.log(respCoderMock.stderr); + } + expect(respCoderMock.exitCode).toBe(0); + expect(JSON.parse(respCoderMock.stdout)).toEqual({ + statusSlug: "ccw", + agentApiUrl: "http://localhost:3284", + }); + }); +}); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index cc7b27e0..19496eff 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.7" } } } @@ -24,6 +24,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "icon" { type = string description = "The icon to use for the app." @@ -48,16 +54,22 @@ variable "claude_code_version" { default = "latest" } -variable "experiment_use_screen" { +variable "experiment_cli_app" { type = bool - description = "Whether to use screen for running Claude Code in the background." + description = "Whether to create the CLI workspace app." default = false } -variable "experiment_use_tmux" { - type = bool - description = "Whether to use tmux instead of screen for running Claude Code in the background." - default = false +variable "experiment_cli_app_order" { + type = number + description = "The order of the CLI workspace app." + default = null +} + +variable "experiment_cli_app_group" { + type = string + description = "The group of the CLI workspace app." + default = null } variable "experiment_report_tasks" { @@ -78,9 +90,29 @@ variable "experiment_post_install_script" { default = null } + +variable "install_agentapi" { + type = bool + description = "Whether to install AgentAPI." + default = true +} + +variable "agentapi_version" { + type = string + description = "The version of AgentAPI to install." + default = "v0.2.2" +} + locals { - encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" - encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" + # we have to trim the slash because otherwise coder exp mcp will + # set up an invalid claude config + workdir = trimsuffix(var.folder, "/") + encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : "" + encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : "" + agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh")) + agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh")) + remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.js")) + claude_code_app_slug = "ccw" } # Install and Initialize Claude Code @@ -91,13 +123,18 @@ resource "coder_script" "claude_code" { script = <<-EOT #!/bin/bash set -e + set -x - # Function to check if a command exists command_exists() { command -v "$1" >/dev/null 2>&1 } - # Run pre-install script if provided + if [ ! -d "${local.workdir}" ]; then + echo "Warning: The specified folder '${local.workdir}' does not exist." + echo "Creating the folder..." + mkdir -p "${local.workdir}" + echo "Folder created successfully." + fi if [ -n "${local.encoded_pre_install_script}" ]; then echo "Running pre-install script..." echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh @@ -105,17 +142,89 @@ resource "coder_script" "claude_code" { /tmp/pre_install.sh fi - # Install Claude Code if enabled if [ "${var.install_claude_code}" = "true" ]; then if ! command_exists npm; then - echo "Error: npm is not installed. Please install Node.js and npm first." - exit 1 + echo "npm not found, checking for Node.js installation..." + if ! command_exists node; then + echo "Node.js not found, installing Node.js via NVM..." + export NVM_DIR="$HOME/.nvm" + if [ ! -d "$NVM_DIR" ]; then + mkdir -p "$NVM_DIR" + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + else + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + fi + + nvm install --lts + nvm use --lts + nvm alias default node + + echo "Node.js installed: $(node --version)" + echo "npm installed: $(npm --version)" + else + echo "Node.js is installed but npm is not available. Please install npm manually." + exit 1 + fi fi echo "Installing Claude Code..." npm install -g @anthropic-ai/claude-code@${var.claude_code_version} fi - # Run post-install script if provided + if ! command_exists node; then + echo "Error: Node.js is not installed. Please install Node.js manually." + exit 1 + fi + + # Install AgentAPI if enabled + if [ "${var.install_agentapi}" = "true" ]; then + echo "Installing AgentAPI..." + arch=$(uname -m) + if [ "$arch" = "x86_64" ]; then + binary_name="agentapi-linux-amd64" + elif [ "$arch" = "aarch64" ]; then + binary_name="agentapi-linux-arm64" + else + echo "Error: Unsupported architecture: $arch" + exit 1 + fi + curl \ + --retry 5 \ + --retry-delay 5 \ + --fail \ + --retry-all-errors \ + -L \ + -C - \ + -o agentapi \ + "https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name" + chmod +x agentapi + sudo mv agentapi /usr/local/bin/agentapi + fi + if ! command_exists agentapi; then + echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually." + exit 1 + fi + + # this must be kept in sync with the agentapi-start.sh script + module_path="$HOME/.claude-module" + mkdir -p "$module_path/scripts" + + # save the prompt for the agentapi start command + echo -n "$CODER_MCP_CLAUDE_TASK_PROMPT" > "$module_path/prompt.txt" + + echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh" + echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh" + echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.js" + chmod +x "$module_path/scripts/agentapi-start.sh" + chmod +x "$module_path/scripts/agentapi-wait-for-start.sh" + + if [ "${var.experiment_report_tasks}" = "true" ]; then + echo "Configuring Claude Code to report tasks via Coder MCP..." + export CODER_MCP_APP_STATUS_SLUG="${local.claude_code_app_slug}" + export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" + coder exp mcp configure claude-code "${local.workdir}" + fi + if [ -n "${local.encoded_post_install_script}" ]; then echo "Running post-install script..." echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh @@ -123,99 +232,43 @@ resource "coder_script" "claude_code" { /tmp/post_install.sh fi - if [ "${var.experiment_report_tasks}" = "true" ]; then - echo "Configuring Claude Code to report tasks via Coder MCP..." - coder exp mcp configure claude-code ${var.folder} - fi - - # Handle terminal multiplexer selection (tmux or screen) - if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then - echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously." - echo "Please set only one of them to true." + if ! command_exists claude; then + echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually." exit 1 fi - # Run with tmux if enabled - if [ "${var.experiment_use_tmux}" = "true" ]; then - echo "Running Claude Code in the background with tmux..." - - # Check if tmux is installed - if ! command_exists tmux; then - echo "Error: tmux is not installed. Please install tmux manually." - exit 1 - fi + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 - touch "$HOME/.claude-code.log" - - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - # Create a new tmux session in detached mode - tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions" - - # Send the prompt to the tmux session if needed - if [ -n "$CODER_MCP_CLAUDE_TASK_PROMPT" ]; then - tmux send-keys -t claude-code "$CODER_MCP_CLAUDE_TASK_PROMPT" - sleep 5 - tmux send-keys -t claude-code Enter - fi - fi - - # Run with screen if enabled - if [ "${var.experiment_use_screen}" = "true" ]; then - echo "Running Claude Code in the background..." - - # Check if screen is installed - if ! command_exists screen; then - echo "Error: screen is not installed. Please install screen manually." - exit 1 - fi - - touch "$HOME/.claude-code.log" - - # Ensure the screenrc exists - if [ ! -f "$HOME/.screenrc" ]; then - echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log" - echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc" - fi - - if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then - echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" - echo "multiuser on" >> "$HOME/.screenrc" - fi - - if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then - echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log" - echo "acladd $(whoami)" >> "$HOME/.screenrc" - fi - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 - - screen -U -dmS claude-code bash -c ' - cd ${var.folder} - claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log" - exec bash - ' - # Extremely hacky way to send the prompt to the screen session - # This will be fixed in the future, but `claude` was not sending MCP - # tasks when an initial prompt is provided. - screen -S claude-code -X stuff "$CODER_MCP_CLAUDE_TASK_PROMPT" - sleep 5 - screen -S claude-code -X stuff "^M" - else - # Check if claude is installed before running - if ! command_exists claude; then - echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually." - exit 1 - fi - fi + cd "${local.workdir}" + nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" & + "$module_path/scripts/agentapi-wait-for-start.sh" EOT run_on_start = true } +resource "coder_app" "claude_code_web" { + # use a short slug to mitigate https://github.com/coder/coder/issues/15178 + slug = local.claude_code_app_slug + display_name = "Claude Code Web" + agent_id = var.agent_id + url = "http://localhost:3284/" + icon = var.icon + order = var.order + group = var.group + subdomain = true + healthcheck { + url = "http://localhost:3284/status" + interval = 3 + threshold = 20 + } +} + resource "coder_app" "claude_code" { + count = var.experiment_cli_app ? 1 : 0 + slug = "claude-code" - display_name = "Claude Code" + display_name = "Claude Code CLI" agent_id = var.agent_id command = <<-EOT #!/bin/bash @@ -224,26 +277,15 @@ resource "coder_app" "claude_code" { export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - if [ "${var.experiment_use_tmux}" = "true" ]; then - if tmux has-session -t claude-code 2>/dev/null; then - echo "Attaching to existing Claude Code tmux session." | tee -a "$HOME/.claude-code.log" - tmux attach-session -t claude-code - else - echo "Starting a new Claude Code tmux session." | tee -a "$HOME/.claude-code.log" - tmux new-session -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions | tee -a \"$HOME/.claude-code.log\"; exec bash" - fi - elif [ "${var.experiment_use_screen}" = "true" ]; then - if screen -list | grep -q "claude-code"; then - echo "Attaching to existing Claude Code screen session." | tee -a "$HOME/.claude-code.log" - screen -xRR claude-code - else - echo "Starting a new Claude Code screen session." | tee -a "$HOME/.claude-code.log" - screen -S claude-code bash -c 'claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash' - fi - else - cd ${var.folder} - claude - fi + agentapi attach EOT icon = var.icon + order = var.experiment_cli_app_order + group = var.experiment_cli_app_group +} + +resource "coder_ai_task" "claude_code" { + sidebar_app { + id = coder_app.claude_code_web.id + } } diff --git a/registry/coder/modules/claude-code/scripts/agentapi-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-start.sh new file mode 100644 index 00000000..c66b7f35 --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/agentapi-start.sh @@ -0,0 +1,63 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +# this must be kept in sync with the main.tf file +module_path="$HOME/.claude-module" +scripts_dir="$module_path/scripts" +log_file_path="$module_path/agentapi.log" + +# if the first argument is not empty, start claude with the prompt +if [ -n "$1" ]; then + cp "$module_path/prompt.txt" /tmp/claude-code-prompt +else + rm -f /tmp/claude-code-prompt +fi + +# if the log file already exists, archive it +if [ -f "$log_file_path" ]; then + mv "$log_file_path" "$log_file_path"".$(date +%s)" +fi + +# see the remove-last-session-id.js script for details +# about why we need it +# avoid exiting if the script fails +node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true + +# we'll be manually handling errors from this point on +set +o errexit + +function start_agentapi() { + local continue_flag="$1" + local prompt_subshell='"$(cat /tmp/claude-code-prompt)"' + + # use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters + # visible in the terminal screen by default. + agentapi server --term-width 67 --term-height 1190 -- \ + bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" \ + > "$log_file_path" 2>&1 +} + +echo "Starting AgentAPI..." + +# attempt to start claude with the --continue flag +start_agentapi --continue +exit_code=$? + +echo "First AgentAPI exit code: $exit_code" + +if [ $exit_code -eq 0 ]; then + exit 0 +fi + +# if there was no conversation to continue, claude exited with an error. +# start claude without the --continue flag. +if grep -q "No conversation found to continue" "$log_file_path"; then + echo "AgentAPI with --continue flag failed, starting claude without it." + start_agentapi + exit_code=$? +fi + +echo "Second AgentAPI exit code: $exit_code" + +exit $exit_code diff --git a/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh new file mode 100644 index 00000000..2eb84975 --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/agentapi-wait-for-start.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -o errexit +set -o pipefail + +# This script waits for the agentapi server to start on port 3284. +# It considers the server started after 3 consecutive successful responses. + +agentapi_started=false + +echo "Waiting for agentapi server to start on port 3284..." +for i in $(seq 1 150); do + for j in $(seq 1 3); do + sleep 0.1 + if curl -fs -o /dev/null "http://localhost:3284/status"; then + echo "agentapi response received ($j/3)" + else + echo "agentapi server not responding ($i/15)" + continue 2 + fi + done + agentapi_started=true + break +done + +if [ "$agentapi_started" != "true" ]; then + echo "Error: agentapi server did not start on port 3284 after 15 seconds." + exit 1 +fi + +echo "agentapi server started on port 3284." diff --git a/registry/coder/modules/claude-code/scripts/remove-last-session-id.js b/registry/coder/modules/claude-code/scripts/remove-last-session-id.js new file mode 100644 index 00000000..0b66edfe --- /dev/null +++ b/registry/coder/modules/claude-code/scripts/remove-last-session-id.js @@ -0,0 +1,40 @@ +// If lastSessionId is present in .claude.json, claude --continue will start a +// conversation starting from that session. The problem is that lastSessionId +// doesn't always point to the last session. The field is updated by claude only +// at the point of normal CLI exit. If Claude exits with an error, or if the user +// restarts the Coder workspace, lastSessionId will be stale, and claude --continue +// will start from an old session. +// +// If lastSessionId is missing, claude seems to accurately figure out where to +// start using the conversation history - even if the CLI previously exited with +// an error. +// +// This script removes the lastSessionId field from .claude.json. +const path = require("path") +const fs = require("fs") + +const workingDirArg = process.argv[2] +if (!workingDirArg) { + console.log("No working directory provided - it must be the first argument") + process.exit(1) +} + +const workingDir = path.resolve(workingDirArg) +console.log("workingDir", workingDir) + + +const claudeJsonPath = path.join(process.env.HOME, ".claude.json") +console.log(".claude.json path", claudeJsonPath) +if (!fs.existsSync(claudeJsonPath)) { + console.log("No .claude.json file found") + process.exit(0) +} + +const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8")) +if ("projects" in claudeJson && workingDir in claudeJson.projects && "lastSessionId" in claudeJson.projects[workingDir]) { + delete claudeJson.projects[workingDir].lastSessionId + fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2)) + console.log("Removed lastSessionId from .claude.json") +} else { + console.log("No lastSessionId found in .claude.json - nothing to do") +} diff --git a/registry/coder/modules/claude-code/testdata/agentapi-mock.js b/registry/coder/modules/claude-code/testdata/agentapi-mock.js new file mode 100644 index 00000000..4ea17b5f --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/agentapi-mock.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +const http = require("http"); +const fs = require("fs"); +const args = process.argv.slice(2); +const port = 3284; + +const controlFile = "/tmp/agentapi-mock.control"; +let control = ""; +if (fs.existsSync(controlFile)) { + control = fs.readFileSync(controlFile, "utf8"); +} + +if ( + control === "no-conversation-found" && + args.join(" ").includes("--continue") +) { + // this must match the error message in the agentapi-start.sh script + console.error("No conversation found to continue"); + process.exit(1); +} + +console.log(`starting server on port ${port}`); + +http + .createServer(function (_request, response) { + response.writeHead(200); + response.end( + JSON.stringify({ + status: "stable", + }), + ); + }) + .listen(port); diff --git a/registry/coder/modules/claude-code/testdata/claude-mock.js b/registry/coder/modules/claude-code/testdata/claude-mock.js new file mode 100644 index 00000000..ea9f9aa9 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/claude-mock.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +const main = async () => { + console.log("mocking claude"); + // sleep for 30 minutes + await new Promise((resolve) => setTimeout(resolve, 30 * 60 * 1000)); +}; + +main(); diff --git a/registry/coder/modules/claude-code/testdata/coder-mock.js b/registry/coder/modules/claude-code/testdata/coder-mock.js new file mode 100644 index 00000000..cc479f43 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/coder-mock.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +const fs = require("fs"); + +const statusSlugEnvVar = "CODER_MCP_APP_STATUS_SLUG"; +const agentApiUrlEnvVar = "CODER_MCP_AI_AGENTAPI_URL"; + +fs.writeFileSync( + "/home/coder/coder-mock-output.json", + JSON.stringify({ + statusSlug: process.env[statusSlugEnvVar] ?? "env var not set", + agentApiUrl: process.env[agentApiUrlEnvVar] ?? "env var not set", + }), +); diff --git a/registry/coder/modules/code-server/README.md b/registry/coder/modules/code-server/README.md index e3c73ef5..f994ccd4 100644 --- a/registry/coder/modules/code-server/README.md +++ b/registry/coder/modules/code-server/README.md @@ -4,7 +4,7 @@ description: VS Code in the browser icon: ../../../../.icons/code.svg maintainer_github: coder verified: true -tags: [helper, ide, web] +tags: [ide, web, code-server] --- # code-server @@ -15,7 +15,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.2.0" + version = "1.3.0" agent_id = coder_agent.example.id } ``` @@ -30,7 +30,7 @@ module "code-server" { module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.2.0" + version = "1.3.0" agent_id = coder_agent.example.id install_version = "4.8.3" } @@ -44,7 +44,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/): module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.2.0" + version = "1.3.0" agent_id = coder_agent.example.id extensions = [ "dracula-theme.theme-dracula" @@ -62,7 +62,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.2.0" + version = "1.3.0" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -79,7 +79,7 @@ Just run code-server in the background, don't fetch it from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.2.0" + version = "1.3.0" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] } @@ -95,7 +95,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.2.0" + version = "1.3.0" agent_id = coder_agent.example.id use_cached = true extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] @@ -108,7 +108,7 @@ Just run code-server in the background, don't fetch it from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.2.0" + version = "1.3.0" agent_id = coder_agent.example.id offline = true } diff --git a/registry/coder/modules/code-server/main.tf b/registry/coder/modules/code-server/main.tf index b4e3d259..650829f6 100644 --- a/registry/coder/modules/code-server/main.tf +++ b/registry/coder/modules/code-server/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.1" + version = ">= 2.5" } } } @@ -89,6 +89,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "offline" { type = bool description = "Just run code-server in the background, don't fetch it from GitHub" @@ -187,6 +193,7 @@ resource "coder_app" "code-server" { subdomain = var.subdomain share = var.share order = var.order + group = var.group open_in = var.open_in healthcheck { diff --git a/registry/coder/modules/coder-login/README.md b/registry/coder/modules/coder-login/README.md index fc82dbfe..d6d1d6d6 100644 --- a/registry/coder/modules/coder-login/README.md +++ b/registry/coder/modules/coder-login/README.md @@ -1,7 +1,7 @@ --- display_name: Coder Login description: Automatically logs the user into Coder on their workspace -icon: ../../../../.icons/coder-white.svg +icon: ../../../../.icons/coder.svg maintainer_github: coder verified: true tags: [helper] diff --git a/registry/coder/modules/cursor/README.md b/registry/coder/modules/cursor/README.md index cd7116ae..6ebb5407 100644 --- a/registry/coder/modules/cursor/README.md +++ b/registry/coder/modules/cursor/README.md @@ -4,7 +4,7 @@ description: Add a one-click button to launch Cursor IDE icon: ../../../../.icons/cursor.svg maintainer_github: coder verified: true -tags: [ide, cursor, helper] +tags: [ide, cursor, ai] --- # Cursor IDE @@ -17,7 +17,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder) module "cursor" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/cursor/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id } ``` @@ -30,7 +30,7 @@ module "cursor" { module "cursor" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/cursor/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id folder = "/home/coder/project" } diff --git a/registry/coder/modules/cursor/main.tf b/registry/coder/modules/cursor/main.tf index 89b320f7..47d35b62 100644 --- a/registry/coder/modules/cursor/main.tf +++ b/registry/coder/modules/cursor/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.23" + version = ">= 2.5" } } } @@ -32,6 +32,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "slug" { type = string description = "The slug of the app." @@ -54,6 +60,7 @@ resource "coder_app" "cursor" { slug = var.slug display_name = var.display_name order = var.order + group = var.group url = join("", [ "cursor://coder.coder-remote/open", "?owner=", diff --git a/registry/coder/modules/dotfiles/README.md b/registry/coder/modules/dotfiles/README.md index a4552e7f..bae98b96 100644 --- a/registry/coder/modules/dotfiles/README.md +++ b/registry/coder/modules/dotfiles/README.md @@ -4,7 +4,7 @@ description: Allow developers to optionally bring their own dotfiles repository icon: ../../../../.icons/dotfiles.svg maintainer_github: coder verified: true -tags: [helper] +tags: [helper, dotfiles] --- # Dotfiles @@ -19,7 +19,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/ module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.0.29" + version = "1.1.0" agent_id = coder_agent.example.id } ``` @@ -32,7 +32,7 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.0.29" + version = "1.1.0" agent_id = coder_agent.example.id } ``` @@ -43,7 +43,7 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.0.29" + version = "1.1.0" agent_id = coder_agent.example.id user = "root" } @@ -55,14 +55,14 @@ module "dotfiles" { module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.0.29" + version = "1.1.0" agent_id = coder_agent.example.id } module "dotfiles-root" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.0.29" + version = "1.1.0" agent_id = coder_agent.example.id user = "root" dotfiles_uri = module.dotfiles.dotfiles_uri @@ -77,7 +77,7 @@ You can set a default dotfiles repository for all users by setting the `default_ module "dotfiles" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/dotfiles/coder" - version = "1.0.29" + version = "1.1.0" agent_id = coder_agent.example.id default_dotfiles_uri = "https://github.com/coder/dotfiles" } diff --git a/registry/coder/modules/dotfiles/main.tf b/registry/coder/modules/dotfiles/main.tf index 9bc3735e..b96b1e66 100644 --- a/registry/coder/modules/dotfiles/main.tf +++ b/registry/coder/modules/dotfiles/main.tf @@ -4,11 +4,23 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.12" + version = ">= 2.5" } } } +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 "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "agent_id" { type = string description = "The ID of a Coder agent." @@ -79,6 +91,8 @@ resource "coder_app" "dotfiles" { display_name = "Refresh Dotfiles" slug = "dotfiles" icon = "/icon/dotfiles.svg" + order = var.order + group = var.group command = templatefile("${path.module}/run.sh", { DOTFILES_URI : local.dotfiles_uri, DOTFILES_USER : local.user diff --git a/registry/coder/modules/filebrowser/README.md b/registry/coder/modules/filebrowser/README.md index dfe97594..eac68eae 100644 --- a/registry/coder/modules/filebrowser/README.md +++ b/registry/coder/modules/filebrowser/README.md @@ -4,7 +4,7 @@ description: A file browser for your workspace icon: ../../../../.icons/filebrowser.svg maintainer_github: coder verified: true -tags: [helper, filebrowser] +tags: [filebrowser, web] --- # File Browser @@ -15,7 +15,7 @@ A file browser for your workspace. module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.0.31" + version = "1.1.1" agent_id = coder_agent.example.id } ``` @@ -30,7 +30,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.0.31" + version = "1.1.1" agent_id = coder_agent.example.id folder = "/home/coder/project" } @@ -42,7 +42,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.0.31" + version = "1.1.1" agent_id = coder_agent.example.id database_path = ".config/filebrowser.db" } @@ -54,7 +54,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.0.31" + version = "1.1.1" agent_id = coder_agent.example.id agent_name = "main" subdomain = false diff --git a/registry/coder/modules/filebrowser/main.test.ts b/registry/coder/modules/filebrowser/main.test.ts index 136fa25e..b74b137d 100644 --- a/registry/coder/modules/filebrowser/main.test.ts +++ b/registry/coder/modules/filebrowser/main.test.ts @@ -55,7 +55,7 @@ describe("filebrowser", async () => { ); testBaseLine(output); - }); + }, 15000); it("runs with database_path var", async () => { const state = await runTerraformApply(import.meta.dir, { @@ -63,7 +63,7 @@ describe("filebrowser", async () => { database_path: ".config/filebrowser.db", }); - const output = await await executeScriptInContainer( + const output = await executeScriptInContainer( state, "alpine/curl", "sh", @@ -71,20 +71,21 @@ describe("filebrowser", async () => { ); testBaseLine(output); - }); + }, 15000); it("runs with folder var", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", folder: "/home/coder/project", }); - const output = await await executeScriptInContainer( + const output = await executeScriptInContainer( state, "alpine/curl", "sh", "apk add bash", ); - }); + + }, 15000); it("runs with subdomain=false", async () => { const state = await runTerraformApply(import.meta.dir, { @@ -93,7 +94,7 @@ describe("filebrowser", async () => { subdomain: false, }); - const output = await await executeScriptInContainer( + const output = await executeScriptInContainer( state, "alpine/curl", "sh", @@ -101,5 +102,5 @@ describe("filebrowser", async () => { ); testBaseLine(output); - }); + }, 15000); }); diff --git a/registry/coder/modules/filebrowser/main.tf b/registry/coder/modules/filebrowser/main.tf index ba83844b..498682dd 100644 --- a/registry/coder/modules/filebrowser/main.tf +++ b/registry/coder/modules/filebrowser/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } @@ -68,6 +68,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "slug" { type = string description = "The slug of the coder_app resource." @@ -91,7 +97,6 @@ resource "coder_script" "filebrowser" { 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 @@ -108,6 +113,7 @@ resource "coder_app" "filebrowser" { subdomain = var.subdomain share = var.share order = var.order + group = var.group healthcheck { url = local.healthcheck_url @@ -120,4 +126,5 @@ 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 index ffb87f04..ea4b857a 100644 --- a/registry/coder/modules/filebrowser/run.sh +++ b/registry/coder/modules/filebrowser/run.sh @@ -25,7 +25,7 @@ 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} + filebrowser users add admin "coderPASSWORD" --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} diff --git a/registry/coder/modules/git-commit-signing/README.md b/registry/coder/modules/git-commit-signing/README.md index 60db7583..fdba4a30 100644 --- a/registry/coder/modules/git-commit-signing/README.md +++ b/registry/coder/modules/git-commit-signing/README.md @@ -9,7 +9,7 @@ tags: [helper, git] # git-commit-signing -> [!IMPORTANT] +> [!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. diff --git a/registry/coder/modules/goose/README.md b/registry/coder/modules/goose/README.md index b8673ed1..268b1d00 100644 --- a/registry/coder/modules/goose/README.md +++ b/registry/coder/modules/goose/README.md @@ -4,7 +4,7 @@ description: Run Goose in your workspace icon: ../../../../.icons/goose.svg maintainer_github: coder verified: true -tags: [agent, goose] +tags: [agent, goose, ai] --- # Goose @@ -14,7 +14,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener ```tf module "goose" { source = "registry.coder.com/coder/goose/coder" - version = "1.1.1" + version = "1.3.0" agent_id = coder_agent.example.id folder = "/home/coder" install_goose = true @@ -24,14 +24,14 @@ module "goose" { ## Prerequisites -- `screen` must be installed in your workspace to run Goose in the background +- `screen` or `tmux` 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. +Your workspace must have `screen` or `tmux` installed to use the background session functionality. ### Run in the background and report tasks (Experimental) @@ -90,7 +90,7 @@ resource "coder_agent" "main" { module "goose" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/goose/coder" - version = "1.1.1" + version = "1.3.0" agent_id = coder_agent.example.id folder = "/home/coder" install_goose = true @@ -99,8 +99,12 @@ module "goose" { # Enable experimental features experiment_report_tasks = true - # Run Goose in the background + # Run Goose in the background with screen (pick one: screen or tmux) experiment_use_screen = true + # experiment_use_tmux = true # Alternative: use tmux instead of screen + + # Optional: customize the session name (defaults to "goose") + # session_name = "goose-session" # Avoid configuring Goose manually experiment_auto_configure = true @@ -143,12 +147,12 @@ Note: The indentation in the heredoc is preserved, so you can write the YAML nat ## 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. +Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or tmux, and without any task reporting to the Coder UI. ```tf module "goose" { source = "registry.coder.com/coder/goose/coder" - version = "1.1.1" + version = "1.3.0" agent_id = coder_agent.example.id folder = "/home/coder" install_goose = true diff --git a/registry/coder/modules/goose/main.tf b/registry/coder/modules/goose/main.tf index 0ea5ead9..a159ca7b 100644 --- a/registry/coder/modules/goose/main.tf +++ b/registry/coder/modules/goose/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } @@ -24,6 +24,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "icon" { type = string description = "The icon to use for the app." @@ -54,6 +60,18 @@ variable "experiment_use_screen" { default = false } +variable "experiment_use_tmux" { + type = bool + description = "Whether to use tmux instead of screen for running Goose in the background." + default = false +} + +variable "session_name" { + type = string + description = "Name for the persistent session (screen or tmux)" + default = "goose" +} + variable "experiment_report_tasks" { type = bool description = "Whether to enable task reporting." @@ -182,15 +200,59 @@ GOOSE_MODEL: ${var.experiment_goose_model} ${trimspace(local.combined_extensions)} EOL fi - + # Write system prompt to config mkdir -p "$HOME/.config/goose" echo "$GOOSE_SYSTEM_PROMPT" > "$HOME/.config/goose/.goosehints" - - # Run with screen if enabled - if [ "${var.experiment_use_screen}" = "true" ]; then + + # Handle terminal multiplexer selection (tmux or screen) + if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then + echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously." + echo "Please set only one of them to true." + exit 1 + fi + + # Determine goose command + if command_exists goose; then + GOOSE_CMD=goose + elif [ -f "$HOME/.local/bin/goose" ]; then + GOOSE_CMD="$HOME/.local/bin/goose" + else + echo "Error: Goose is not installed. Please enable install_goose or install it manually." + exit 1 + fi + + # Run with tmux if enabled + if [ "${var.experiment_use_tmux}" = "true" ]; then + echo "Running Goose in the background with tmux..." + + # Check if tmux is installed + if ! command_exists tmux; then + echo "Error: tmux is not installed. Please install tmux manually." + exit 1 + fi + + touch "$HOME/.goose.log" + + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + # Configure tmux for shared sessions + if [ ! -f "$HOME/.tmux.conf" ]; then + echo "Creating ~/.tmux.conf with shared session settings..." + echo "set -g mouse on" > "$HOME/.tmux.conf" + fi + + if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then + echo "Adding 'set -g mouse on' to ~/.tmux.conf..." + echo "set -g mouse on" >> "$HOME/.tmux.conf" + fi + + # Create a new tmux session in detached mode + tmux new-session -d -s ${var.session_name} -c ${var.folder} "\"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"; exec bash" + elif [ "${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." @@ -204,7 +266,7 @@ EOL 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" @@ -216,32 +278,12 @@ EOL fi export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 - - # Determine goose command - if command_exists goose; then - GOOSE_CMD=goose - elif [ -f "$HOME/.local/bin/goose" ]; then - GOOSE_CMD="$HOME/.local/bin/goose" - else - echo "Error: Goose is not installed. Please enable install_goose or install it manually." - exit 1 - fi - - screen -U -dmS goose bash -c " + + screen -U -dmS ${var.session_name} bash -c " cd ${var.folder} \"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\" /bin/bash " - else - # Check if goose is installed before running - if command_exists goose; then - GOOSE_CMD=goose - elif [ -f "$HOME/.local/bin/goose" ]; then - GOOSE_CMD="$HOME/.local/bin/goose" - else - echo "Error: Goose is not installed. Please enable install_goose or install it manually." - exit 1 - fi fi EOT run_on_start = true @@ -270,20 +312,31 @@ resource "coder_app" "goose" { exit 1 fi - if [ "${var.experiment_use_screen}" = "true" ]; then + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + if [ "${var.experiment_use_tmux}" = "true" ]; then + if tmux has-session -t ${var.session_name} 2>/dev/null; then + echo "Attaching to existing Goose tmux session." | tee -a "$HOME/.goose.log" + tmux attach-session -t ${var.session_name} + else + echo "Starting a new Goose tmux session." | tee -a "$HOME/.goose.log" + tmux new-session -s ${var.session_name} -c ${var.folder} "\"$GOOSE_CMD\" run --text \"Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT\" --interactive | tee -a \"$HOME/.goose.log\"; exec bash" + fi + elif [ "${var.experiment_use_screen}" = "true" ]; then # Check if session exists first - if ! screen -list | grep -q "goose"; then + if ! screen -list | grep -q "${var.session_name}"; then echo "Error: No existing Goose session found. Please wait for the script to start it." exit 1 fi # Only attach to existing session - screen -xRR goose + screen -xRR ${var.session_name} else cd ${var.folder} - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 "$GOOSE_CMD" run --text "Review goosehints. Your task: $GOOSE_TASK_PROMPT" --interactive fi EOT icon = var.icon + order = var.order + group = var.group } diff --git a/registry/coder/modules/hcp-vault-secrets/README.md b/registry/coder/modules/hcp-vault-secrets/README.md index e18d2fea..bba62c7d 100644 --- a/registry/coder/modules/hcp-vault-secrets/README.md +++ b/registry/coder/modules/hcp-vault-secrets/README.md @@ -5,7 +5,7 @@ icon: ../../../../.icons/vault.svg maintainer_github: coder partner_github: hashicorp verified: true -tags: [helper, integration, vault, hashicorp, hvs] +tags: [integration, vault, hashicorp, hvs] --- # HCP Vault Secrets diff --git a/registry/coder/modules/jetbrains-gateway/README.md b/registry/coder/modules/jetbrains-gateway/README.md index 6bda2345..21721341 100644 --- a/registry/coder/modules/jetbrains-gateway/README.md +++ b/registry/coder/modules/jetbrains-gateway/README.md @@ -4,7 +4,7 @@ description: Add a one-click button to launch JetBrains Gateway IDEs in the dash icon: ../../../../.icons/gateway.svg maintainer_github: coder verified: true -tags: [ide, jetbrains, helper, parameter] +tags: [ide, jetbrains, parameter, gateway] --- # JetBrains Gateway @@ -18,7 +18,7 @@ Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prereq module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.1.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"] @@ -36,7 +36,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.1.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] @@ -50,7 +50,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.1.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["IU", "PY"] @@ -65,7 +65,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.1.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["IU", "PY"] @@ -90,7 +90,7 @@ module "jetbrains_gateway" { module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.1.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] @@ -108,7 +108,7 @@ Due to the highest priority of the `ide_download_link` parameter in the `(jetbra module "jetbrains_gateway" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-gateway/coder" - version = "1.1.0" + version = "1.2.1" agent_id = coder_agent.example.id folder = "/home/coder/example" jetbrains_ides = ["GO", "WS"] diff --git a/registry/coder/modules/jetbrains-gateway/main.test.ts b/registry/coder/modules/jetbrains-gateway/main.test.ts index 764170e3..51802fab 100644 --- a/registry/coder/modules/jetbrains-gateway/main.test.ts +++ b/registry/coder/modules/jetbrains-gateway/main.test.ts @@ -20,7 +20,7 @@ describe("jetbrains-gateway", async () => { 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", + "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&agent_id=foo", ); const coder_app = state.resources.find( diff --git a/registry/coder/modules/jetbrains-gateway/main.tf b/registry/coder/modules/jetbrains-gateway/main.tf index 502469f2..eda5dcc8 100644 --- a/registry/coder/modules/jetbrains-gateway/main.tf +++ b/registry/coder/modules/jetbrains-gateway/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } http = { source = "hashicorp/http" @@ -45,7 +45,7 @@ variable "folder" { type = string description = "The directory to open in the IDE. e.g. /home/coder/project" validation { - condition = can(regex("^(?:/[^/]+)+$", var.folder)) + condition = can(regex("^(?:/[^/]+)+/?$", var.folder)) error_message = "The folder must be a full path and must not start with a ~." } } @@ -62,6 +62,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + 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)." @@ -324,6 +330,7 @@ resource "coder_app" "gateway" { icon = local.icon external = true order = var.order + group = var.group url = join("", [ "jetbrains-gateway://connect#type=coder&workspace=", data.coder_workspace.me.name, @@ -341,6 +348,8 @@ resource "coder_app" "gateway" { local.build_number, "&ide_download_link=", local.download_link, + "&agent_id=", + var.agent_id, ]) } diff --git a/registry/coder/modules/jfrog-oauth/README.md b/registry/coder/modules/jfrog-oauth/README.md index e8a6a5f6..a1b9d295 100644 --- a/registry/coder/modules/jfrog-oauth/README.md +++ b/registry/coder/modules/jfrog-oauth/README.md @@ -5,7 +5,7 @@ icon: ../../../../.icons/jfrog.svg maintainer_github: coder partner_github: jfrog verified: true -tags: [integration, jfrog] +tags: [integration, jfrog, helper] --- # JFrog diff --git a/registry/coder/modules/jupyter-notebook/README.md b/registry/coder/modules/jupyter-notebook/README.md index 105863ab..ab4f15ca 100644 --- a/registry/coder/modules/jupyter-notebook/README.md +++ b/registry/coder/modules/jupyter-notebook/README.md @@ -4,7 +4,7 @@ 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] +tags: [jupyter, ide, web] --- # Jupyter Notebook @@ -17,7 +17,7 @@ A module that adds Jupyter Notebook in your Coder template. module "jupyter-notebook" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jupyter-notebook/coder" - version = "1.0.19" + version = "1.1.0" agent_id = coder_agent.example.id } ``` diff --git a/registry/coder/modules/jupyter-notebook/main.tf b/registry/coder/modules/jupyter-notebook/main.tf index a588ef15..61cf25eb 100644 --- a/registry/coder/modules/jupyter-notebook/main.tf +++ b/registry/coder/modules/jupyter-notebook/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } @@ -42,6 +42,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + resource "coder_script" "jupyter-notebook" { agent_id = var.agent_id display_name = "jupyter-notebook" @@ -62,4 +68,5 @@ resource "coder_app" "jupyter-notebook" { subdomain = true share = var.share order = var.order + group = var.group } diff --git a/registry/coder/modules/jupyterlab/README.md b/registry/coder/modules/jupyterlab/README.md index d1bd555b..77b5952f 100644 --- a/registry/coder/modules/jupyterlab/README.md +++ b/registry/coder/modules/jupyterlab/README.md @@ -4,7 +4,7 @@ description: A module that adds JupyterLab in your Coder template. icon: ../../../../.icons/jupyter.svg maintainer_github: coder verified: true -tags: [jupyter, helper, ide, web] +tags: [jupyter, ide, web] --- # JupyterLab @@ -17,7 +17,7 @@ A module that adds JupyterLab in your Coder template. module "jupyterlab" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jupyterlab/coder" - version = "1.0.31" + version = "1.1.0" agent_id = coder_agent.example.id } ``` diff --git a/registry/coder/modules/jupyterlab/main.tf b/registry/coder/modules/jupyterlab/main.tf index d66edb1c..1237d980 100644 --- a/registry/coder/modules/jupyterlab/main.tf +++ b/registry/coder/modules/jupyterlab/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } @@ -51,6 +51,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + resource "coder_script" "jupyterlab" { agent_id = var.agent_id display_name = "jupyterlab" @@ -72,4 +78,5 @@ resource "coder_app" "jupyterlab" { subdomain = var.subdomain share = var.share order = var.order + group = var.group } diff --git a/registry/coder/modules/kasmvnc/README.md b/registry/coder/modules/kasmvnc/README.md index df548d1c..995b03f4 100644 --- a/registry/coder/modules/kasmvnc/README.md +++ b/registry/coder/modules/kasmvnc/README.md @@ -4,7 +4,7 @@ description: A modern open source VNC server icon: ../../../../.icons/kasmvnc.svg maintainer_github: coder verified: true -tags: [helper, vnc, desktop] +tags: [vnc, desktop, kasmvnc] --- # KasmVNC @@ -15,9 +15,10 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and module "kasmvnc" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/kasmvnc/coder" - version = "1.0.23" + version = "1.2.0" agent_id = coder_agent.example.id desktop_environment = "xfce" + subdomain = true } ``` diff --git a/registry/coder/modules/kasmvnc/main.tf b/registry/coder/modules/kasmvnc/main.tf index 4265f3c7..ca7315ec 100644 --- a/registry/coder/modules/kasmvnc/main.tf +++ b/registry/coder/modules/kasmvnc/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.12" + version = ">= 2.5" } } } @@ -29,32 +29,56 @@ variable "kasm_version" { 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." } } +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 "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "subdomain" { + type = bool + default = true + description = "Is subdomain sharing enabled in your cluster?" +} + 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 + script = templatefile("${path.module}/run.sh", { + PORT = var.port, + DESKTOP_ENVIRONMENT = var.desktop_environment, + KASM_VERSION = var.kasm_version + SUBDOMAIN = tostring(var.subdomain) + PATH_VNC_HTML = var.subdomain ? "" : file("${path.module}/path_vnc.html") + }) } resource "coder_app" "kasm_vnc" { agent_id = var.agent_id slug = "kasm-vnc" - display_name = "kasmVNC" + display_name = "KasmVNC" url = "http://localhost:${var.port}" icon = "/icon/kasmvnc.svg" - subdomain = true + subdomain = var.subdomain share = "owner" + order = var.order + group = var.group + healthcheck { url = "http://localhost:${var.port}/app" interval = 5 diff --git a/registry/coder/modules/kasmvnc/path_vnc.html b/registry/coder/modules/kasmvnc/path_vnc.html new file mode 100644 index 00000000..849ec777 --- /dev/null +++ b/registry/coder/modules/kasmvnc/path_vnc.html @@ -0,0 +1,81 @@ + + + + Path-Sharing Bounce Page + + + + +

Path-Sharing Bounce Page

+

+ This application is being served via path sharing. + If you are not redirected, check the + Javascript console in your browser's developer tools + for more information. +

+ + + diff --git a/registry/coder/modules/kasmvnc/run.sh b/registry/coder/modules/kasmvnc/run.sh index c285b050..67a8a310 100644 --- a/registry/coder/modules/kasmvnc/run.sh +++ b/registry/coder/modules/kasmvnc/run.sh @@ -3,6 +3,8 @@ # Exit on error, undefined variables, and pipe failures set -euo pipefail +error() { printf "💀 ERROR: %s\n" "$@"; exit 1; } + # Function to check if vncserver is already installed check_installed() { if command -v vncserver &> /dev/null; then @@ -188,7 +190,7 @@ if command -v sudo &> /dev/null && sudo -n true 2> /dev/null; then SUDO=sudo else kasm_config_file="$HOME/.vnc/kasmvnc.yaml" - SUDO= + SUDO="" echo "WARNING: Sudo access not available, using user config dir!" @@ -206,6 +208,7 @@ echo "Writing KasmVNC config to $kasm_config_file" $SUDO tee "$kasm_config_file" > /dev/null << EOF network: protocol: http + interface: 127.0.0.1 websocket_port: ${PORT} ssl: require_ssl: false @@ -220,16 +223,82 @@ EOF # and does not listen publicly echo -e "password\npassword\n" | vncpasswd -wo -u "$USER" +get_http_dir() { + # determine the served file path + # Start with the default + httpd_directory="/usr/share/kasmvnc/www" + + # Check the system configuration path + if [[ -e /etc/kasmvnc/kasmvnc.yaml ]]; then + d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml)) + # If this grep is successful, it will return: + # httpd_directory: /usr/share/kasmvnc/www + if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then + httpd_directory="$${d[1]}" + fi + fi + + # Check the home directory for overriding values + if [[ -e "$HOME/.vnc/kasmvnc.yaml" ]]; then + d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml)) + if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then + httpd_directory="$${d[1]}" + fi + fi + echo $httpd_directory +} + +fix_server_index_file(){ + local fname=$${FUNCNAME[0]} # gets current function name + if [[ $# -ne 1 ]]; then + error "$fname requires exactly 1 parameter:\n\tpath to KasmVNC httpd_directory" + fi + local httpdir="$1" + if [[ ! -d "$httpdir" ]]; then + error "$fname: $httpdir is not a directory" + fi + pushd "$httpdir" > /dev/null + + cat <<'EOH' > /tmp/path_vnc.html +${PATH_VNC_HTML} +EOH + $SUDO mv /tmp/path_vnc.html . + # check for the switcheroo + if [[ -f "index.html" && -L "vnc.html" ]]; then + $SUDO mv $httpdir/index.html $httpdir/vnc.html + fi + $SUDO ln -s -f path_vnc.html index.html + popd > /dev/null +} + +patch_kasm_http_files(){ + homedir=$(get_http_dir) + fix_server_index_file "$homedir" +} + +if [[ "${SUBDOMAIN}" == "false" ]]; then + echo "🩹 Patching up webserver files to support path-sharing..." + patch_kasm_http_files +fi + +VNC_LOG="/tmp/kasmvncserver.log" # 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" +set +e +vncserver -select-de "${DESKTOP_ENVIRONMENT}" -disableBasicAuth > "$VNC_LOG" 2>&1 +RETVAL=$? +set -e + +if [[ $RETVAL -ne 0 ]]; then + echo "ERROR: Failed to start KasmVNC server. Return code: $RETVAL" + if [[ -f "$VNC_LOG" ]]; then + echo "Full logs:" + cat "$VNC_LOG" + else + echo "ERROR: Log file not found: $VNC_LOG" + fi exit 1 fi + printf "🚀 KasmVNC server started successfully!\n" diff --git a/registry/coder/modules/local-windows-rdp/README.md b/registry/coder/modules/local-windows-rdp/README.md new file mode 100644 index 00000000..4b447967 --- /dev/null +++ b/registry/coder/modules/local-windows-rdp/README.md @@ -0,0 +1,74 @@ +--- +display_name: Windows RDP Desktop +description: Enable RDP on Windows and add a one-click Coder Desktop button for seamless access +icon: ../../../../.icons/desktop.svg +maintainer_github: coder +verified: true +supported_os: [windows] +tags: [rdp, windows, desktop, remote] +--- + +# Windows RDP Desktop + +This module enables Remote Desktop Protocol (RDP) on Windows workspaces and adds a one-click button to launch RDP sessions directly through [Coder Desktop](https://coder.com/docs/user-guides/desktop). It provides a complete, standalone solution for RDP access, eliminating the need for manual configuration or port forwarding through the Coder CLI. + +> **Note**: [Coder Desktop](https://coder.com/docs/user-guides/desktop) is required on client devices to use the Local Windows RDP access feature. + +```tf +module "rdp_desktop" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/local-windows-rdp/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + agent_name = coder_agent.main.name +} +``` + +## Features + +- ✅ **Standalone Solution**: Automatically configures RDP on Windows workspaces +- ✅ **One-click Access**: Launch RDP sessions directly through Coder Desktop +- ✅ **No Port Forwarding**: Uses Coder Desktop URI handling +- ✅ **Auto-configuration**: Sets up Windows firewall, services, and authentication +- ✅ **Secure**: Configurable credentials with sensitive variable handling +- ✅ **Customizable**: Display name, credentials, and UI ordering options + +## What This Module Does + +1. **Enables RDP** on the Windows workspace +2. **Sets the administrator password** for RDP authentication +3. **Configures Windows Firewall** to allow RDP connections +4. **Starts RDP services** automatically +5. **Creates a Coder Desktop button** for one-click access + +## Examples + +### Basic Usage + +Uses default credentials (Username: `Administrator`, Password: `coderRDP!`): + +```tf +module "rdp_desktop" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/local-windows-rdp/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + agent_name = coder_agent.main.name +} +``` + +### Custom display name + +Specify a custom display name for the `coder_app` button: + +```tf +module "rdp_desktop" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/local-windows-rdp/coder" + version = "1.0.0" + agent_id = coder_agent.windows.id + agent_name = "windows" + display_name = "Windows Desktop" + order = 1 +} +``` diff --git a/registry/coder/modules/local-windows-rdp/configure-rdp.ps1 b/registry/coder/modules/local-windows-rdp/configure-rdp.ps1 new file mode 100644 index 00000000..34a3b3ec --- /dev/null +++ b/registry/coder/modules/local-windows-rdp/configure-rdp.ps1 @@ -0,0 +1,120 @@ +# PowerShell script to configure RDP for Coder Desktop access +# This script enables RDP, sets the admin password, and configures necessary settings + +Write-Output "[Coder RDP Setup] Starting RDP configuration..." + +# Function to set the administrator password +function Set-AdminPassword { + param ( + [string]$adminUsername, + [string]$adminPassword + ) + + Write-Output "[Coder RDP Setup] Setting password for user: $adminUsername" + + try { + # Convert password to secure string + $securePassword = ConvertTo-SecureString -AsPlainText $adminPassword -Force + + # Set the password for the user + Get-LocalUser -Name $adminUsername | Set-LocalUser -Password $securePassword + + # Enable the user account (in case it's disabled) + Get-LocalUser -Name $adminUsername | Enable-LocalUser + + Write-Output "[Coder RDP Setup] Successfully set password for $adminUsername" + } catch { + Write-Error "[Coder RDP Setup] Failed to set password: $_" + exit 1 + } +} + +# Function to enable and configure RDP +function Enable-RDP { + Write-Output "[Coder RDP Setup] Enabling Remote Desktop..." + + try { + # Enable RDP + Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 -Force + + # Disable Network Level Authentication (NLA) for easier access + Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "UserAuthentication" -Value 0 -Force + + # Set security layer to RDP Security Layer + Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "SecurityLayer" -Value 1 -Force + + Write-Output "[Coder RDP Setup] RDP enabled successfully" + } catch { + Write-Error "[Coder RDP Setup] Failed to enable RDP: $_" + exit 1 + } +} + +# Function to configure Windows Firewall for RDP +function Configure-Firewall { + Write-Output "[Coder RDP Setup] Configuring Windows Firewall for RDP..." + + try { + # Enable RDP firewall rules + Enable-NetFirewallRule -DisplayGroup "Remote Desktop" -ErrorAction SilentlyContinue + + # If the above fails, try alternative method + if ($LASTEXITCODE -ne 0) { + netsh advfirewall firewall set rule group="remote desktop" new enable=Yes + } + + Write-Output "[Coder RDP Setup] Firewall configured successfully" + } catch { + Write-Warning "[Coder RDP Setup] Failed to configure firewall rules: $_" + # Continue anyway as RDP might still work + } +} + +# Function to ensure RDP service is running +function Start-RDPService { + Write-Output "[Coder RDP Setup] Starting Remote Desktop Services..." + + try { + # Start the Terminal Services + Set-Service -Name "TermService" -StartupType Automatic -ErrorAction SilentlyContinue + Start-Service -Name "TermService" -ErrorAction SilentlyContinue + + # Start Remote Desktop Services UserMode Port Redirector + Set-Service -Name "UmRdpService" -StartupType Automatic -ErrorAction SilentlyContinue + Start-Service -Name "UmRdpService" -ErrorAction SilentlyContinue + + Write-Output "[Coder RDP Setup] RDP services started successfully" + } catch { + Write-Warning "[Coder RDP Setup] Some RDP services may not have started: $_" + # Continue anyway + } +} + +# Main execution +try { + # Template variables from Terraform + $username = "${username}" + $password = "${password}" + + # Validate inputs + if ([string]::IsNullOrWhiteSpace($username) -or [string]::IsNullOrWhiteSpace($password)) { + Write-Error "[Coder RDP Setup] Username or password is empty" + exit 1 + } + + # Execute configuration steps + Set-AdminPassword -adminUsername $username -adminPassword $password + Enable-RDP + Configure-Firewall + Start-RDPService + + Write-Output "[Coder RDP Setup] RDP configuration completed successfully!" + Write-Output "[Coder RDP Setup] You can now connect using:" + Write-Output " Username: $username" + Write-Output " Password: [hidden]" + Write-Output " Port: 3389 (default)" + +} catch { + Write-Error "[Coder RDP Setup] An unexpected error occurred: $_" + exit 1 +} \ No newline at end of file diff --git a/registry/coder/modules/local-windows-rdp/main.test.ts b/registry/coder/modules/local-windows-rdp/main.test.ts new file mode 100644 index 00000000..75b6dde0 --- /dev/null +++ b/registry/coder/modules/local-windows-rdp/main.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "bun:test"; +import { + type TerraformState, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +type TestVariables = Readonly<{ + agent_id: string; + agent_name: string; + username?: string; + password?: string; + display_name?: string; + order?: number; +}>; + +function findRdpApp(state: TerraformState) { + for (const resource of state.resources) { + const isRdpAppResource = + resource.type === "coder_app" && resource.name === "rdp_desktop"; + + if (!isRdpAppResource) { + continue; + } + + for (const instance of resource.instances) { + if (instance.attributes.slug === "rdp-desktop") { + return instance.attributes; + } + } + } + + return null; +} + +function findRdpScript(state: TerraformState) { + for (const resource of state.resources) { + const isRdpScriptResource = + resource.type === "coder_script" && resource.name === "rdp_setup"; + + if (!isRdpScriptResource) { + continue; + } + + for (const instance of resource.instances) { + if (instance.attributes.display_name === "Configure RDP") { + return instance.attributes; + } + } + } + + return null; +} + +describe("local-windows-rdp", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "test-agent", + }); + + it("should create RDP app with default values", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + }); + + const app = findRdpApp(state); + + // Verify the app was created + expect(app).not.toBeNull(); + expect(app?.slug).toBe("rdp-desktop"); + expect(app?.display_name).toBe("RDP Desktop"); + expect(app?.icon).toBe("/icon/desktop.svg"); + expect(app?.external).toBe(true); + + // Verify the URI format + expect(app?.url).toStartWith("coder://"); + expect(app?.url).toContain("/v0/open/ws/"); + expect(app?.url).toContain("/agent/main/rdp"); + expect(app?.url).toContain("username=Administrator"); + expect(app?.url).toContain("password=coderRDP!"); + }); + + it("should create RDP configuration script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + }); + + const script = findRdpScript(state); + + // Verify the script was created + expect(script).not.toBeNull(); + expect(script?.display_name).toBe("Configure RDP"); + expect(script?.icon).toBe("/icon/desktop.svg"); + expect(script?.run_on_start).toBe(true); + expect(script?.run_on_stop).toBe(false); + + // Verify the script contains PowerShell configuration + expect(script?.script).toContain("Set-AdminPassword"); + expect(script?.script).toContain("Enable-RDP"); + expect(script?.script).toContain("Configure-Firewall"); + expect(script?.script).toContain("Start-RDPService"); + }); + + it("should create RDP app with custom values", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "custom-agent-id", + agent_name: "windows-agent", + username: "CustomUser", + password: "CustomPass123!", + display_name: "Custom RDP", + order: 5, + }); + + const app = findRdpApp(state); + + // Verify custom values + expect(app?.display_name).toBe("Custom RDP"); + expect(app?.order).toBe(5); + + // Verify custom credentials in URI + expect(app?.url).toContain("/agent/windows-agent/rdp"); + expect(app?.url).toContain("username=CustomUser"); + expect(app?.url).toContain("password=CustomPass123!"); + }); + + it("should pass custom credentials to PowerShell script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + username: "TestAdmin", + password: "TestPassword123!", + }); + + const script = findRdpScript(state); + + // Verify custom credentials are in the script + expect(script?.script).toContain('$username = "TestAdmin"'); + expect(script?.script).toContain('$password = "TestPassword123!"'); + }); + + it("should handle sensitive password variable", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + password: "SensitivePass123!", + }); + + const app = findRdpApp(state); + + // Verify password is included in URI even when sensitive + expect(app?.url).toContain("password=SensitivePass123!"); + }); + + it("should use correct default agent name", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "main", + }); + + const app = findRdpApp(state); + expect(app?.url).toContain("/agent/main/rdp"); + }); + + it("should construct proper Coder URI format", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_name: "test-agent", + username: "TestUser", + password: "TestPass", + }); + + const app = findRdpApp(state); + + // Verify complete URI structure + expect(app?.url).toMatch( + /^coder:\/\/[^\/]+\/v0\/open\/ws\/[^\/]+\/agent\/test-agent\/rdp\?username=TestUser&password=TestPass$/, + ); + }); +}); diff --git a/registry/coder/modules/local-windows-rdp/main.tf b/registry/coder/modules/local-windows-rdp/main.tf new file mode 100644 index 00000000..b5f59445 --- /dev/null +++ b/registry/coder/modules/local-windows-rdp/main.tf @@ -0,0 +1,81 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "agent_name" { + type = string + description = "The name of the Coder agent." +} + +variable "username" { + type = string + description = "The username for RDP authentication." + default = "Administrator" +} + +variable "password" { + type = string + description = "The password for RDP authentication." + default = "coderRDP!" + sensitive = true +} + +variable "display_name" { + type = string + description = "The display name for the RDP app button." + default = "RDP Desktop" +} + +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 "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +locals { + # Extract server name from workspace access URL + server_name = regex("https?:\\/\\/([^\\/]+)", data.coder_workspace.me.access_url)[0] +} + +data "coder_workspace" "me" {} + +resource "coder_script" "rdp_setup" { + agent_id = var.agent_id + display_name = "Configure RDP" + icon = "/icon/desktop.svg" + script = templatefile("${path.module}/configure-rdp.ps1", { + username = var.username + password = var.password + }) + run_on_start = true +} + +resource "coder_app" "rdp_desktop" { + agent_id = var.agent_id + slug = "rdp-desktop" + display_name = var.display_name + url = "coder://${local.server_name}/v0/open/ws/${data.coder_workspace.me.name}/agent/${var.agent_name}/rdp?username=${var.username}&password=${var.password}" + icon = "/icon/desktop.svg" + external = true + order = var.order + group = var.group +} + diff --git a/registry/coder/modules/personalize/README.md b/registry/coder/modules/personalize/README.md index 3a5b2cba..d6bff9ae 100644 --- a/registry/coder/modules/personalize/README.md +++ b/registry/coder/modules/personalize/README.md @@ -4,7 +4,7 @@ description: Allow developers to customize their workspace on start icon: ../../../../.icons/personalize.svg maintainer_github: coder verified: true -tags: [helper] +tags: [helper, personalize] --- # Personalize diff --git a/registry/coder/modules/slackme/README.md b/registry/coder/modules/slackme/README.md index d65012bf..ab416760 100644 --- a/registry/coder/modules/slackme/README.md +++ b/registry/coder/modules/slackme/README.md @@ -4,7 +4,7 @@ description: Send a Slack message when a command finishes inside a workspace! icon: ../../../../.icons/slack.svg maintainer_github: coder verified: true -tags: [helper] +tags: [helper, slack] --- # Slack Me diff --git a/registry/coder/modules/vault-github/README.md b/registry/coder/modules/vault-github/README.md index 44b7cf66..2dc66e01 100644 --- a/registry/coder/modules/vault-github/README.md +++ b/registry/coder/modules/vault-github/README.md @@ -5,7 +5,7 @@ icon: ../../../../.icons/vault.svg maintainer_github: coder partner_github: hashicorp verified: true -tags: [helper, integration, vault, github] +tags: [hashicorp, integration, vault, github] --- # Hashicorp Vault Integration (GitHub) diff --git a/registry/coder/modules/vault-jwt/README.md b/registry/coder/modules/vault-jwt/README.md index 53eb41bf..c3f9199f 100644 --- a/registry/coder/modules/vault-jwt/README.md +++ b/registry/coder/modules/vault-jwt/README.md @@ -5,7 +5,7 @@ icon: ../../../../.icons/vault.svg maintainer_github: coder partner_github: hashicorp verified: true -tags: [helper, integration, vault, jwt, oidc] +tags: [hashicorp, integration, vault, jwt, oidc] --- # Hashicorp Vault Integration (JWT) @@ -109,7 +109,7 @@ resource "jwt_signed_token" "vault" { sub = "${data.coder_workspace.me.id}" aud = "https://vault.example.com" iat = provider::time::rfc3339_parse(plantimestamp()).unix - # Uncomment to set an expiry on the JWT token(default 3600 seconds). + # Uncomment to set an expiry on the JWT token(default 3600 seconds). # workspace will need to be restarted to generate a new token if it expires #exp = provider::time::rfc3339_parse(timeadd(timestamp(), 3600)).unix agent = coder_agent.main.id provisioner = data.coder_provisioner.main.id diff --git a/registry/coder/modules/vault-token/README.md b/registry/coder/modules/vault-token/README.md index 26428dfa..f6a4b084 100644 --- a/registry/coder/modules/vault-token/README.md +++ b/registry/coder/modules/vault-token/README.md @@ -5,7 +5,7 @@ icon: ../../../../.icons/vault.svg maintainer_github: coder partner_github: hashicorp verified: true -tags: [helper, integration, vault, token] +tags: [hashicorp, integration, vault, token] --- # Hashicorp Vault Integration (Token) @@ -20,11 +20,12 @@ variable "vault_token" { } module "vault" { - source = "registry.coder.com/coder/vault-token/coder" - version = "1.1.0" - agent_id = coder_agent.example.id - vault_token = var.token # optional - vault_addr = "https://vault.example.com" + source = "registry.coder.com/coder/vault-token/coder" + version = "1.2.0" + agent_id = coder_agent.example.id + vault_token = var.token # optional + vault_addr = "https://vault.example.com" + vault_namespace = "prod" # optional, vault enterprise only } ``` @@ -74,7 +75,7 @@ variable "vault_token" { module "vault" { source = "registry.coder.com/coder/vault-token/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" vault_token = var.token diff --git a/registry/coder/modules/vault-token/main.tf b/registry/coder/modules/vault-token/main.tf index 3461ba56..51c3a935 100644 --- a/registry/coder/modules/vault-token/main.tf +++ b/registry/coder/modules/vault-token/main.tf @@ -26,6 +26,11 @@ variable "vault_token" { sensitive = true default = null } +variable "vault_namespace" { + type = string + description = "The Vault namespace to use." + default = null +} variable "vault_cli_version" { type = string @@ -62,3 +67,10 @@ resource "coder_env" "vault_token" { name = "VAULT_TOKEN" value = var.vault_token } + +resource "coder_env" "vault_namespace" { + count = var.vault_namespace != null ? 1 : 0 + agent_id = var.agent_id + name = "VAULT_NAMESPACE" + value = var.vault_namespace +} \ No newline at end of file diff --git a/registry/coder/modules/vscode-desktop/README.md b/registry/coder/modules/vscode-desktop/README.md index 72e399b7..7fcda3d5 100644 --- a/registry/coder/modules/vscode-desktop/README.md +++ b/registry/coder/modules/vscode-desktop/README.md @@ -4,7 +4,7 @@ description: Add a one-click button to launch VS Code Desktop icon: ../../../../.icons/code.svg maintainer_github: coder verified: true -tags: [ide, vscode, helper] +tags: [ide, vscode] --- # VS Code Desktop @@ -17,7 +17,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder) module "vscode" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-desktop/coder" - version = "1.0.15" + version = "1.1.0" agent_id = coder_agent.example.id } ``` @@ -30,7 +30,7 @@ module "vscode" { module "vscode" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-desktop/coder" - version = "1.0.15" + version = "1.1.0" agent_id = coder_agent.example.id folder = "/home/coder/project" } diff --git a/registry/coder/modules/vscode-desktop/main.tf b/registry/coder/modules/vscode-desktop/main.tf index 16d070b4..f93d14e3 100644 --- a/registry/coder/modules/vscode-desktop/main.tf +++ b/registry/coder/modules/vscode-desktop/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.23" + version = ">= 2.5" } } } @@ -32,6 +32,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} @@ -42,6 +48,8 @@ resource "coder_app" "vscode" { slug = "vscode" display_name = "VS Code Desktop" order = var.order + group = var.group + url = join("", [ "vscode://coder.coder-remote/open", "?owner=", diff --git a/registry/coder/modules/vscode-web/README.md b/registry/coder/modules/vscode-web/README.md index 0690c5ad..1bccc25e 100644 --- a/registry/coder/modules/vscode-web/README.md +++ b/registry/coder/modules/vscode-web/README.md @@ -4,7 +4,7 @@ description: VS Code Web - Visual Studio Code in the browser icon: ../../../../.icons/code.svg maintainer_github: coder verified: true -tags: [helper, ide, vscode, web] +tags: [ide, vscode, web] --- # VS Code Web @@ -15,7 +15,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/ module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.1.0" + version = "1.3.0" agent_id = coder_agent.example.id accept_license = true } @@ -31,7 +31,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.1.0" + version = "1.3.0" agent_id = coder_agent.example.id install_prefix = "/home/coder/.vscode-web" folder = "/home/coder" @@ -45,7 +45,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.1.0" + version = "1.3.0" agent_id = coder_agent.example.id extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] accept_license = true @@ -60,7 +60,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.1.0" + version = "1.3.0" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -78,7 +78,7 @@ By default, this module installs the latest. To pin a specific version, retrieve module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.1.0" + version = "1.3.0" agent_id = coder_agent.example.id commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" accept_license = true diff --git a/registry/coder/modules/vscode-web/main.tf b/registry/coder/modules/vscode-web/main.tf index 9ffddb25..c0072454 100644 --- a/registry/coder/modules/vscode-web/main.tf +++ b/registry/coder/modules/vscode-web/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 2.5" } } } @@ -97,6 +97,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "settings" { type = any description = "A map of settings to apply to VS Code web." @@ -115,6 +121,12 @@ variable "use_cached" { default = false } +variable "disable_trust" { + type = bool + description = "Disables workspace trust protection for VS Code Web." + default = false +} + variable "extensions_dir" { type = string description = "Override the directory to store extensions in." @@ -163,6 +175,7 @@ resource "coder_script" "vscode-web" { SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), OFFLINE : var.offline, USE_CACHED : var.use_cached, + DISABLE_TRUST : var.disable_trust, EXTENSIONS_DIR : var.extensions_dir, FOLDER : var.folder, AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, @@ -194,6 +207,7 @@ resource "coder_app" "vscode-web" { subdomain = var.subdomain share = var.share order = var.order + group = var.group healthcheck { url = local.healthcheck_url diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh index a2ee1911..9346b4bd 100644 --- a/registry/coder/modules/vscode-web/run.sh +++ b/registry/coder/modules/vscode-web/run.sh @@ -16,10 +16,16 @@ if [ -n "${SERVER_BASE_PATH}" ]; then SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}" fi +# Set disable workspace trust +DISABLE_TRUST_ARG="" +if [ "${DISABLE_TRUST}" = true ]; then + DISABLE_TRUST_ARG="--disable-workspace-trust" +fi + run_vscode_web() { - echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..." + echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG $DISABLE_TRUST_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..." echo "Check logs at ${LOG_PATH}!" - "$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 & + "$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_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... diff --git a/registry/coder/modules/windows-rdp/README.md b/registry/coder/modules/windows-rdp/README.md index 87bd98d7..0899a79a 100644 --- a/registry/coder/modules/windows-rdp/README.md +++ b/registry/coder/modules/windows-rdp/README.md @@ -16,7 +16,7 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.0.18" + version = "1.2.1" agent_id = resource.coder_agent.main.id resource_id = resource.aws_instance.dev.id } @@ -34,7 +34,7 @@ module "windows_rdp" { module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.0.18" + version = "1.2.1" agent_id = resource.coder_agent.main.id resource_id = resource.aws_instance.dev.id } @@ -46,12 +46,25 @@ module "windows_rdp" { module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.0.18" + version = "1.2.1" agent_id = resource.coder_agent.main.id resource_id = resource.google_compute_instance.dev[0].id } ``` +### With Custom Devolutions Gateway Version + +```tf +module "windows_rdp" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/windows-rdp/coder" + version = "1.2.1" + agent_id = resource.coder_agent.main.id + resource_id = resource.aws_instance.dev.id + devolutions_gateway_version = "2025.1.6" # Specify a specific version +} +``` + ## Roadmap - [ ] Test on Microsoft Azure. diff --git a/registry/coder/modules/windows-rdp/main.tf b/registry/coder/modules/windows-rdp/main.tf index 10ece09c..b610c52c 100644 --- a/registry/coder/modules/windows-rdp/main.tf +++ b/registry/coder/modules/windows-rdp/main.tf @@ -9,6 +9,18 @@ terraform { } } +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 "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + variable "share" { type = string default = "owner" @@ -39,14 +51,21 @@ variable "admin_password" { sensitive = true } +variable "devolutions_gateway_version" { + type = string + default = "2025.2.1" + description = "Version of Devolutions Gateway to install. Defaults to the latest available version." +} + 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 + admin_username = var.admin_username + admin_password = var.admin_password + devolutions_gateway_version = var.devolutions_gateway_version # Wanted to have this be in the powershell template file, but Terraform # doesn't allow recursive calls to the templatefile function. Have to feed @@ -68,6 +87,8 @@ resource "coder_app" "windows-rdp" { url = "http://localhost:7171" icon = "/icon/desktop.svg" subdomain = true + order = var.order + group = var.group healthcheck { url = "http://localhost:7171" @@ -78,7 +99,7 @@ resource "coder_app" "windows-rdp" { resource "coder_app" "rdp-docs" { agent_id = var.agent_id - display_name = "Local RDP" + display_name = "Local RDP Docs" slug = "rdp-docs" icon = "https://raw.githubusercontent.com/matifali/logos/main/windows.svg" url = "https://coder.com/docs/ides/remote-desktops#rdp-desktop" diff --git a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl index 1b7ab487..27c45b45 100644 --- a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl +++ b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl @@ -16,12 +16,17 @@ function Configure-RDP { 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" + + # Disable UDP. It doesn't work via `coder port-forward` and is broken due to MTU issues in Coder Connect. + # Requires a restart to take effect. c.f. https://github.com/coder/internal/issues/608#issuecomment-2965923672 + New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services' -Name "SelectTransport" -Value 1 -PropertyType DWORD -Force + Restart-Service -Name "TermService" -Force } function Install-DevolutionsGateway { # Define the module name and version $moduleName = "DevolutionsGateway" -$moduleVersion = "2024.1.5" +$moduleVersion = "${devolutions_gateway_version}" # Install the module with the specified version for all users # This requires administrator privileges diff --git a/registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png b/registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png index f37d65da..e2f246a7 100644 Binary files a/registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png and b/registry/coder/modules/windows-rdp/video-thumbnails/video-thumbnail.png differ diff --git a/registry/coder/modules/windsurf/README.md b/registry/coder/modules/windsurf/README.md index 3fdd9073..517c98b3 100644 --- a/registry/coder/modules/windsurf/README.md +++ b/registry/coder/modules/windsurf/README.md @@ -4,7 +4,7 @@ description: Add a one-click button to launch Windsurf Editor icon: ../../../../.icons/windsurf.svg maintainer_github: coder verified: true -tags: [ide, windsurf, helper, ai] +tags: [ide, windsurf, ai] --- # Windsurf Editor @@ -17,7 +17,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder) module "windsurf" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windsurf/coder" - version = "1.0.0" + version = "1.1.0" agent_id = coder_agent.example.id } ``` @@ -30,7 +30,7 @@ module "windsurf" { module "windsurf" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windsurf/coder" - version = "1.0.0" + version = "1.1.0" agent_id = coder_agent.example.id folder = "/home/coder/project" } diff --git a/registry/coder/modules/windsurf/main.tf b/registry/coder/modules/windsurf/main.tf index 1d836d7e..2f9d02a5 100644 --- a/registry/coder/modules/windsurf/main.tf +++ b/registry/coder/modules/windsurf/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.23" + version = ">= 2.5" } } } @@ -32,6 +32,12 @@ variable "order" { default = null } +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} @@ -42,6 +48,7 @@ resource "coder_app" "windsurf" { slug = "windsurf" display_name = "Windsurf Editor" order = var.order + group = var.group url = join("", [ "windsurf://coder.coder-remote/open", "?owner=", diff --git a/registry/coder/modules/zed/README.md b/registry/coder/modules/zed/README.md new file mode 100644 index 00000000..ea11372e --- /dev/null +++ b/registry/coder/modules/zed/README.md @@ -0,0 +1,65 @@ +--- +display_name: Zed +description: Add a one-click button to launch Zed +icon: ../../../../.icons/zed.svg +maintainer_github: coder +verified: true +tags: [ide, zed, editor] +--- + +# Zed + +Add a button to open any workspace with a single click in Zed. + +Zed is a high-performance, multiplayer code editor from the creators of Atom and Tree-sitter. + +> [!IMPORTANT] +> Zed needs you to either have Coder CLI installed with `coder config-ssh` run or [Coder Desktop](https://coder.com/docs/user-guides/desktop) + +```tf +module "zed" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/zed/coder" + version = "1.0.0" + agent_id = coder_agent.example.id +} +``` + +## Examples + +### Open in a specific directory + +```tf +module "zed" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/zed/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/project" +} +``` + +### Custom display name and order + +```tf +module "zed" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/zed/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + display_name = "Zed Editor" + order = 1 +} +``` + +### With custom agent name + +```tf +module "zed" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/zed/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + agent_name = coder_agent.example.name +} +``` diff --git a/registry/coder/modules/zed/main.test.ts b/registry/coder/modules/zed/main.test.ts new file mode 100644 index 00000000..9a657e02 --- /dev/null +++ b/registry/coder/modules/zed/main.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("zed", 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.zed_url.value).toBe( + "zed://ssh/default.coder", + ); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "zed", + ); + + 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.zed_url.value).toBe( + "zed://ssh/default.coder/foo/bar", + ); + }); + + 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 === "zed", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); + + it("expect display_name to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + display_name: "Custom Zed", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "zed", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.display_name).toBe("Custom Zed"); + }); + + it("adds agent_name to hostname", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_name: "myagent", + }); + expect(state.outputs.zed_url.value).toBe( + "zed://ssh/myagent.default.default.coder", + ); + }); +}); diff --git a/registry/coder/modules/zed/main.tf b/registry/coder/modules/zed/main.tf new file mode 100644 index 00000000..2f6376a4 --- /dev/null +++ b/registry/coder/modules/zed/main.tf @@ -0,0 +1,77 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent" +} + +variable "agent_name" { + type = string + description = "The name of the agent" + default = "" +} + +variable "folder" { + type = string + description = "The folder to open in Zed" + default = "" +} + +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 "group" { + type = string + description = "The name of a group that this app belongs to" + default = null +} + +variable "slug" { + type = string + description = "The slug of the app" + default = "zed" +} + +variable "display_name" { + type = string + description = "The display name of the app" + default = "Zed" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + workspace_name = lower(data.coder_workspace.me.name) + owner_name = lower(data.coder_workspace_owner.me.name) + agent_name = lower(var.agent_name) + hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder" +} + +resource "coder_app" "zed" { + agent_id = var.agent_id + display_name = var.display_name + slug = var.slug + icon = "/icon/zed.svg" + external = true + order = var.order + group = var.group + url = "zed://ssh/${local.hostname}${var.folder}" +} + +output "zed_url" { + value = coder_app.zed.url + description = "Zed URL" +} diff --git a/registry/coder/templates/aws-devcontainer/README.md b/registry/coder/templates/aws-devcontainer/README.md new file mode 100644 index 00000000..1f944039 --- /dev/null +++ b/registry/coder/templates/aws-devcontainer/README.md @@ -0,0 +1,111 @@ +--- +display_name: AWS EC2 (Devcontainer) +description: Provision AWS EC2 VMs with a devcontainer as Coder workspaces +icon: ../../../../.icons/aws.svg +maintainer_github: coder +verified: true +tags: [vm, linux, aws, persistent, devcontainer] +--- + +# Remote Development on AWS EC2 VMs using a Devcontainer + +Provision AWS EC2 VMs as [Coder workspaces](https://coder.com/docs) with this example template. +![Architecture Diagram](./architecture.svg) + + + +## Prerequisites + +### Authentication + +By default, this template authenticates to AWS using the provider's default [authentication methods](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication-and-configuration). + +The simplest way (without making changes to the template) is via environment variables (e.g. `AWS_ACCESS_KEY_ID`) or a [credentials file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html#cli-configure-files-format). If you are running Coder on a VM, this file must be in `/home/coder/aws/credentials`. + +To use another [authentication method](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication), edit the template. + +## Required permissions / policy + +The following sample policy allows Coder to create EC2 instances and modify +instances provisioned by Coder: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "ec2:GetDefaultCreditSpecification", + "ec2:DescribeIamInstanceProfileAssociations", + "ec2:DescribeTags", + "ec2:DescribeInstances", + "ec2:DescribeInstanceTypes", + "ec2:DescribeInstanceStatus", + "ec2:CreateTags", + "ec2:RunInstances", + "ec2:DescribeInstanceCreditSpecifications", + "ec2:DescribeImages", + "ec2:ModifyDefaultCreditSpecification", + "ec2:DescribeVolumes" + ], + "Resource": "*" + }, + { + "Sid": "CoderResources", + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstanceAttribute", + "ec2:UnmonitorInstances", + "ec2:TerminateInstances", + "ec2:StartInstances", + "ec2:StopInstances", + "ec2:DeleteTags", + "ec2:MonitorInstances", + "ec2:CreateTags", + "ec2:RunInstances", + "ec2:ModifyInstanceAttribute", + "ec2:ModifyInstanceCreditSpecification" + ], + "Resource": "arn:aws:ec2:*:*:instance/*", + "Condition": { + "StringEquals": { + "aws:ResourceTag/Coder_Provisioned": "true" + } + } + } + ] +} +``` + +## Architecture + +This template provisions the following resources: + +- AWS Instance + +Coder uses `aws_ec2_instance_state` to start and stop the VM. This example template is fully persistent, meaning the full filesystem is preserved when the workspace restarts. See this [community example](https://github.com/bpmct/coder-templates/tree/main/aws-linux-ephemeral) of an ephemeral AWS instance. + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## Caching + +To speed up your builds, you can use a container registry as a cache. +When creating the template, set the parameter `cache_repo` to a valid Docker repository in the form `host.tld/path/to/repo`. + +See the [Envbuilder Terraform Provider Examples](https://github.com/coder/terraform-provider-envbuilder/blob/main/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf/) for a more complete example of how the provider works. + +> [!NOTE] +> We recommend using a registry cache with authentication enabled. +> To allow Envbuilder to authenticate with a registry cache hosted on ECR, specify an IAM instance +> profile that has read and write access to the given registry. For more information, see the +> [AWS documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html). +> +> Alternatively, you can specify the variable `cache_repo_docker_config_path` +> with the path to a Docker config `.json` on disk containing valid credentials for the registry. + +## code-server + +`code-server` is installed via the [`code-server`](https://registry.coder.com/modules/code-server) registry module. For a list of all modules and templates pplease check [Coder Registry](https://registry.coder.com). diff --git a/registry/coder/templates/aws-devcontainer/architecture.svg b/registry/coder/templates/aws-devcontainer/architecture.svg new file mode 100644 index 00000000..be66c3f1 --- /dev/null +++ b/registry/coder/templates/aws-devcontainer/architecture.svg @@ -0,0 +1,8 @@ +AWSAWSHostingHostingVirtual MachineVirtual MachineLinux HardwareLinux HardwareCoder WorkspaceCoder WorkspaceDevcontainerDevcontainerenvbuilder created filesystemenvbuilder created filesystemA Clone of your repoA Clone of your repoSource codeSource codeLanguagesLanguagesPython. Go, etcPython. Go, etcToolingToolingExtensions, linting, formatting, etcExtensions, linting, formatting, etcCPUsCPUsDisk StorageDisk StorageCode EditorCode EditorVS Code DesktopVS Code DesktopLocal InstallationLocal InstallationVS Code DesktopVS Code DesktopLocal InstallationLocal Installationcode-servercode-serverA web IDEA web IDEJetBrains GatewayJetBrains GatewayLocal InstallationLocal InstallationCommand LineCommand LineSSH via Coder CLISSH via Coder CLI \ No newline at end of file diff --git a/registry/coder/templates/aws-devcontainer/cloud-init/cloud-config.yaml.tftpl b/registry/coder/templates/aws-devcontainer/cloud-init/cloud-config.yaml.tftpl new file mode 100644 index 00000000..af6b3517 --- /dev/null +++ b/registry/coder/templates/aws-devcontainer/cloud-init/cloud-config.yaml.tftpl @@ -0,0 +1,15 @@ +#cloud-config +cloud_final_modules: + - [scripts-user, always] +hostname: ${hostname} +users: + - name: ${linux_user} + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + ssh_authorized_keys: + - "${ssh_pubkey}" +# Automatically grow the partition +growpart: + mode: auto + devices: ['/'] + ignore_growroot_disabled: false diff --git a/registry/coder/templates/aws-devcontainer/cloud-init/userdata.sh.tftpl b/registry/coder/templates/aws-devcontainer/cloud-init/userdata.sh.tftpl new file mode 100644 index 00000000..67c166cb --- /dev/null +++ b/registry/coder/templates/aws-devcontainer/cloud-init/userdata.sh.tftpl @@ -0,0 +1,37 @@ +#!/bin/bash +# Install Docker +if ! command -v docker &> /dev/null +then + echo "Docker not found, installing..." + curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh 2>&1 >/dev/null + usermod -aG docker ${linux_user} + newgrp docker +else + echo "Docker is already installed." +fi + +# Set up Docker credentials +mkdir -p "/home/${linux_user}/.docker" + +if [ -n "${docker_config_json_base64}" ]; then + # Write the Docker config JSON to disk if it is provided. + printf "%s" "${docker_config_json_base64}" | base64 -d | tee "/home/${linux_user}/.docker/config.json" +else + # Assume that we're going to use the instance IAM role to pull from the cache repo if we need to. + # Set up the ecr credential helper. + apt-get update -y && apt-get install -y amazon-ecr-credential-helper + mkdir -p .docker + printf '{"credsStore": "ecr-login"}' | tee "/home/${linux_user}/.docker/config.json" +fi +chown -R ${linux_user}:${linux_user} "/home/${linux_user}/.docker" + +# Start envbuilder +sudo -u coder docker run \ + --rm \ + --net=host \ + -h ${hostname} \ + -v /home/${linux_user}/envbuilder:/workspaces \ + %{ for key, value in environment ~} + -e ${key}="${value}" \ + %{ endfor ~} + ${builder_image} diff --git a/registry/coder/templates/aws-devcontainer/main.tf b/registry/coder/templates/aws-devcontainer/main.tf new file mode 100644 index 00000000..b23b9a65 --- /dev/null +++ b/registry/coder/templates/aws-devcontainer/main.tf @@ -0,0 +1,331 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + aws = { + source = "hashicorp/aws" + } + cloudinit = { + source = "hashicorp/cloudinit" + } + envbuilder = { + source = "coder/envbuilder" + } + } +} + +module "aws_region" { + source = "https://registry.coder.com/modules/aws-region" + default = "us-east-1" +} + +provider "aws" { + region = module.aws_region.value +} + +variable "cache_repo" { + default = "" + description = "(Optional) Use a container registry as a cache to speed up builds. Example: host.tld/path/to/repo." + type = string +} + +variable "cache_repo_docker_config_path" { + default = "" + description = "(Optional) Path to a docker config.json containing credentials to the provided cache repo, if required. This will depend on your Coder setup. Example: `/home/coder/.docker/config.json`." + sensitive = true + type = string +} + +variable "iam_instance_profile" { + default = "" + description = "(Optional) Name of an IAM instance profile to assign to the instance." + type = string +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "aws_ami" "ubuntu" { + most_recent = true + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"] + } + filter { + name = "virtualization-type" + values = ["hvm"] + } + owners = ["099720109477"] # Canonical +} + +data "coder_parameter" "instance_type" { + name = "instance_type" + display_name = "Instance type" + description = "What instance type should your workspace use?" + default = "t3.micro" + mutable = false + option { + name = "2 vCPU, 1 GiB RAM" + value = "t3.micro" + } + option { + name = "2 vCPU, 2 GiB RAM" + value = "t3.small" + } + option { + name = "2 vCPU, 4 GiB RAM" + value = "t3.medium" + } + option { + name = "2 vCPU, 8 GiB RAM" + value = "t3.large" + } + option { + name = "4 vCPU, 16 GiB RAM" + value = "t3.xlarge" + } + option { + name = "8 vCPU, 32 GiB RAM" + value = "t3.2xlarge" + } +} + +data "coder_parameter" "root_volume_size_gb" { + name = "root_volume_size_gb" + display_name = "Root Volume Size (GB)" + description = "How large should the root volume for the instance be?" + default = 30 + type = "number" + mutable = true + validation { + min = 1 + monotonic = "increasing" + } +} + +data "coder_parameter" "fallback_image" { + default = "codercom/enterprise-base:ubuntu" + description = "This image runs if the devcontainer fails to build." + display_name = "Fallback Image" + mutable = true + name = "fallback_image" + order = 3 +} + +data "coder_parameter" "devcontainer_builder" { + description = <<-EOF +Image that will build the devcontainer. +Find the latest version of Envbuilder here: https://ghcr.io/coder/envbuilder +Be aware that using the `:latest` tag may expose you to breaking changes. +EOF + display_name = "Devcontainer Builder" + mutable = true + name = "devcontainer_builder" + default = "ghcr.io/coder/envbuilder:latest" + order = 4 +} + +data "coder_parameter" "repo_url" { + name = "repo_url" + display_name = "Repository URL" + default = "https://github.com/coder/envbuilder-starter-devcontainer" + description = "Repository URL" + mutable = true +} + +data "coder_parameter" "ssh_pubkey" { + name = "ssh_pubkey" + display_name = "SSH Public Key" + default = "" + description = "(Optional) Add an SSH public key to the `coder` user's authorized_keys. Useful for troubleshooting. You may need to add a security group to the instance." + mutable = false +} + +data "local_sensitive_file" "cache_repo_dockerconfigjson" { + count = var.cache_repo_docker_config_path == "" ? 0 : 1 + filename = var.cache_repo_docker_config_path +} + +data "aws_iam_instance_profile" "vm_instance_profile" { + count = var.iam_instance_profile == "" ? 0 : 1 + name = var.iam_instance_profile +} + +# Be careful when modifying the below locals! +locals { + # TODO: provide a way to pick the availability zone. + aws_availability_zone = "${module.aws_region.value}a" + + hostname = lower(data.coder_workspace.me.name) + linux_user = "coder" + + # The devcontainer builder image is the image that will build the devcontainer. + devcontainer_builder_image = data.coder_parameter.devcontainer_builder.value + + # We may need to authenticate with a registry. If so, the user will provide a path to a docker config.json. + docker_config_json_base64 = try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, "") + + # The envbuilder provider requires a key-value map of environment variables. Build this here. + envbuilder_env = { + # ENVBUILDER_GIT_URL and ENVBUILDER_CACHE_REPO will be overridden by the provider + # if the cache repo is enabled. + "ENVBUILDER_GIT_URL" : data.coder_parameter.repo_url.value, + # The agent token is required for the agent to connect to the Coder platform. + "CODER_AGENT_TOKEN" : try(coder_agent.dev.0.token, ""), + # The agent URL is required for the agent to connect to the Coder platform. + "CODER_AGENT_URL" : data.coder_workspace.me.access_url, + # The agent init script is required for the agent to start up. We base64 encode it here + # to avoid quoting issues. + "ENVBUILDER_INIT_SCRIPT" : "echo ${base64encode(try(coder_agent.dev[0].init_script, ""))} | base64 -d | sh", + "ENVBUILDER_DOCKER_CONFIG_BASE64" : local.docker_config_json_base64, + # The fallback image is the image that will run if the devcontainer fails to build. + "ENVBUILDER_FALLBACK_IMAGE" : data.coder_parameter.fallback_image.value, + # The following are used to push the image to the cache repo, if defined. + "ENVBUILDER_CACHE_REPO" : var.cache_repo, + "ENVBUILDER_PUSH_IMAGE" : var.cache_repo == "" ? "" : "true", + # You can add other required environment variables here. + # See: https://github.com/coder/envbuilder/?tab=readme-ov-file#environment-variables + } +} + +# Check for the presence of a prebuilt image in the cache repo +# that we can use instead. +resource "envbuilder_cached_image" "cached" { + count = var.cache_repo == "" ? 0 : data.coder_workspace.me.start_count + builder_image = local.devcontainer_builder_image + git_url = data.coder_parameter.repo_url.value + cache_repo = var.cache_repo + extra_env = local.envbuilder_env +} + +data "cloudinit_config" "user_data" { + gzip = false + base64_encode = false + + boundary = "//" + + part { + filename = "cloud-config.yaml" + content_type = "text/cloud-config" + + content = templatefile("${path.module}/cloud-init/cloud-config.yaml.tftpl", { + hostname = local.hostname + linux_user = local.linux_user + + ssh_pubkey = data.coder_parameter.ssh_pubkey.value + }) + } + + part { + filename = "userdata.sh" + content_type = "text/x-shellscript" + + content = templatefile("${path.module}/cloud-init/userdata.sh.tftpl", { + hostname = local.hostname + linux_user = local.linux_user + + # If we have a cached image, use the cached image's environment variables. + # Otherwise, just use the environment variables we've defined in locals. + environment = try(envbuilder_cached_image.cached[0].env_map, local.envbuilder_env) + + # Builder image will either be the builder image parameter, or the cached image, if cache is provided. + builder_image = try(envbuilder_cached_image.cached[0].image, data.coder_parameter.devcontainer_builder.value) + + docker_config_json_base64 = local.docker_config_json_base64 + }) + } +} + +# This is useful for debugging the startup script. Left here for reference. +# resource local_file "startup_script" { +# content = data.cloudinit_config.user_data.rendered +# filename = "${path.module}/user_data.txt" +# } + +resource "aws_instance" "vm" { + ami = data.aws_ami.ubuntu.id + availability_zone = local.aws_availability_zone + instance_type = data.coder_parameter.instance_type.value + iam_instance_profile = try(data.aws_iam_instance_profile.vm_instance_profile[0].name, null) + root_block_device { + volume_size = data.coder_parameter.root_volume_size_gb.value + } + + user_data = data.cloudinit_config.user_data.rendered + tags = { + Name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + # Required if you are using our example policy, see template README + Coder_Provisioned = "true" + } + lifecycle { + ignore_changes = [ami] + } +} + +resource "aws_ec2_instance_state" "vm" { + instance_id = aws_instance.vm.id + state = data.coder_workspace.me.transition == "start" ? "running" : "stopped" +} + +resource "coder_agent" "dev" { + count = data.coder_workspace.me.start_count + arch = "amd64" + auth = "token" + os = "linux" + dir = "/workspaces/${trimsuffix(basename(data.coder_parameter.repo_url.value), ".git")}" + connection_timeout = 0 + + metadata { + key = "cpu" + display_name = "CPU Usage" + interval = 5 + timeout = 5 + script = "coder stat cpu" + } + metadata { + key = "memory" + display_name = "Memory Usage" + interval = 5 + timeout = 5 + script = "coder stat mem" + } +} + +resource "coder_metadata" "info" { + count = data.coder_workspace.me.start_count + resource_id = coder_agent.dev[0].id + item { + key = "ami" + value = aws_instance.vm.ami + } + item { + key = "availability_zone" + value = local.aws_availability_zone + } + item { + key = "instance_type" + value = data.coder_parameter.instance_type.value + } + item { + key = "ssh_pubkey" + value = data.coder_parameter.ssh_pubkey.value + } + item { + key = "repo_url" + value = data.coder_parameter.repo_url.value + } + item { + key = "devcontainer_builder" + value = data.coder_parameter.devcontainer_builder.value + } +} + +# See https://registry.coder.com/modules/coder/code-server +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + agent_id = coder_agent.dev[0].id +} diff --git a/registry/coder/templates/aws-linux/README.md b/registry/coder/templates/aws-linux/README.md new file mode 100644 index 00000000..248fbea1 --- /dev/null +++ b/registry/coder/templates/aws-linux/README.md @@ -0,0 +1,94 @@ +--- +display_name: AWS EC2 (Linux) +description: Provision AWS EC2 VMs as Coder workspaces +icon: ../../../../.icons/aws.svg +maintainer_github: coder +verified: true +tags: [vm, linux, aws, persistent-vm] +--- + +# Remote Development on AWS EC2 VMs (Linux) + +Provision AWS EC2 VMs as [Coder workspaces](https://coder.com/docs/workspaces) with this example template. + +## Prerequisites + +### Authentication + +By default, this template authenticates to AWS using the provider's default [authentication methods](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication-and-configuration). + +The simplest way (without making changes to the template) is via environment variables (e.g. `AWS_ACCESS_KEY_ID`) or a [credentials file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html#cli-configure-files-format). If you are running Coder on a VM, this file must be in `/home/coder/aws/credentials`. + +To use another [authentication method](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication), edit the template. + +## Required permissions / policy + +The following sample policy allows Coder to create EC2 instances and modify +instances provisioned by Coder: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "ec2:GetDefaultCreditSpecification", + "ec2:DescribeIamInstanceProfileAssociations", + "ec2:DescribeTags", + "ec2:DescribeInstances", + "ec2:DescribeInstanceTypes", + "ec2:DescribeInstanceStatus", + "ec2:CreateTags", + "ec2:RunInstances", + "ec2:DescribeInstanceCreditSpecifications", + "ec2:DescribeImages", + "ec2:ModifyDefaultCreditSpecification", + "ec2:DescribeVolumes" + ], + "Resource": "*" + }, + { + "Sid": "CoderResources", + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstanceAttribute", + "ec2:UnmonitorInstances", + "ec2:TerminateInstances", + "ec2:StartInstances", + "ec2:StopInstances", + "ec2:DeleteTags", + "ec2:MonitorInstances", + "ec2:CreateTags", + "ec2:RunInstances", + "ec2:ModifyInstanceAttribute", + "ec2:ModifyInstanceCreditSpecification" + ], + "Resource": "arn:aws:ec2:*:*:instance/*", + "Condition": { + "StringEquals": { + "aws:ResourceTag/Coder_Provisioned": "true" + } + } + } + ] +} +``` + +## Architecture + +This template provisions the following resources: + +- AWS Instance + +Coder uses `aws_ec2_instance_state` to start and stop the VM. This example template is fully persistent, meaning the full filesystem is preserved when the workspace restarts. See this [community example](https://github.com/bpmct/coder-templates/tree/main/aws-linux-ephemeral) of an ephemeral AWS instance. + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## code-server + +`code-server` is installed via the `startup_script` argument in the `coder_agent` +resource block. The `coder_app` resource is defined to access `code-server` through +the dashboard UI over `localhost:13337`. diff --git a/registry/coder/templates/aws-linux/cloud-init/cloud-config.yaml.tftpl b/registry/coder/templates/aws-linux/cloud-init/cloud-config.yaml.tftpl new file mode 100644 index 00000000..14da7694 --- /dev/null +++ b/registry/coder/templates/aws-linux/cloud-init/cloud-config.yaml.tftpl @@ -0,0 +1,8 @@ +#cloud-config +cloud_final_modules: + - [scripts-user, always] +hostname: ${hostname} +users: + - name: ${linux_user} + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash diff --git a/registry/coder/templates/aws-linux/cloud-init/userdata.sh.tftpl b/registry/coder/templates/aws-linux/cloud-init/userdata.sh.tftpl new file mode 100644 index 00000000..2070bc4d --- /dev/null +++ b/registry/coder/templates/aws-linux/cloud-init/userdata.sh.tftpl @@ -0,0 +1,2 @@ +#!/bin/bash +sudo -u '${linux_user}' sh -c '${init_script}' diff --git a/registry/coder/templates/aws-linux/main.tf b/registry/coder/templates/aws-linux/main.tf new file mode 100644 index 00000000..bf59dadc --- /dev/null +++ b/registry/coder/templates/aws-linux/main.tf @@ -0,0 +1,296 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + cloudinit = { + source = "hashicorp/cloudinit" + } + aws = { + source = "hashicorp/aws" + } + } +} + +# Last updated 2023-03-14 +# aws ec2 describe-regions | jq -r '[.Regions[].RegionName] | sort' +data "coder_parameter" "region" { + name = "region" + display_name = "Region" + description = "The region to deploy the workspace in." + default = "us-east-1" + mutable = false + option { + name = "Asia Pacific (Tokyo)" + value = "ap-northeast-1" + icon = "/emojis/1f1ef-1f1f5.png" + } + option { + name = "Asia Pacific (Seoul)" + value = "ap-northeast-2" + icon = "/emojis/1f1f0-1f1f7.png" + } + option { + name = "Asia Pacific (Osaka)" + value = "ap-northeast-3" + icon = "/emojis/1f1ef-1f1f5.png" + } + option { + name = "Asia Pacific (Mumbai)" + value = "ap-south-1" + icon = "/emojis/1f1ee-1f1f3.png" + } + option { + name = "Asia Pacific (Singapore)" + value = "ap-southeast-1" + icon = "/emojis/1f1f8-1f1ec.png" + } + option { + name = "Asia Pacific (Sydney)" + value = "ap-southeast-2" + icon = "/emojis/1f1e6-1f1fa.png" + } + option { + name = "Canada (Central)" + value = "ca-central-1" + icon = "/emojis/1f1e8-1f1e6.png" + } + option { + name = "EU (Frankfurt)" + value = "eu-central-1" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "EU (Stockholm)" + value = "eu-north-1" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "EU (Ireland)" + value = "eu-west-1" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "EU (London)" + value = "eu-west-2" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "EU (Paris)" + value = "eu-west-3" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "South America (São Paulo)" + value = "sa-east-1" + icon = "/emojis/1f1e7-1f1f7.png" + } + option { + name = "US East (N. Virginia)" + value = "us-east-1" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "US East (Ohio)" + value = "us-east-2" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "US West (N. California)" + value = "us-west-1" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "US West (Oregon)" + value = "us-west-2" + icon = "/emojis/1f1fa-1f1f8.png" + } +} + +data "coder_parameter" "instance_type" { + name = "instance_type" + display_name = "Instance type" + description = "What instance type should your workspace use?" + default = "t3.micro" + mutable = false + option { + name = "2 vCPU, 1 GiB RAM" + value = "t3.micro" + } + option { + name = "2 vCPU, 2 GiB RAM" + value = "t3.small" + } + option { + name = "2 vCPU, 4 GiB RAM" + value = "t3.medium" + } + option { + name = "2 vCPU, 8 GiB RAM" + value = "t3.large" + } + option { + name = "4 vCPU, 16 GiB RAM" + value = "t3.xlarge" + } + option { + name = "8 vCPU, 32 GiB RAM" + value = "t3.2xlarge" + } +} + +provider "aws" { + region = data.coder_parameter.region.value +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "aws_ami" "ubuntu" { + most_recent = true + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"] + } + filter { + name = "virtualization-type" + values = ["hvm"] + } + owners = ["099720109477"] # Canonical +} + +resource "coder_agent" "dev" { + count = data.coder_workspace.me.start_count + arch = "amd64" + auth = "aws-instance-identity" + os = "linux" + startup_script = <<-EOT + set -e + + # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + EOT + + metadata { + key = "cpu" + display_name = "CPU Usage" + interval = 5 + timeout = 5 + script = "coder stat cpu" + } + metadata { + key = "memory" + display_name = "Memory Usage" + interval = 5 + timeout = 5 + script = "coder stat mem" + } + metadata { + key = "disk" + display_name = "Disk Usage" + interval = 600 # every 10 minutes + timeout = 30 # df can take a while on large filesystems + script = "coder stat disk --path $HOME" + } +} + +# See https://registry.coder.com/modules/coder/code-server +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/code-server/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.dev[0].id + order = 1 +} + +# See https://registry.coder.com/modules/jetbrains-gateway +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + + # JetBrains IDEs to make available for the user to select + jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] + default = "IU" + + # Default folder to open when starting a JetBrains IDE + folder = "/home/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.dev[0].id + agent_name = "dev" + order = 2 +} + +locals { + hostname = lower(data.coder_workspace.me.name) + linux_user = "coder" +} + +data "cloudinit_config" "user_data" { + gzip = false + base64_encode = false + + boundary = "//" + + part { + filename = "cloud-config.yaml" + content_type = "text/cloud-config" + + content = templatefile("${path.module}/cloud-init/cloud-config.yaml.tftpl", { + hostname = local.hostname + linux_user = local.linux_user + }) + } + + part { + filename = "userdata.sh" + content_type = "text/x-shellscript" + + content = templatefile("${path.module}/cloud-init/userdata.sh.tftpl", { + linux_user = local.linux_user + + init_script = try(coder_agent.dev[0].init_script, "") + }) + } +} + +resource "aws_instance" "dev" { + ami = data.aws_ami.ubuntu.id + availability_zone = "${data.coder_parameter.region.value}a" + instance_type = data.coder_parameter.instance_type.value + + user_data = data.cloudinit_config.user_data.rendered + tags = { + Name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}" + # Required if you are using our example policy, see template README + Coder_Provisioned = "true" + } + lifecycle { + ignore_changes = [ami] + } +} + +resource "coder_metadata" "workspace_info" { + resource_id = aws_instance.dev.id + item { + key = "region" + value = data.coder_parameter.region.value + } + item { + key = "instance type" + value = aws_instance.dev.instance_type + } + item { + key = "disk" + value = "${aws_instance.dev.root_block_device[0].volume_size} GiB" + } +} + +resource "aws_ec2_instance_state" "dev" { + instance_id = aws_instance.dev.id + state = data.coder_workspace.me.transition == "start" ? "running" : "stopped" +} diff --git a/registry/coder/templates/aws-windows/README.md b/registry/coder/templates/aws-windows/README.md new file mode 100644 index 00000000..0ebd075d --- /dev/null +++ b/registry/coder/templates/aws-windows/README.md @@ -0,0 +1,96 @@ +--- +display_name: AWS EC2 (Windows) +description: Provision AWS EC2 VMs as Coder workspaces +icon: ../../../../.icons/aws.svg +maintainer_github: coder +verified: true +tags: [vm, windows, aws] +--- + +# Remote Development on AWS EC2 VMs (Windows) + +Provision AWS EC2 Windows VMs as [Coder workspaces](https://coder.com/docs/workspaces) with this example template. + + + +## Prerequisites + +### Authentication + +By default, this template authenticates to AWS with using the provider's default [authentication methods](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication-and-configuration). + +The simplest way (without making changes to the template) is via environment variables (e.g. `AWS_ACCESS_KEY_ID`) or a [credentials file](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html#cli-configure-files-format). If you are running Coder on a VM, this file must be in `/home/coder/aws/credentials`. + +To use another [authentication method](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication), edit the template. + +## Required permissions / policy + +The following sample policy allows Coder to create EC2 instances and modify +instances provisioned by Coder: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "ec2:GetDefaultCreditSpecification", + "ec2:DescribeIamInstanceProfileAssociations", + "ec2:DescribeTags", + "ec2:DescribeInstances", + "ec2:DescribeInstanceTypes", + "ec2:DescribeInstanceStatus", + "ec2:CreateTags", + "ec2:RunInstances", + "ec2:DescribeInstanceCreditSpecifications", + "ec2:DescribeImages", + "ec2:ModifyDefaultCreditSpecification", + "ec2:DescribeVolumes" + ], + "Resource": "*" + }, + { + "Sid": "CoderResources", + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstanceAttribute", + "ec2:UnmonitorInstances", + "ec2:TerminateInstances", + "ec2:StartInstances", + "ec2:StopInstances", + "ec2:DeleteTags", + "ec2:MonitorInstances", + "ec2:CreateTags", + "ec2:RunInstances", + "ec2:ModifyInstanceAttribute", + "ec2:ModifyInstanceCreditSpecification" + ], + "Resource": "arn:aws:ec2:*:*:instance/*", + "Condition": { + "StringEquals": { + "aws:ResourceTag/Coder_Provisioned": "true" + } + } + } + ] +} +``` + +## Architecture + +This template provisions the following resources: + +- AWS Instance + +Coder uses `aws_ec2_instance_state` to start and stop the VM. This example template is fully persistent, meaning the full filesystem is preserved when the workspace restarts. See this [community example](https://github.com/bpmct/coder-templates/tree/main/aws-linux-ephemeral) of an ephemeral AWS instance. + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## code-server + +`code-server` is installed via the `startup_script` argument in the `coder_agent` +resource block. The `coder_app` resource is defined to access `code-server` through +the dashboard UI over `localhost:13337`. diff --git a/registry/coder/templates/aws-windows/main.tf b/registry/coder/templates/aws-windows/main.tf new file mode 100644 index 00000000..167b1b69 --- /dev/null +++ b/registry/coder/templates/aws-windows/main.tf @@ -0,0 +1,214 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + aws = { + source = "hashicorp/aws" + } + } +} + +# Last updated 2023-03-14 +# aws ec2 describe-regions | jq -r '[.Regions[].RegionName] | sort' +data "coder_parameter" "region" { + name = "region" + display_name = "Region" + description = "The region to deploy the workspace in." + default = "us-east-1" + mutable = false + option { + name = "Asia Pacific (Tokyo)" + value = "ap-northeast-1" + icon = "/emojis/1f1ef-1f1f5.png" + } + option { + name = "Asia Pacific (Seoul)" + value = "ap-northeast-2" + icon = "/emojis/1f1f0-1f1f7.png" + } + option { + name = "Asia Pacific (Osaka-Local)" + value = "ap-northeast-3" + icon = "/emojis/1f1f0-1f1f7.png" + } + option { + name = "Asia Pacific (Mumbai)" + value = "ap-south-1" + icon = "/emojis/1f1f0-1f1f7.png" + } + option { + name = "Asia Pacific (Singapore)" + value = "ap-southeast-1" + icon = "/emojis/1f1f0-1f1f7.png" + } + option { + name = "Asia Pacific (Sydney)" + value = "ap-southeast-2" + icon = "/emojis/1f1f0-1f1f7.png" + } + option { + name = "Canada (Central)" + value = "ca-central-1" + icon = "/emojis/1f1e8-1f1e6.png" + } + option { + name = "EU (Frankfurt)" + value = "eu-central-1" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "EU (Stockholm)" + value = "eu-north-1" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "EU (Ireland)" + value = "eu-west-1" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "EU (London)" + value = "eu-west-2" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "EU (Paris)" + value = "eu-west-3" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "South America (São Paulo)" + value = "sa-east-1" + icon = "/emojis/1f1e7-1f1f7.png" + } + option { + name = "US East (N. Virginia)" + value = "us-east-1" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "US East (Ohio)" + value = "us-east-2" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "US West (N. California)" + value = "us-west-1" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "US West (Oregon)" + value = "us-west-2" + icon = "/emojis/1f1fa-1f1f8.png" + } +} + +data "coder_parameter" "instance_type" { + name = "instance_type" + display_name = "Instance type" + description = "What instance type should your workspace use?" + default = "t3.micro" + mutable = false + option { + name = "2 vCPU, 1 GiB RAM" + value = "t3.micro" + } + option { + name = "2 vCPU, 2 GiB RAM" + value = "t3.small" + } + option { + name = "2 vCPU, 4 GiB RAM" + value = "t3.medium" + } + option { + name = "2 vCPU, 8 GiB RAM" + value = "t3.large" + } + option { + name = "4 vCPU, 16 GiB RAM" + value = "t3.xlarge" + } + option { + name = "8 vCPU, 32 GiB RAM" + value = "t3.2xlarge" + } +} + +provider "aws" { + region = data.coder_parameter.region.value +} + +data "coder_workspace" "me" { +} +data "coder_workspace_owner" "me" {} + +data "aws_ami" "windows" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["Windows_Server-2019-English-Full-Base-*"] + } +} + +resource "coder_agent" "main" { + arch = "amd64" + auth = "aws-instance-identity" + os = "windows" +} + +locals { + + # User data is used to stop/start AWS instances. See: + # https://github.com/hashicorp/terraform-provider-aws/issues/22 + + user_data_start = < +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +${coder_agent.main.init_script} + +true +EOT + + user_data_end = < +shutdown /s + +true +EOT +} + +resource "aws_instance" "dev" { + ami = data.aws_ami.windows.id + availability_zone = "${data.coder_parameter.region.value}a" + instance_type = data.coder_parameter.instance_type.value + + user_data = data.coder_workspace.me.transition == "start" ? local.user_data_start : local.user_data_end + tags = { + Name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}" + # Required if you are using our example policy, see template README + Coder_Provisioned = "true" + } + lifecycle { + ignore_changes = [ami] + } +} + +resource "coder_metadata" "workspace_info" { + resource_id = aws_instance.dev.id + item { + key = "region" + value = data.coder_parameter.region.value + } + item { + key = "instance type" + value = aws_instance.dev.instance_type + } + item { + key = "disk" + value = "${aws_instance.dev.root_block_device[0].volume_size} GiB" + } +} diff --git a/registry/coder/templates/azure-linux/README.md b/registry/coder/templates/azure-linux/README.md new file mode 100644 index 00000000..9b5ba079 --- /dev/null +++ b/registry/coder/templates/azure-linux/README.md @@ -0,0 +1,64 @@ +--- +display_name: Azure VM (Linux) +description: Provision Azure VMs as Coder workspaces +icon: ../../../../.icons/azure.svg +maintainer_github: coder +verified: true +tags: [vm, linux, azure] +--- + +# Remote Development on Azure VMs (Linux) + +Provision Azure Linux VMs as [Coder workspaces](https://coder.com/docs/workspaces) with this example template. + + + +## Prerequisites + +### Authentication + +This template assumes that coderd is run in an environment that is authenticated +with Azure. For example, run `az login` then `az account set --subscription=` +to import credentials on the system and user running coderd. For other ways to +authenticate, [consult the Terraform docs](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs#authenticating-to-azure). + +## Architecture + +This template provisions the following resources: + +- Azure VM (ephemeral, deleted on stop) +- Managed disk (persistent, mounted to `/home/coder`) + +This means, when the workspace restarts, any tools or files outside of the home directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles. + +> [!NOTE] +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +### Persistent VM + +> [!IMPORTANT] +> This approach requires the [`az` CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli#install) to be present in the PATH of your Coder Provisioner. +> You will have to do this installation manually as it is not included in our official images. + +It is possible to make the VM persistent (instead of ephemeral) by removing the `count` attribute in the `azurerm_linux_virtual_machine` resource block as well as adding the following snippet: + +```hcl +# Stop the VM +resource "null_resource" "stop_vm" { + count = data.coder_workspace.me.transition == "stop" ? 1 : 0 + depends_on = [azurerm_linux_virtual_machine.main] + provisioner "local-exec" { + # Use deallocate so the VM is not charged + command = "az vm deallocate --ids ${azurerm_linux_virtual_machine.main.id}" + } +} + +# Start the VM +resource "null_resource" "start" { + count = data.coder_workspace.me.transition == "start" ? 1 : 0 + depends_on = [azurerm_linux_virtual_machine.main] + provisioner "local-exec" { + command = "az vm start --ids ${azurerm_linux_virtual_machine.main.id}" + } +} +``` diff --git a/registry/coder/templates/azure-linux/cloud-init/cloud-config.yaml.tftpl b/registry/coder/templates/azure-linux/cloud-init/cloud-config.yaml.tftpl new file mode 100644 index 00000000..75006778 --- /dev/null +++ b/registry/coder/templates/azure-linux/cloud-init/cloud-config.yaml.tftpl @@ -0,0 +1,56 @@ +#cloud-config +cloud_final_modules: +- [scripts-user, always] +bootcmd: + # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117 + - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done +device_aliases: + homedir: /dev/disk/azure/scsi1/lun10 +disk_setup: + homedir: + table_type: gpt + layout: true +fs_setup: + - label: coder_home + filesystem: ext4 + device: homedir.1 +mounts: + - ["LABEL=coder_home", "/home/${username}"] +hostname: ${hostname} +users: + - name: ${username} + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + groups: sudo + shell: /bin/bash +packages: + - git +write_files: + - path: /opt/coder/init + permissions: "0755" + encoding: b64 + content: ${init_script} + - path: /etc/systemd/system/coder-agent.service + permissions: "0644" + content: | + [Unit] + Description=Coder Agent + After=network-online.target + Wants=network-online.target + + [Service] + User=${username} + ExecStart=/opt/coder/init + Restart=always + RestartSec=10 + TimeoutStopSec=90 + KillMode=process + + OOMScoreAdjust=-900 + SyslogIdentifier=coder-agent + + [Install] + WantedBy=multi-user.target +runcmd: + - chown ${username}:${username} /home/${username} + - systemctl enable coder-agent + - systemctl start coder-agent diff --git a/registry/coder/templates/azure-linux/main.tf b/registry/coder/templates/azure-linux/main.tf new file mode 100644 index 00000000..687c8cae --- /dev/null +++ b/registry/coder/templates/azure-linux/main.tf @@ -0,0 +1,325 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + azurerm = { + source = "hashicorp/azurerm" + } + cloudinit = { + source = "hashicorp/cloudinit" + } + } +} + +# See https://registry.coder.com/modules/coder/azure-region +module "azure_region" { + source = "registry.coder.com/coder/azure-region/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + default = "eastus" +} + +data "coder_parameter" "instance_type" { + name = "instance_type" + display_name = "Instance type" + description = "What instance type should your workspace use?" + default = "Standard_B4ms" + icon = "/icon/azure.png" + mutable = false + option { + name = "Standard_B1ms (1 vCPU, 2 GiB RAM)" + value = "Standard_B1ms" + } + option { + name = "Standard_B2ms (2 vCPU, 8 GiB RAM)" + value = "Standard_B2ms" + } + option { + name = "Standard_B4ms (4 vCPU, 16 GiB RAM)" + value = "Standard_B4ms" + } + option { + name = "Standard_B8ms (8 vCPU, 32 GiB RAM)" + value = "Standard_B8ms" + } + option { + name = "Standard_B12ms (12 vCPU, 48 GiB RAM)" + value = "Standard_B12ms" + } + option { + name = "Standard_B16ms (16 vCPU, 64 GiB RAM)" + value = "Standard_B16ms" + } + option { + name = "Standard_D2as_v5 (2 vCPU, 8 GiB RAM)" + value = "Standard_D2as_v5" + } + option { + name = "Standard_D4as_v5 (4 vCPU, 16 GiB RAM)" + value = "Standard_D4as_v5" + } + option { + name = "Standard_D8as_v5 (8 vCPU, 32 GiB RAM)" + value = "Standard_D8as_v5" + } + option { + name = "Standard_D16as_v5 (16 vCPU, 64 GiB RAM)" + value = "Standard_D16as_v5" + } + option { + name = "Standard_D32as_v5 (32 vCPU, 128 GiB RAM)" + value = "Standard_D32as_v5" + } +} + +data "coder_parameter" "home_size" { + name = "home_size" + display_name = "Home volume size" + description = "How large would you like your home volume to be (in GB)?" + default = 20 + type = "number" + icon = "/icon/azure.png" + mutable = false + validation { + min = 1 + max = 1024 + } +} + +provider "azurerm" { + features {} +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + arch = "amd64" + os = "linux" + auth = "azure-instance-identity" + + metadata { + key = "cpu" + display_name = "CPU Usage" + interval = 5 + timeout = 5 + script = <<-EOT + #!/bin/bash + set -e + top -bn1 | grep "Cpu(s)" | awk '{print $2 + $4 "%"}' + EOT + } + metadata { + key = "memory" + display_name = "Memory Usage" + interval = 5 + timeout = 5 + script = <<-EOT + #!/bin/bash + set -e + free -m | awk 'NR==2{printf "%.2f%%\t", $3*100/$2 }' + EOT + } + metadata { + key = "disk" + display_name = "Disk Usage" + interval = 600 # every 10 minutes + timeout = 30 # df can take a while on large filesystems + script = <<-EOT + #!/bin/bash + set -e + df /home/coder | awk '$NF=="/"{printf "%s", $5}' + EOT + } +} + +# See https://registry.coder.com/modules/coder/code-server +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + order = 1 +} + +# See https://registry.coder.com/modules/coder/jetbrains-gateway +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-gateway/coder" + + # JetBrains IDEs to make available for the user to select + jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] + default = "IU" + + # Default folder to open when starting a JetBrains IDE + folder = "/home/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + agent_name = "main" + order = 2 +} + +locals { + prefix = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}" +} + +data "cloudinit_config" "user_data" { + gzip = false + base64_encode = true + + boundary = "//" + + part { + filename = "cloud-config.yaml" + content_type = "text/cloud-config" + + content = templatefile("${path.module}/cloud-init/cloud-config.yaml.tftpl", { + username = "coder" # Ensure this user/group does not exist in your VM image + init_script = base64encode(coder_agent.main.init_script) + hostname = lower(data.coder_workspace.me.name) + }) + } +} + +resource "azurerm_resource_group" "main" { + name = "${local.prefix}-resources" + location = module.azure_region.value + + tags = { + Coder_Provisioned = "true" + } +} + +// Uncomment here and in the azurerm_network_interface resource to obtain a public IP +#resource "azurerm_public_ip" "main" { +# name = "publicip" +# resource_group_name = azurerm_resource_group.main.name +# location = azurerm_resource_group.main.location +# allocation_method = "Static" +# +# tags = { +# Coder_Provisioned = "true" +# } +#} + +resource "azurerm_virtual_network" "main" { + name = "network" + address_space = ["10.0.0.0/24"] + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + + tags = { + Coder_Provisioned = "true" + } +} + +resource "azurerm_subnet" "internal" { + name = "internal" + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = ["10.0.0.0/29"] +} + +resource "azurerm_network_interface" "main" { + name = "nic" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + + ip_configuration { + name = "internal" + subnet_id = azurerm_subnet.internal.id + private_ip_address_allocation = "Dynamic" + // Uncomment for public IP address as well as azurerm_public_ip resource above + //public_ip_address_id = azurerm_public_ip.main.id + } + + tags = { + Coder_Provisioned = "true" + } +} + +resource "azurerm_managed_disk" "home" { + create_option = "Empty" + location = azurerm_resource_group.main.location + name = "home" + resource_group_name = azurerm_resource_group.main.name + storage_account_type = "StandardSSD_LRS" + disk_size_gb = data.coder_parameter.home_size.value +} + +// azurerm requires an SSH key (or password) for an admin user or it won't start a VM. However, +// cloud-init overwrites this anyway, so we'll just use a dummy SSH key. +resource "tls_private_key" "dummy" { + algorithm = "RSA" + rsa_bits = 4096 +} + +resource "azurerm_linux_virtual_machine" "main" { + count = data.coder_workspace.me.start_count + name = "vm" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + size = data.coder_parameter.instance_type.value + // cloud-init overwrites this, so the value here doesn't matter + admin_username = "adminuser" + admin_ssh_key { + public_key = tls_private_key.dummy.public_key_openssh + username = "adminuser" + } + + network_interface_ids = [ + azurerm_network_interface.main.id, + ] + computer_name = lower(data.coder_workspace.me.name) + os_disk { + caching = "ReadWrite" + storage_account_type = "Standard_LRS" + } + source_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-focal" + sku = "20_04-lts-gen2" + version = "latest" + } + user_data = data.cloudinit_config.user_data.rendered + + tags = { + Coder_Provisioned = "true" + } +} + +resource "azurerm_virtual_machine_data_disk_attachment" "home" { + count = data.coder_workspace.me.transition == "start" ? 1 : 0 + managed_disk_id = azurerm_managed_disk.home.id + virtual_machine_id = azurerm_linux_virtual_machine.main[0].id + lun = "10" + caching = "ReadWrite" +} + +resource "coder_metadata" "workspace_info" { + count = data.coder_workspace.me.start_count + resource_id = azurerm_linux_virtual_machine.main[0].id + + item { + key = "type" + value = azurerm_linux_virtual_machine.main[0].size + } +} + +resource "coder_metadata" "home_info" { + resource_id = azurerm_managed_disk.home.id + + item { + key = "size" + value = "${data.coder_parameter.home_size.value} GiB" + } +} diff --git a/registry/coder/templates/azure-windows/FirstLogonCommands.xml b/registry/coder/templates/azure-windows/FirstLogonCommands.xml new file mode 100644 index 00000000..ac4a9d80 --- /dev/null +++ b/registry/coder/templates/azure-windows/FirstLogonCommands.xml @@ -0,0 +1,12 @@ + + + cmd /c "copy C:\AzureData\CustomData.bin C:\AzureData\Initialize.ps1" + Copy Initialize.ps1 to file from CustomData + 3 + + + powershell.exe -sta -ExecutionPolicy Unrestricted -Command "C:\AzureData\Initialize.ps1 *> C:\AzureData\Initialize.log" + Execute Initialize.ps1 script + 4 + + diff --git a/registry/coder/templates/azure-windows/Initialize.ps1.tftpl b/registry/coder/templates/azure-windows/Initialize.ps1.tftpl new file mode 100644 index 00000000..ae1bdef7 --- /dev/null +++ b/registry/coder/templates/azure-windows/Initialize.ps1.tftpl @@ -0,0 +1,73 @@ +# This script gets run once when the VM is first created. + +# Initialize the data disk & home directory. +$disk = Get-Disk -Number 2 +if ($disk.PartitionStyle -Eq 'RAW') +{ + "Initializing data disk" + $disk | Initialize-Disk +} else { + "data disk already initialized" +} + +$partitions = Get-Partition -DiskNumber $disk.Number | Where-Object Type -Ne 'Reserved' +if ($partitions.Count -Eq 0) { + "Creating partition on data disk" + $partition = New-Partition -DiskNumber $disk.Number -UseMaximumSize +} else { + $partition = $partitions[0] + $s = "data disk already has partition of size {0:n1} GiB" -f ($partition.Size / 1073741824) + Write-Output $s +} + +$volume = Get-Volume -Partition $partition +if ($volume.FileSystemType -Eq 'Unknown') +{ + "Formatting data disk" + Format-Volume -InputObject $volume -FileSystem NTFS -Confirm:$false +} else { + "data disk is already formatted" +} + +# Mount the partition +Add-PartitionAccessPath -InputObject $partition -AccessPath "F:" + +# Enable RDP +Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -name "fDenyTSConnections" -value 0 +# Enable RDP through Windows Firewall +Enable-NetFirewallRule -DisplayGroup "Remote Desktop" +# Disable Network Level Authentication (NLA) +# Clients will connect via Coder's tunnel +(Get-WmiObject -class "Win32_TSGeneralSetting" -Namespace root\cimv2\terminalservices -ComputerName $env:COMPUTERNAME -Filter "TerminalName='RDP-tcp'").SetUserAuthenticationRequired(0) + +# Install Chocolatey package manager +Set-ExecutionPolicy Bypass -Scope Process -Force +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 +iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) +# Reload path so sessions include "choco" and "refreshenv" +$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + +# Install Git and reload path +choco install -y git +$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + +# Set protocol to TLS1.2 for agent download +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Set Coder Agent to run immediately, and on each restart +$init_script = @' +${init_script} +'@ +Out-File -FilePath "C:\AzureData\CoderAgent.ps1" -InputObject $init_script +$task = @{ + TaskName = 'CoderAgent' + Action = (New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-sta -ExecutionPolicy Unrestricted -Command "C:\AzureData\CoderAgent.ps1 *>> C:\AzureData\CoderAgent.log"') + Trigger = (New-ScheduledTaskTrigger -AtStartup), (New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(15)) + Settings = (New-ScheduledTaskSettingsSet -DontStopOnIdleEnd -ExecutionTimeLimit ([TimeSpan]::FromDays(3650)) -Compatibility Win8) + Principal = (New-ScheduledTaskPrincipal -UserId "$env:COMPUTERNAME\$env:USERNAME" -RunLevel Highest -LogonType S4U) +} +Register-ScheduledTask @task -Force + +# Additional Chocolatey package installs (optional, uncomment to enable) +# choco feature enable -n=allowGlobalConfirmation +# choco install visualstudio2022community --package-parameters "--add=Microsoft.VisualStudio.Workload.ManagedDesktop;includeRecommended --passive --locale en-US" diff --git a/registry/coder/templates/azure-windows/README.md b/registry/coder/templates/azure-windows/README.md new file mode 100644 index 00000000..1fb1bfe1 --- /dev/null +++ b/registry/coder/templates/azure-windows/README.md @@ -0,0 +1,64 @@ +--- +display_name: Azure VM (Windows) +description: Provision Azure VMs as Coder workspaces +icon: ../../../../.icons/azure.svg +maintainer_github: coder +verified: true +tags: [vm, windows, azure] +--- + +# Remote Development on Azure VMs (Windows) + +Provision Azure Windows VMs as [Coder workspaces](https://coder.com/docs/workspaces) with this example template. + + + +## Prerequisites + +### Authentication + +This template assumes that coderd is run in an environment that is authenticated +with Azure. For example, run `az login` then `az account set --subscription=` +to import credentials on the system and user running coderd. For other ways to +authenticate, [consult the Terraform docs](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs#authenticating-to-azure). + +## Architecture + +This template provisions the following resources: + +- Azure VM (ephemeral, deleted on stop) +- Managed disk (persistent, mounted to `F:`) + +This means, when the workspace restarts, any tools or files outside of the data directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). + +> [!NOTE] +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +### Persistent VM + +> [!IMPORTANT] +> This approach requires the [`az` CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli#install) to be present in the PATH of your Coder Provisioner. +> You will have to do this installation manually as it is not included in our official images. + +It is possible to make the VM persistent (instead of ephemeral) by removing the `count` attribute in the `azurerm_windows_virtual_machine` resource block as well as adding the following snippet: + +```hcl +# Stop the VM +resource "null_resource" "stop_vm" { + count = data.coder_workspace.me.transition == "stop" ? 1 : 0 + depends_on = [azurerm_windows_virtual_machine.main] + provisioner "local-exec" { + # Use deallocate so the VM is not charged + command = "az vm deallocate --ids ${azurerm_windows_virtual_machine.main.id}" + } +} + +# Start the VM +resource "null_resource" "start" { + count = data.coder_workspace.me.transition == "start" ? 1 : 0 + depends_on = [azurerm_windows_virtual_machine.main] + provisioner "local-exec" { + command = "az vm start --ids ${azurerm_windows_virtual_machine.main.id}" + } +} +``` diff --git a/registry/coder/templates/azure-windows/main.tf b/registry/coder/templates/azure-windows/main.tf new file mode 100644 index 00000000..65447a77 --- /dev/null +++ b/registry/coder/templates/azure-windows/main.tf @@ -0,0 +1,210 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + azurerm = { + source = "hashicorp/azurerm" + } + } +} + +provider "azurerm" { + features {} +} + +provider "coder" {} +data "coder_workspace" "me" {} + +# See https://registry.coder.com/modules/coder/azure-region +module "azure_region" { + source = "registry.coder.com/coder/azure-region/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + default = "eastus" +} + +# See https://registry.coder.com/modules/coder/windows-rdp +module "windows_rdp" { + source = "registry.coder.com/coder/windows-rdp/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + admin_username = local.admin_username + admin_password = random_password.admin_password.result + + agent_id = resource.coder_agent.main.id + resource_id = null # Unused, to be removed in a future version +} + +data "coder_parameter" "data_disk_size" { + description = "Size of your data (F:) drive in GB" + display_name = "Data disk size" + name = "data_disk_size" + default = 20 + mutable = "false" + type = "number" + validation { + min = 5 + max = 5000 + } +} + +resource "coder_agent" "main" { + arch = "amd64" + auth = "azure-instance-identity" + os = "windows" +} + +resource "random_password" "admin_password" { + length = 16 + special = true + # https://docs.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/password-must-meet-complexity-requirements#reference + # we remove characters that require special handling in XML, as this is how we pass it to the VM; we also remove the powershell escape character + # namely: <>&'`" + override_special = "~!@#$%^*_-+=|\\(){}[]:;,.?/" +} + +locals { + prefix = "coder-win" + admin_username = "coder" +} + +resource "azurerm_resource_group" "main" { + name = "${local.prefix}-${data.coder_workspace.me.id}" + location = module.azure_region.value + tags = { + Coder_Provisioned = "true" + } +} + +// Uncomment here and in the azurerm_network_interface resource to obtain a public IP +#resource "azurerm_public_ip" "main" { +# name = "publicip" +# resource_group_name = azurerm_resource_group.main.name +# location = azurerm_resource_group.main.location +# allocation_method = "Static" +# tags = { +# Coder_Provisioned = "true" +# } +#} +resource "azurerm_virtual_network" "main" { + name = "network" + address_space = ["10.0.0.0/24"] + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + tags = { + Coder_Provisioned = "true" + } +} +resource "azurerm_subnet" "internal" { + name = "internal" + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = ["10.0.0.0/29"] +} +resource "azurerm_network_interface" "main" { + name = "nic" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + ip_configuration { + name = "internal" + subnet_id = azurerm_subnet.internal.id + private_ip_address_allocation = "Dynamic" + // Uncomment for public IP address as well as azurerm_public_ip resource above + # public_ip_address_id = azurerm_public_ip.main.id + } + tags = { + Coder_Provisioned = "true" + } +} +# Create storage account for boot diagnostics +resource "azurerm_storage_account" "my_storage_account" { + name = "diag${random_id.storage_id.hex}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + account_tier = "Standard" + account_replication_type = "LRS" +} +# Generate random text for a unique storage account name +resource "random_id" "storage_id" { + keepers = { + # Generate a new ID only when a new resource group is defined + resource_group = azurerm_resource_group.main.name + } + byte_length = 8 +} + +resource "azurerm_managed_disk" "data" { + name = "data_disk" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + storage_account_type = "Standard_LRS" + create_option = "Empty" + disk_size_gb = data.coder_parameter.data_disk_size.value +} + +# Create virtual machine +resource "azurerm_windows_virtual_machine" "main" { + count = data.coder_workspace.me.start_count + name = "vm" + admin_username = local.admin_username + admin_password = random_password.admin_password.result + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + network_interface_ids = [azurerm_network_interface.main.id] + size = "Standard_DS1_v2" + custom_data = base64encode( + templatefile("${path.module}/Initialize.ps1.tftpl", { init_script = coder_agent.main.init_script }) + ) + os_disk { + name = "myOsDisk" + caching = "ReadWrite" + storage_account_type = "Premium_LRS" + } + source_image_reference { + publisher = "MicrosoftWindowsServer" + offer = "WindowsServer" + sku = "2022-datacenter-azure-edition" + version = "latest" + } + additional_unattend_content { + content = "${random_password.admin_password.result}true1${local.admin_username}" + setting = "AutoLogon" + } + additional_unattend_content { + content = file("${path.module}/FirstLogonCommands.xml") + setting = "FirstLogonCommands" + } + boot_diagnostics { + storage_account_uri = azurerm_storage_account.my_storage_account.primary_blob_endpoint + } + tags = { + Coder_Provisioned = "true" + } +} + +resource "coder_metadata" "rdp_login" { + count = data.coder_workspace.me.start_count + resource_id = azurerm_windows_virtual_machine.main[0].id + item { + key = "Username" + value = local.admin_username + } + item { + key = "Password" + value = random_password.admin_password.result + sensitive = true + } +} + +resource "azurerm_virtual_machine_data_disk_attachment" "main_data" { + count = data.coder_workspace.me.start_count + managed_disk_id = azurerm_managed_disk.data.id + virtual_machine_id = azurerm_windows_virtual_machine.main[0].id + lun = "10" + caching = "ReadWrite" +} diff --git a/registry/coder/templates/digitalocean-linux/README.md b/registry/coder/templates/digitalocean-linux/README.md new file mode 100644 index 00000000..5e72b0bf --- /dev/null +++ b/registry/coder/templates/digitalocean-linux/README.md @@ -0,0 +1,52 @@ +--- +display_name: DigitalOcean Droplet (Linux) +description: Provision DigitalOcean Droplets as Coder workspaces +icon: ../../../../.icons/digital-ocean.svg +maintainer_github: coder +verified: true +tags: [vm, linux, digitalocean] +--- + +# Remote Development on DigitalOcean Droplets + +Provision DigitalOcean Droplets as [Coder workspaces](https://coder.com/docs/workspaces) with this example template. + + + +## Prerequisites + +To deploy workspaces as DigitalOcean Droplets, you'll need: + +- DigitalOcean [personal access token (PAT)](https://docs.digitalocean.com/reference/api/create-personal-access-token) + +- DigitalOcean project ID (you can get your project information via the `doctl` CLI by running `doctl projects list`) + + - Remove the following sections from the `main.tf` file if you don't want to + associate your workspaces with a project: + + - `variable "project_uuid"` + - `resource "digitalocean_project_resources" "project"` + +- **Optional:** DigitalOcean SSH key ID (obtain via the `doctl` CLI by running + `doctl compute ssh-key list`) + + - Note that this is only required for Fedora images to work. + +### Authentication + +This template assumes that the Coder Provisioner is run in an environment that is authenticated with Digital Ocean. + +Obtain a [Digital Ocean Personal Access Token](https://cloud.digitalocean.com/account/api/tokens) and set the `DIGITALOCEAN_TOKEN` environment variable to the access token. +For other ways to authenticate [consult the Terraform provider's docs](https://registry.terraform.io/providers/digitalocean/digitalocean/latest/docs). + +## Architecture + +This template provisions the following resources: + +- DigitalOcean VM (ephemeral, deleted on stop) +- Managed disk (persistent, mounted to `/home/coder`) + +This means, when the workspace restarts, any tools or files outside of the home directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the VM image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). + +> [!NOTE] +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. diff --git a/registry/coder/templates/digitalocean-linux/cloud-config.yaml.tftpl b/registry/coder/templates/digitalocean-linux/cloud-config.yaml.tftpl new file mode 100644 index 00000000..a2400b54 --- /dev/null +++ b/registry/coder/templates/digitalocean-linux/cloud-config.yaml.tftpl @@ -0,0 +1,46 @@ +#cloud-config +users: + - name: ${username} + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + groups: sudo + shell: /bin/bash +packages: + - git +mounts: + - [ + "LABEL=${home_volume_label}", + "/home/${username}", + auto, + "defaults,uid=1000,gid=1000", + ] +write_files: + - path: /opt/coder/init + permissions: "0755" + encoding: b64 + content: ${init_script} + - path: /etc/systemd/system/coder-agent.service + permissions: "0644" + content: | + [Unit] + Description=Coder Agent + After=network-online.target + Wants=network-online.target + + [Service] + User=${username} + ExecStart=/opt/coder/init + Environment=CODER_AGENT_TOKEN=${coder_agent_token} + Restart=always + RestartSec=10 + TimeoutStopSec=90 + KillMode=process + + OOMScoreAdjust=-900 + SyslogIdentifier=coder-agent + + [Install] + WantedBy=multi-user.target +runcmd: + - chown ${username}:${username} /home/${username} + - systemctl enable coder-agent + - systemctl start coder-agent diff --git a/registry/coder/templates/digitalocean-linux/main.tf b/registry/coder/templates/digitalocean-linux/main.tf new file mode 100644 index 00000000..4daf4b8b --- /dev/null +++ b/registry/coder/templates/digitalocean-linux/main.tf @@ -0,0 +1,361 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + digitalocean = { + source = "digitalocean/digitalocean" + } + } +} + +provider "coder" {} + +variable "project_uuid" { + type = string + description = <<-EOF + DigitalOcean project ID + + $ doctl projects list + EOF + sensitive = true + + validation { + # make sure length of alphanumeric string is 36 (UUIDv4 size) + condition = length(var.project_uuid) == 36 + error_message = "Invalid Digital Ocean Project ID." + } + +} + +variable "ssh_key_id" { + type = number + description = <<-EOF + DigitalOcean SSH key ID (some Droplet images require an SSH key to be set): + + Can be set to "0" for no key. + + Note: Setting this to zero will break Fedora images and notify root passwords via email. + + $ doctl compute ssh-key list + EOF + sensitive = true + default = 0 + + validation { + condition = var.ssh_key_id >= 0 + error_message = "Invalid Digital Ocean SSH key ID, a number is required." + } +} + +data "coder_parameter" "droplet_image" { + name = "droplet_image" + display_name = "Droplet image" + description = "Which Droplet image would you like to use?" + default = "ubuntu-22-04-x64" + type = "string" + mutable = false + option { + name = "AlmaLinux 9" + value = "almalinux-9-x64" + icon = "/icon/almalinux.svg" + } + option { + name = "AlmaLinux 8" + value = "almalinux-8-x64" + icon = "/icon/almalinux.svg" + } + option { + name = "Fedora 39" + value = "fedora-39-x64" + icon = "/icon/fedora.svg" + } + option { + name = "Fedora 38" + value = "fedora-38-x64" + icon = "/icon/fedora.svg" + } + option { + name = "CentOS Stream 9" + value = "centos-stream-9-x64" + icon = "/icon/centos.svg" + } + option { + name = "CentOS Stream 8" + value = "centos-stream-8-x64" + icon = "/icon/centos.svg" + } + option { + name = "Debian 12" + value = "debian-12-x64" + icon = "/icon/debian.svg" + } + option { + name = "Debian 11" + value = "debian-11-x64" + icon = "/icon/debian.svg" + } + option { + name = "Debian 10" + value = "debian-10-x64" + icon = "/icon/debian.svg" + } + option { + name = "Rocky Linux 9" + value = "rockylinux-9-x64" + icon = "/icon/rockylinux.svg" + } + option { + name = "Rocky Linux 8" + value = "rockylinux-8-x64" + icon = "/icon/rockylinux.svg" + } + option { + name = "Ubuntu 22.04 (LTS)" + value = "ubuntu-22-04-x64" + icon = "/icon/ubuntu.svg" + } + option { + name = "Ubuntu 20.04 (LTS)" + value = "ubuntu-20-04-x64" + icon = "/icon/ubuntu.svg" + } +} + +data "coder_parameter" "droplet_size" { + name = "droplet_size" + display_name = "Droplet size" + description = "Which Droplet configuration would you like to use?" + default = "s-1vcpu-1gb" + type = "string" + icon = "/icon/memory.svg" + mutable = false + # s-1vcpu-512mb-10gb is unsupported in tor1, blr1, lon1, sfo2, and nyc3 regions + # s-8vcpu-16gb access requires a support ticket with Digital Ocean + option { + name = "1 vCPU, 1 GB RAM" + value = "s-1vcpu-1gb" + } + option { + name = "1 vCPU, 2 GB RAM" + value = "s-1vcpu-2gb" + } + option { + name = "2 vCPU, 2 GB RAM" + value = "s-2vcpu-2gb" + } + option { + name = "2 vCPU, 4 GB RAM" + value = "s-2vcpu-4gb" + } + option { + name = "4 vCPU, 8 GB RAM" + value = "s-4vcpu-8gb" + } +} + +data "coder_parameter" "home_volume_size" { + name = "home_volume_size" + display_name = "Home volume size" + description = "How large would you like your home volume to be (in GB)?" + type = "number" + default = "20" + mutable = false + validation { + min = 1 + max = 100 # Sizes larger than 100 GB require a support ticket with Digital Ocean + } +} + +data "coder_parameter" "region" { + name = "region" + display_name = "Region" + description = "This is the region where your workspace will be created." + icon = "/emojis/1f30e.png" + type = "string" + default = "ams3" + mutable = false + # nyc1, sfo1, and ams2 regions were excluded because they do not support volumes, which are used to persist data while decreasing cost + option { + name = "Canada (Toronto)" + value = "tor1" + icon = "/emojis/1f1e8-1f1e6.png" + } + option { + name = "Germany (Frankfurt)" + value = "fra1" + icon = "/emojis/1f1e9-1f1ea.png" + } + option { + name = "India (Bangalore)" + value = "blr1" + icon = "/emojis/1f1ee-1f1f3.png" + } + option { + name = "Netherlands (Amsterdam)" + value = "ams3" + icon = "/emojis/1f1f3-1f1f1.png" + } + option { + name = "Singapore" + value = "sgp1" + icon = "/emojis/1f1f8-1f1ec.png" + } + option { + name = "United Kingdom (London)" + value = "lon1" + icon = "/emojis/1f1ec-1f1e7.png" + } + option { + name = "United States (California - 2)" + value = "sfo2" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "United States (California - 3)" + value = "sfo3" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "United States (New York - 1)" + value = "nyc1" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "United States (New York - 3)" + value = "nyc3" + icon = "/emojis/1f1fa-1f1f8.png" + } +} + +# Configure the DigitalOcean Provider +provider "digitalocean" { + # Recommended: use environment variable DIGITALOCEAN_TOKEN with your personal access token when starting coderd + # alternatively, you can pass the token via a variable. +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + os = "linux" + arch = "amd64" + + metadata { + key = "cpu" + display_name = "CPU Usage" + interval = 5 + timeout = 5 + script = "coder stat cpu" + } + metadata { + key = "memory" + display_name = "Memory Usage" + interval = 5 + timeout = 5 + script = "coder stat mem" + } + metadata { + key = "home" + display_name = "Home Usage" + interval = 600 # every 10 minutes + timeout = 30 # df can take a while on large filesystems + script = "coder stat disk --path /home/${lower(data.coder_workspace_owner.me.name)}" + } +} + +# See https://registry.coder.com/modules/coder/code-server +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + order = 1 +} + +# See https://registry.coder.com/modules/coder/jetbrains-gateway +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-gateway/coder" + + # JetBrains IDEs to make available for the user to select + jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] + default = "IU" + + # Default folder to open when starting a JetBrains IDE + folder = "/home/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + agent_name = "main" + order = 2 +} + +resource "digitalocean_volume" "home_volume" { + region = data.coder_parameter.region.value + name = "coder-${data.coder_workspace.me.id}-home" + size = data.coder_parameter.home_volume_size.value + initial_filesystem_type = "ext4" + initial_filesystem_label = "coder-home" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } +} + +resource "digitalocean_droplet" "workspace" { + region = data.coder_parameter.region.value + count = data.coder_workspace.me.start_count + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}" + image = data.coder_parameter.droplet_image.value + size = data.coder_parameter.droplet_size.value + + volume_ids = [digitalocean_volume.home_volume.id] + user_data = templatefile("cloud-config.yaml.tftpl", { + username = lower(data.coder_workspace_owner.me.name) + home_volume_label = digitalocean_volume.home_volume.initial_filesystem_label + init_script = base64encode(coder_agent.main.init_script) + coder_agent_token = coder_agent.main.token + }) + # Required to provision Fedora. + ssh_keys = var.ssh_key_id > 0 ? [var.ssh_key_id] : [] +} + +resource "digitalocean_project_resources" "project" { + project = var.project_uuid + # Workaround for terraform plan when using count. + resources = length(digitalocean_droplet.workspace) > 0 ? [ + digitalocean_volume.home_volume.urn, + digitalocean_droplet.workspace[0].urn + ] : [ + digitalocean_volume.home_volume.urn + ] +} + +resource "coder_metadata" "workspace-info" { + count = data.coder_workspace.me.start_count + resource_id = digitalocean_droplet.workspace[0].id + + item { + key = "region" + value = digitalocean_droplet.workspace[0].region + } + item { + key = "image" + value = digitalocean_droplet.workspace[0].image + } +} + +resource "coder_metadata" "volume-info" { + resource_id = digitalocean_volume.home_volume.id + + item { + key = "size" + value = "${digitalocean_volume.home_volume.size} GiB" + } +} diff --git a/registry/coder/templates/docker-devcontainer/README.md b/registry/coder/templates/docker-devcontainer/README.md new file mode 100644 index 00000000..0ba7eaa5 --- /dev/null +++ b/registry/coder/templates/docker-devcontainer/README.md @@ -0,0 +1,77 @@ +--- +display_name: Docker (Devcontainer) +description: Provision envbuilder containers as Coder workspaces +icon: ../../../../.icons/docker.svg +maintainer_github: coder +verified: true +tags: [container, docker, devcontainer] +--- + +# Remote Development on Docker Containers (with Devcontainers) + +Provision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces) in Docker with this example template. + +## Prerequisites + +### Infrastructure + +Coder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group: + +```shell +# Add coder user to Docker group +sudo usermod -aG docker coder + +# Restart Coder server +sudo systemctl restart coder + +# Test Docker +sudo -u coder docker ps +``` + +## Architecture + +Coder supports Devcontainers via [envbuilder](https://github.com/coder/envbuilder), an open source project. Read more about this in [Coder's documentation](https://coder.com/docs/templates/dev-containers). + +This template provisions the following resources: + +- Envbuilder cached image (conditional, persistent) using [`terraform-provider-envbuilder`](https://github.com/coder/terraform-provider-envbuilder) +- Docker image (persistent) using [`envbuilder`](https://github.com/coder/envbuilder) +- Docker container (ephemeral) +- Docker volume (persistent on `/workspaces`) + +The Git repository is cloned inside the `/workspaces` volume if not present. +Any local changes to the Devcontainer files inside the volume will be applied when you restart the workspace. +Keep in mind that any tools or files outside of `/workspaces` or not added as part of the Devcontainer specification are not persisted. +Edit the `devcontainer.json` instead! + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## Docker-in-Docker + +See the [Envbuilder documentation](https://github.com/coder/envbuilder/blob/main/docs/docker.md) for information on running Docker containers inside a devcontainer built by Envbuilder. + +## Caching + +To speed up your builds, you can use a container registry as a cache. +When creating the template, set the parameter `cache_repo` to a valid Docker repository. + +For example, you can run a local registry: + +```shell +docker run --detach \ + --volume registry-cache:/var/lib/registry \ + --publish 5000:5000 \ + --name registry-cache \ + --net=host \ + registry:2 +``` + +Then, when creating the template, enter `localhost:5000/devcontainer-cache` for the parameter `cache_repo`. + +See the [Envbuilder Terraform Provider Examples](https://github.com/coder/terraform-provider-envbuilder/blob/main/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf/) for a more complete example of how the provider works. + +> [!NOTE] +> We recommend using a registry cache with authentication enabled. +> To allow Envbuilder to authenticate with the registry cache, specify the variable `cache_repo_docker_config_path` +> with the path to a Docker config `.json` on disk containing valid credentials for the registry. diff --git a/registry/coder/templates/docker-devcontainer/main.tf b/registry/coder/templates/docker-devcontainer/main.tf new file mode 100644 index 00000000..2765874f --- /dev/null +++ b/registry/coder/templates/docker-devcontainer/main.tf @@ -0,0 +1,372 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 2.0" + } + docker = { + source = "kreuzwerker/docker" + } + envbuilder = { + source = "coder/envbuilder" + } + } +} + +variable "docker_socket" { + default = "" + description = "(Optional) Docker socket URI" + type = string +} + +provider "coder" {} +provider "docker" { + # Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default + host = var.docker_socket != "" ? var.docker_socket : null +} +provider "envbuilder" {} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "repo" { + description = "Select a repository to automatically clone and start working with a devcontainer." + display_name = "Repository (auto)" + mutable = true + name = "repo" + option { + name = "vercel/next.js" + description = "The React Framework" + value = "https://github.com/vercel/next.js" + } + option { + name = "home-assistant/core" + description = "🏡 Open source home automation that puts local control and privacy first." + value = "https://github.com/home-assistant/core" + } + option { + name = "discourse/discourse" + description = "A platform for community discussion. Free, open, simple." + value = "https://github.com/discourse/discourse" + } + option { + name = "denoland/deno" + description = "A modern runtime for JavaScript and TypeScript." + value = "https://github.com/denoland/deno" + } + option { + name = "microsoft/vscode" + icon = "/icon/code.svg" + description = "Code editing. Redefined." + value = "https://github.com/microsoft/vscode" + } + option { + name = "Custom" + icon = "/emojis/1f5c3.png" + description = "Specify a custom repo URL below" + value = "custom" + } + order = 1 +} + +data "coder_parameter" "custom_repo_url" { + default = "" + description = "Optionally enter a custom repository URL, see [awesome-devcontainers](https://github.com/manekinekko/awesome-devcontainers)." + display_name = "Repository URL (custom)" + name = "custom_repo_url" + mutable = true + order = 2 +} + +data "coder_parameter" "fallback_image" { + default = "codercom/enterprise-base:ubuntu" + description = "This image runs if the devcontainer fails to build." + display_name = "Fallback Image" + mutable = true + name = "fallback_image" + order = 3 +} + +data "coder_parameter" "devcontainer_builder" { + description = <<-EOF +Image that will build the devcontainer. +We highly recommend using a specific release as the `:latest` tag will change. +Find the latest version of Envbuilder here: https://github.com/coder/envbuilder/pkgs/container/envbuilder +EOF + display_name = "Devcontainer Builder" + mutable = true + name = "devcontainer_builder" + default = "ghcr.io/coder/envbuilder:latest" + order = 4 +} + +variable "cache_repo" { + default = "" + description = "(Optional) Use a container registry as a cache to speed up builds." + type = string +} + +variable "insecure_cache_repo" { + default = false + description = "Enable this option if your cache registry does not serve HTTPS." + type = bool +} + +variable "cache_repo_docker_config_path" { + default = "" + description = "(Optional) Path to a docker config.json containing credentials to the provided cache repo, if required." + sensitive = true + type = string +} + +locals { + container_name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + devcontainer_builder_image = data.coder_parameter.devcontainer_builder.value + git_author_name = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + git_author_email = data.coder_workspace_owner.me.email + repo_url = data.coder_parameter.repo.value == "custom" ? data.coder_parameter.custom_repo_url.value : data.coder_parameter.repo.value + # The envbuilder provider requires a key-value map of environment variables. + envbuilder_env = { + # ENVBUILDER_GIT_URL and ENVBUILDER_CACHE_REPO will be overridden by the provider + # if the cache repo is enabled. + "ENVBUILDER_GIT_URL" : local.repo_url, + "ENVBUILDER_CACHE_REPO" : var.cache_repo, + "CODER_AGENT_TOKEN" : coder_agent.main.token, + # Use the docker gateway if the access URL is 127.0.0.1 + "CODER_AGENT_URL" : replace(data.coder_workspace.me.access_url, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), + # Use the docker gateway if the access URL is 127.0.0.1 + "ENVBUILDER_INIT_SCRIPT" : replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), + "ENVBUILDER_FALLBACK_IMAGE" : data.coder_parameter.fallback_image.value, + "ENVBUILDER_DOCKER_CONFIG_BASE64" : try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, ""), + "ENVBUILDER_PUSH_IMAGE" : var.cache_repo == "" ? "" : "true", + "ENVBUILDER_INSECURE" : "${var.insecure_cache_repo}", + } + # Convert the above map to the format expected by the docker provider. + docker_env = [ + for k, v in local.envbuilder_env : "${k}=${v}" + ] +} + +data "local_sensitive_file" "cache_repo_dockerconfigjson" { + count = var.cache_repo_docker_config_path == "" ? 0 : 1 + filename = var.cache_repo_docker_config_path +} + +resource "docker_image" "devcontainer_builder_image" { + name = local.devcontainer_builder_image + keep_locally = true +} + +resource "docker_volume" "workspaces" { + name = "coder-${data.coder_workspace.me.id}" + # Protect the volume from being deleted due to changes in attributes. + lifecycle { + ignore_changes = all + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + # This field becomes outdated if the workspace is renamed but can + # be useful for debugging or cleaning out dangling volumes. + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +# Check for the presence of a prebuilt image in the cache repo +# that we can use instead. +resource "envbuilder_cached_image" "cached" { + count = var.cache_repo == "" ? 0 : data.coder_workspace.me.start_count + builder_image = local.devcontainer_builder_image + git_url = local.repo_url + cache_repo = var.cache_repo + extra_env = local.envbuilder_env + insecure = var.insecure_cache_repo +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image + # Uses lower() to avoid Docker restriction on container names. + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + # Hostname makes the shell more user friendly: coder@my-workspace:~$ + hostname = data.coder_workspace.me.name + # Use the environment specified by the envbuilder provider, if available. + env = var.cache_repo == "" ? local.docker_env : envbuilder_cached_image.cached.0.env + # network_mode = "host" # Uncomment if testing with a registry running on `localhost`. + host { + host = "host.docker.internal" + ip = "host-gateway" + } + volumes { + container_path = "/workspaces" + volume_name = docker_volume.workspaces.name + read_only = false + } + # Add labels in Docker to keep track of orphan resources. + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e + + # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + EOT + dir = "/workspaces" + + # These environment variables allow you to make Git commits right away after creating a + # workspace. Note that they take precedence over configuration defined in ~/.gitconfig! + # You can remove this block if you'd prefer to configure Git manually or using + # dotfiles. (see docs/dotfiles.md) + env = { + GIT_AUTHOR_NAME = local.git_author_name + GIT_AUTHOR_EMAIL = local.git_author_email + GIT_COMMITTER_NAME = local.git_author_name + GIT_COMMITTER_EMAIL = local.git_author_email + } + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $HOME" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = < + +## Prerequisites + +### Infrastructure + +The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group: + +```sh +# Add coder user to Docker group +sudo adduser coder docker + +# Restart Coder server +sudo systemctl restart coder + +# Test Docker +sudo -u coder docker ps +``` + +## Architecture + +This template provisions the following resources: + +- Docker image (built by Docker socket and kept locally) +- Docker container pod (ephemeral) +- Docker volume (persistent on `/home/coder`) + +This means, when the workspace restarts, any tools or files outside of the home directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the container image. Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles. + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +### Editing the image + +Edit the `Dockerfile` and run `coder templates push` to update workspaces. diff --git a/registry/coder/templates/docker/main.tf b/registry/coder/templates/docker/main.tf new file mode 100644 index 00000000..234c4338 --- /dev/null +++ b/registry/coder/templates/docker/main.tf @@ -0,0 +1,220 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + docker = { + source = "kreuzwerker/docker" + } + } +} + +locals { + username = data.coder_workspace_owner.me.name +} + +variable "docker_socket" { + default = "" + description = "(Optional) Docker socket URI" + type = string +} + +provider "docker" { + # Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default + host = var.docker_socket != "" ? var.docker_socket : null +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e + + # Prepare user home with default files on first start. + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ + touch ~/.init_done + fi + + # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + EOT + + # These environment variables allow you to make Git commits right away after creating a + # workspace. Note that they take precedence over configuration defined in ~/.gitconfig! + # You can remove this block if you'd prefer to configure Git manually or using + # dotfiles. (see docs/dotfiles.md) + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}" + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}" + } + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = < **Create new key**. + +1. Generate a **JSON private key**, which will be what you provide to Coder + during the setup process. + +## Architecture + +This template provisions the following resources: + +- Envbuilder cached image (conditional, persistent) using [`terraform-provider-envbuilder`](https://github.com/coder/terraform-provider-envbuilder) +- GCP VM (persistent) with a running Docker daemon +- GCP Disk (persistent, mounted to root) +- [Envbuilder container](https://github.com/coder/envbuilder) inside the GCP VM + +Coder persists the root volume. The full filesystem is preserved when the workspace restarts. +When the GCP VM starts, a startup script runs that ensures a running Docker daemon, and starts +an Envbuilder container using this Docker daemon. The Docker socket is also mounted inside the container to allow running Docker containers inside the workspace. + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## Caching + +To speed up your builds, you can use a container registry as a cache. +When creating the template, set the parameter `cache_repo` to a valid Docker repository in the form `host.tld/path/to/repo`. + +See the [Envbuilder Terraform Provider Examples](https://github.com/coder/terraform-provider-envbuilder/blob/main/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf/) for a more complete example of how the provider works. + +> [!NOTE] +> We recommend using a registry cache with authentication enabled. +> To allow Envbuilder to authenticate with the registry cache, specify the variable `cache_repo_docker_config_path` +> with the path to a Docker config `.json` on disk containing valid credentials for the registry. + +## code-server + +`code-server` is installed via the [`code-server`](https://registry.coder.com/modules/code-server) registry module. Please check [Coder Registry](https://registry.coder.com) for a list of all modules and templates. diff --git a/registry/coder/templates/gcp-devcontainer/architecture.svg b/registry/coder/templates/gcp-devcontainer/architecture.svg new file mode 100644 index 00000000..c3dfd645 --- /dev/null +++ b/registry/coder/templates/gcp-devcontainer/architecture.svg @@ -0,0 +1,8 @@ +GCPGCPHostingHostingVirtual MachineVirtual MachineLinux HardwareLinux HardwareCoder WorkspaceCoder WorkspaceDevcontainerDevcontainerenvbuilder created filesystemenvbuilder created filesystemA Clone of your repoA Clone of your repoSource codeSource codeLanguagesLanguagesPython. Go, etcPython. Go, etcToolingToolingExtensions, linting, formatting, etcExtensions, linting, formatting, etcCPUsCPUsDisk StorageDisk StorageCode EditorCode EditorVS Code DesktopVS Code DesktopLocal InstallationLocal InstallationVS Code DesktopVS Code DesktopLocal InstallationLocal Installationcode-servercode-serverA web IDEA web IDEJetBrains GatewayJetBrains GatewayLocal InstallationLocal InstallationCommand LineCommand LineSSH via Coder CLISSH via Coder CLI \ No newline at end of file diff --git a/registry/coder/templates/gcp-devcontainer/main.tf b/registry/coder/templates/gcp-devcontainer/main.tf new file mode 100644 index 00000000..317a22fc --- /dev/null +++ b/registry/coder/templates/gcp-devcontainer/main.tf @@ -0,0 +1,341 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + google = { + source = "hashicorp/google" + } + envbuilder = { + source = "coder/envbuilder" + } + } +} + +provider "coder" {} + +provider "google" { + zone = module.gcp_region.value + project = var.project_id +} + +data "google_compute_default_service_account" "default" {} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +variable "project_id" { + description = "Which Google Compute Project should your workspace live in?" +} + +variable "cache_repo" { + default = "" + description = "(Optional) Use a container registry as a cache to speed up builds. Example: host.tld/path/to/repo." + type = string +} + +variable "cache_repo_docker_config_path" { + default = "" + description = "(Optional) Path to a docker config.json containing credentials to the provided cache repo, if required. This will depend on your Coder setup. Example: `/home/coder/.docker/config.json`." + sensitive = true + type = string +} + +# See https://registry.coder.com/modules/coder/gcp-region +module "gcp_region" { + source = "registry.coder.com/coder/gcp-region/coder" + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + regions = ["us", "europe"] +} + +data "coder_parameter" "instance_type" { + name = "instance_type" + display_name = "Instance Type" + description = "Select an instance type for your workspace." + type = "string" + mutable = false + order = 2 + default = "e2-micro" + option { + name = "e2-micro (2C, 1G)" + value = "e2-micro" + } + option { + name = "e2-small (2C, 2G)" + value = "e2-small" + } + option { + name = "e2-medium (2C, 2G)" + value = "e2-medium" + } +} + +data "coder_parameter" "fallback_image" { + default = "codercom/enterprise-base:ubuntu" + description = "This image runs if the devcontainer fails to build." + display_name = "Fallback Image" + mutable = true + name = "fallback_image" + order = 3 +} + +data "coder_parameter" "devcontainer_builder" { + description = <<-EOF +Image that will build the devcontainer. +Find the latest version of Envbuilder here: https://ghcr.io/coder/envbuilder +Be aware that using the `:latest` tag may expose you to breaking changes. +EOF + display_name = "Devcontainer Builder" + mutable = true + name = "devcontainer_builder" + default = "ghcr.io/coder/envbuilder:latest" + order = 4 +} + +data "coder_parameter" "repo_url" { + name = "repo_url" + display_name = "Repository URL" + default = "https://github.com/coder/envbuilder-starter-devcontainer" + description = "Repository URL" + mutable = true +} + +data "local_sensitive_file" "cache_repo_dockerconfigjson" { + count = var.cache_repo_docker_config_path == "" ? 0 : 1 + filename = var.cache_repo_docker_config_path +} + +# Be careful when modifying the below locals! +locals { + # Ensure Coder username is a valid Linux username + linux_user = lower(substr(data.coder_workspace_owner.me.name, 0, 32)) + # Name the container after the workspace and owner. + container_name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + # The devcontainer builder image is the image that will build the devcontainer. + devcontainer_builder_image = data.coder_parameter.devcontainer_builder.value + # We may need to authenticate with a registry. If so, the user will provide a path to a docker config.json. + docker_config_json_base64 = try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, "") + # The envbuilder provider requires a key-value map of environment variables. Build this here. + envbuilder_env = { + # ENVBUILDER_GIT_URL and ENVBUILDER_CACHE_REPO will be overridden by the provider + # if the cache repo is enabled. + "ENVBUILDER_GIT_URL" : data.coder_parameter.repo_url.value, + # The agent token is required for the agent to connect to the Coder platform. + "CODER_AGENT_TOKEN" : try(coder_agent.dev.0.token, ""), + # The agent URL is required for the agent to connect to the Coder platform. + "CODER_AGENT_URL" : data.coder_workspace.me.access_url, + # The agent init script is required for the agent to start up. We base64 encode it here + # to avoid quoting issues. + "ENVBUILDER_INIT_SCRIPT" : "echo ${base64encode(try(coder_agent.dev[0].init_script, ""))} | base64 -d | sh", + "ENVBUILDER_DOCKER_CONFIG_BASE64" : try(data.local_sensitive_file.cache_repo_dockerconfigjson[0].content_base64, ""), + # The fallback image is the image that will run if the devcontainer fails to build. + "ENVBUILDER_FALLBACK_IMAGE" : data.coder_parameter.fallback_image.value, + # The following are used to push the image to the cache repo, if defined. + "ENVBUILDER_CACHE_REPO" : var.cache_repo, + "ENVBUILDER_PUSH_IMAGE" : var.cache_repo == "" ? "" : "true", + # You can add other required environment variables here. + # See: https://github.com/coder/envbuilder/?tab=readme-ov-file#environment-variables + } + # If we have a cached image, use the cached image's environment variables. Otherwise, just use + # the environment variables we've defined above. + docker_env_input = try(envbuilder_cached_image.cached.0.env_map, local.envbuilder_env) + # Convert the above to the list of arguments for the Docker run command. + # The startup script will write this to a file, which the Docker run command will reference. + docker_env_list_base64 = base64encode(join("\n", [for k, v in local.docker_env_input : "${k}=${v}"])) + + # Builder image will either be the builder image parameter, or the cached image, if cache is provided. + builder_image = try(envbuilder_cached_image.cached[0].image, data.coder_parameter.devcontainer_builder.value) + + # The GCP VM needs a startup script to set up the environment and start the container. Defining this here. + # NOTE: make sure to test changes by uncommenting the local_file resource at the bottom of this file + # and running `terraform apply` to see the generated script. You should also run shellcheck on the script + # to ensure it is valid. + startup_script = <<-META + #!/usr/bin/env sh + set -eux + + # If user does not exist, create it and set up passwordless sudo + if ! id -u "${local.linux_user}" >/dev/null 2>&1; then + useradd -m -s /bin/bash "${local.linux_user}" + echo "${local.linux_user} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/coder-user + fi + + # Check for Docker, install if not present + if ! command -v docker >/dev/null 2>&1; then + echo "Docker not found, installing..." + curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh >/dev/null 2>&1 + sudo usermod -aG docker ${local.linux_user} + newgrp docker + else + echo "Docker is already installed." + fi + + # Write the Docker config JSON to disk if it is provided. + if [ -n "${local.docker_config_json_base64}" ]; then + mkdir -p "/home/${local.linux_user}/.docker" + printf "%s" "${local.docker_config_json_base64}" | base64 -d | tee "/home/${local.linux_user}/.docker/config.json" + chown -R ${local.linux_user}:${local.linux_user} "/home/${local.linux_user}/.docker" + fi + + # Write the container env to disk. + printf "%s" "${local.docker_env_list_base64}" | base64 -d | tee "/home/${local.linux_user}/env.txt" + + # Start envbuilder. + docker run \ + --rm \ + --net=host \ + -h ${lower(data.coder_workspace.me.name)} \ + -v /home/${local.linux_user}/envbuilder:/workspaces \ + -v /var/run/docker.sock:/var/run/docker.sock \ + --env-file /home/${local.linux_user}/env.txt \ + ${local.builder_image} + META +} + +# Create a persistent disk to store the workspace data. +resource "google_compute_disk" "root" { + name = "coder-${data.coder_workspace.me.id}-root" + type = "pd-ssd" + image = "debian-cloud/debian-12" + lifecycle { + ignore_changes = all + } +} + +# Check for the presence of a prebuilt image in the cache repo +# that we can use instead. +resource "envbuilder_cached_image" "cached" { + count = var.cache_repo == "" ? 0 : data.coder_workspace.me.start_count + builder_image = local.devcontainer_builder_image + git_url = data.coder_parameter.repo_url.value + cache_repo = var.cache_repo + extra_env = local.envbuilder_env +} + +# This is useful for debugging the startup script. Left here for reference. +# resource local_file "startup_script" { +# content = local.startup_script +# filename = "${path.module}/startup_script.sh" +# } + +# Create a VM where the workspace will run. +resource "google_compute_instance" "vm" { + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root" + machine_type = data.coder_parameter.instance_type.value + # data.coder_workspace_owner.me.name == "default" is a workaround to suppress error in the terraform plan phase while creating a new workspace. + desired_status = (data.coder_workspace_owner.me.name == "default" || data.coder_workspace.me.start_count == 1) ? "RUNNING" : "TERMINATED" + + network_interface { + network = "default" + access_config { + // Ephemeral public IP + } + } + + boot_disk { + auto_delete = false + source = google_compute_disk.root.name + } + + service_account { + email = data.google_compute_default_service_account.default.email + scopes = ["cloud-platform"] + } + + metadata = { + # The startup script runs as root with no $HOME environment set up, so instead of directly + # running the agent init script, create a user (with a homedir, default shell and sudo + # permissions) and execute the init script as that user. + startup-script = local.startup_script + } +} + +# Create a Coder agent to manage the workspace. +resource "coder_agent" "dev" { + count = data.coder_workspace.me.start_count + arch = "amd64" + auth = "token" + os = "linux" + dir = "/workspaces/${trimsuffix(basename(data.coder_parameter.repo_url.value), ".git")}" + connection_timeout = 0 + + metadata { + key = "cpu" + display_name = "CPU Usage" + interval = 5 + timeout = 5 + script = "coder stat cpu" + } + metadata { + key = "memory" + display_name = "Memory Usage" + interval = 5 + timeout = 5 + script = "coder stat mem" + } + metadata { + key = "disk" + display_name = "Disk Usage" + interval = 5 + timeout = 5 + script = "coder stat disk" + } +} + +# See https://registry.coder.com/modules/coder/code-server +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + order = 1 +} + +# See https://registry.coder.com/modules/coder/jetbrains-gateway +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-gateway/coder" + + # JetBrains IDEs to make available for the user to select + jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] + default = "IU" + + # Default folder to open when starting a JetBrains IDE + folder = "/workspaces" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + agent_name = "main" + order = 2 +} + +# Create metadata for the workspace and home disk. +resource "coder_metadata" "workspace_info" { + count = data.coder_workspace.me.start_count + resource_id = google_compute_instance.vm.id + + item { + key = "type" + value = google_compute_instance.vm.machine_type + } + + item { + key = "zone" + value = module.gcp_region.value + } +} + +resource "coder_metadata" "home_info" { + resource_id = google_compute_disk.root.id + + item { + key = "size" + value = "${google_compute_disk.root.size} GiB" + } +} diff --git a/registry/coder/templates/gcp-linux/README.md b/registry/coder/templates/gcp-linux/README.md new file mode 100644 index 00000000..60191040 --- /dev/null +++ b/registry/coder/templates/gcp-linux/README.md @@ -0,0 +1,64 @@ +--- +display_name: Google Compute Engine (Linux) +description: Provision Google Compute Engine instances as Coder workspaces +icon: ../../../../.icons/gcp.svg +maintainer_github: coder +verified: true +tags: [vm, linux, gcp] +--- + +# Remote Development on Google Compute Engine (Linux) + +## Prerequisites + +### Authentication + +This template assumes that coderd is run in an environment that is authenticated +with Google Cloud. For example, run `gcloud auth application-default login` to +import credentials on the system and user running coderd. For other ways to +authenticate [consult the Terraform +docs](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/getting_started#adding-credentials). + +Coder requires a Google Cloud Service Account to provision workspaces. To create +a service account: + +1. Navigate to the [CGP + console](https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts/create), + and select your Cloud project (if you have more than one project associated + with your account) + +1. Provide a service account name (this name is used to generate the service + account ID) + +1. Click **Create and continue**, and choose the following IAM roles to grant to + the service account: + + - Compute Admin + - Service Account User + + Click **Continue**. + +1. Click on the created key, and navigate to the **Keys** tab. + +1. Click **Add key** > **Create new key**. + +1. Generate a **JSON private key**, which will be what you provide to Coder + during the setup process. + +## Architecture + +This template provisions the following resources: + +- GCP VM (ephemeral) +- GCP Disk (persistent, mounted to root) + +Coder persists the root volume. The full filesystem is preserved when the workspace restarts. See this [community example](https://github.com/bpmct/coder-templates/tree/main/aws-linux-ephemeral) of an ephemeral AWS instance. + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## code-server + +`code-server` is installed via the `startup_script` argument in the `coder_agent` +resource block. The `coder_app` resource is defined to access `code-server` through +the dashboard UI over `localhost:13337`. diff --git a/registry/coder/templates/gcp-linux/main.tf b/registry/coder/templates/gcp-linux/main.tf new file mode 100644 index 00000000..286db4e4 --- /dev/null +++ b/registry/coder/templates/gcp-linux/main.tf @@ -0,0 +1,184 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + google = { + source = "hashicorp/google" + } + } +} + +provider "coder" {} + +variable "project_id" { + description = "Which Google Compute Project should your workspace live in?" +} + +# See https://registry.coder.com/modules/coder/gcp-region +module "gcp_region" { + source = "registry.coder.com/coder/gcp-region/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + regions = ["us", "europe"] + default = "us-central1-a" +} + +provider "google" { + zone = module.gcp_region.value + project = var.project_id +} + +data "google_compute_default_service_account" "default" {} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "google_compute_disk" "root" { + name = "coder-${data.coder_workspace.me.id}-root" + type = "pd-ssd" + zone = module.gcp_region.value + image = "debian-cloud/debian-11" + lifecycle { + ignore_changes = [name, image] + } +} + +resource "coder_agent" "main" { + auth = "google-instance-identity" + arch = "amd64" + os = "linux" + startup_script = <<-EOT + set -e + + # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + EOT + + metadata { + key = "cpu" + display_name = "CPU Usage" + interval = 5 + timeout = 5 + script = <<-EOT + #!/bin/bash + set -e + top -bn1 | grep "Cpu(s)" | awk '{print $2 + $4 "%"}' + EOT + } + metadata { + key = "memory" + display_name = "Memory Usage" + interval = 5 + timeout = 5 + script = <<-EOT + #!/bin/bash + set -e + free -m | awk 'NR==2{printf "%.2f%%\t", $3*100/$2 }' + EOT + } + metadata { + key = "disk" + display_name = "Disk Usage" + interval = 600 # every 10 minutes + timeout = 30 # df can take a while on large filesystems + script = <<-EOT + #!/bin/bash + set -e + df /home/coder | awk '$NF=="/"{printf "%s", $5}' + EOT + } +} + +# See https://registry.coder.com/modules/coder/code-server +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + order = 1 +} + +# See https://registry.coder.com/modules/coder/jetbrains-gateway +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-gateway/coder" + + # JetBrains IDEs to make available for the user to select + jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] + default = "IU" + + # Default folder to open when starting a JetBrains IDE + folder = "/home/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + agent_name = "main" + order = 2 +} + +resource "google_compute_instance" "dev" { + zone = module.gcp_region.value + count = data.coder_workspace.me.start_count + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root" + machine_type = "e2-medium" + network_interface { + network = "default" + access_config { + // Ephemeral public IP + } + } + boot_disk { + auto_delete = false + source = google_compute_disk.root.name + } + service_account { + email = data.google_compute_default_service_account.default.email + scopes = ["cloud-platform"] + } + # The startup script runs as root with no $HOME environment set up, so instead of directly + # running the agent init script, create a user (with a homedir, default shell and sudo + # permissions) and execute the init script as that user. + metadata_startup_script = </dev/null 2>&1; then + useradd -m -s /bin/bash "${local.linux_user}" + echo "${local.linux_user} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/coder-user +fi + +exec sudo -u "${local.linux_user}" sh -c '${coder_agent.main.init_script}' +EOMETA +} + +locals { + # Ensure Coder username is a valid Linux username + linux_user = lower(substr(data.coder_workspace_owner.me.name, 0, 32)) +} + +resource "coder_metadata" "workspace_info" { + count = data.coder_workspace.me.start_count + resource_id = google_compute_instance.dev[0].id + + item { + key = "type" + value = google_compute_instance.dev[0].machine_type + } +} + +resource "coder_metadata" "home_info" { + resource_id = google_compute_disk.root.id + + item { + key = "size" + value = "${google_compute_disk.root.size} GiB" + } +} diff --git a/registry/coder/templates/gcp-vm-container/README.md b/registry/coder/templates/gcp-vm-container/README.md new file mode 100644 index 00000000..83704ee2 --- /dev/null +++ b/registry/coder/templates/gcp-vm-container/README.md @@ -0,0 +1,65 @@ +--- +display_name: Google Compute Engine (VM Container) +description: Provision Google Compute Engine instances as Coder workspaces +icon: ../../../../.icons/gcp.svg +maintainer_github: coder +verified: true +tags: [vm-container, linux, gcp] +--- + +# Remote Development on Google Compute Engine (VM Container) + +## Prerequisites + +### Authentication + +This template assumes that coderd is run in an environment that is authenticated +with Google Cloud. For example, run `gcloud auth application-default login` to +import credentials on the system and user running coderd. For other ways to +authenticate [consult the Terraform +docs](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/getting_started#adding-credentials). + +Coder requires a Google Cloud Service Account to provision workspaces. To create +a service account: + +1. Navigate to the [CGP + console](https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts/create), + and select your Cloud project (if you have more than one project associated + with your account) + +1. Provide a service account name (this name is used to generate the service + account ID) + +1. Click **Create and continue**, and choose the following IAM roles to grant to + the service account: + + - Compute Admin + - Service Account User + + Click **Continue**. + +1. Click on the created key, and navigate to the **Keys** tab. + +1. Click **Add key** > **Create new key**. + +1. Generate a **JSON private key**, which will be what you provide to Coder + during the setup process. + +## Architecture + +This template provisions the following resources: + +- GCP VM (ephemeral, deleted on stop) + - Container in VM +- Managed disk (persistent, mounted to `/home/coder` in container) + +This means, when the workspace restarts, any tools or files outside of the home directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the container image, or use a [startup script](https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script). + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## code-server + +`code-server` is installed via the `startup_script` argument in the `coder_agent` +resource block. The `coder_app` resource is defined to access `code-server` through +the dashboard UI over `localhost:13337`. diff --git a/registry/coder/templates/gcp-vm-container/main.tf b/registry/coder/templates/gcp-vm-container/main.tf new file mode 100644 index 00000000..b259b4b2 --- /dev/null +++ b/registry/coder/templates/gcp-vm-container/main.tf @@ -0,0 +1,136 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + google = { + source = "hashicorp/google" + } + } +} + +provider "coder" {} + +variable "project_id" { + description = "Which Google Compute Project should your workspace live in?" +} + +# https://registry.coder.com/modules/coder/gcp-region/coder +module "gcp_region" { + source = "registry.coder.com/coder/gcp-region/coder" + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + regions = ["us", "europe"] +} + +provider "google" { + zone = module.gcp_region.value + project = var.project_id +} + +data "google_compute_default_service_account" "default" {} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + auth = "google-instance-identity" + arch = "amd64" + os = "linux" + startup_script = <<-EOT + set -e + + # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + EOT +} + +# See https://registry.coder.com/modules/coder/code-server +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + order = 1 +} + +# See https://registry.coder.com/modules/coder/jetbrains-gateway +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains-gateway/coder" + + # JetBrains IDEs to make available for the user to select + jetbrains_ides = ["IU", "PY", "WS", "PS", "RD", "CL", "GO", "RM"] + default = "IU" + + # Default folder to open when starting a JetBrains IDE + folder = "/home/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + agent_id = coder_agent.main.id + agent_name = "main" + order = 2 +} + +# See https://registry.terraform.io/modules/terraform-google-modules/container-vm +module "gce-container" { + source = "terraform-google-modules/container-vm/google" + version = "3.0.0" + + container = { + image = "codercom/enterprise-base:ubuntu" + command = ["sh"] + args = ["-c", coder_agent.main.init_script] + securityContext = { + privileged : true + } + } +} + +resource "google_compute_instance" "dev" { + zone = module.gcp_region.value + count = data.coder_workspace.me.start_count + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}" + machine_type = "e2-medium" + network_interface { + network = "default" + access_config { + // Ephemeral public IP + } + } + boot_disk { + initialize_params { + image = module.gce-container.source_image + } + } + service_account { + email = data.google_compute_default_service_account.default.email + scopes = ["cloud-platform"] + } + metadata = { + "gce-container-declaration" = module.gce-container.metadata_value + } + labels = { + container-vm = module.gce-container.vm_container_label + } +} + +resource "coder_agent_instance" "dev" { + count = data.coder_workspace.me.start_count + agent_id = coder_agent.main.id + instance_id = google_compute_instance.dev[0].instance_id +} + +resource "coder_metadata" "workspace_info" { + count = data.coder_workspace.me.start_count + resource_id = google_compute_instance.dev[0].id + + item { + key = "image" + value = module.gce-container.container.image + } +} diff --git a/registry/coder/templates/gcp-windows/README.md b/registry/coder/templates/gcp-windows/README.md new file mode 100644 index 00000000..ac717e41 --- /dev/null +++ b/registry/coder/templates/gcp-windows/README.md @@ -0,0 +1,64 @@ +--- +display_name: Google Compute Engine (Windows) +description: Provision Google Compute Engine instances as Coder workspaces +icon: ../../../../.icons/gcp.svg +maintainer_github: coder +verified: true +tags: [vm, windows, gcp] +--- + +# Remote Development on Google Compute Engine (Windows) + +## Prerequisites + +### Authentication + +This template assumes that coderd is run in an environment that is authenticated +with Google Cloud. For example, run `gcloud auth application-default login` to +import credentials on the system and user running coderd. For other ways to +authenticate [consult the Terraform +docs](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/getting_started#adding-credentials). + +Coder requires a Google Cloud Service Account to provision workspaces. To create +a service account: + +1. Navigate to the [CGP + console](https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts/create), + and select your Cloud project (if you have more than one project associated + with your account) + +1. Provide a service account name (this name is used to generate the service + account ID) + +1. Click **Create and continue**, and choose the following IAM roles to grant to + the service account: + + - Compute Admin + - Service Account User + + Click **Continue**. + +1. Click on the created key, and navigate to the **Keys** tab. + +1. Click **Add key** > **Create new key**. + +1. Generate a **JSON private key**, which will be what you provide to Coder + during the setup process. + +## Architecture + +This template provisions the following resources: + +- GCP VM (ephemeral) +- GCP Disk (persistent, mounted to root) + +Coder persists the root volume. The full filesystem is preserved when the workspace restarts. See this [community example](https://github.com/bpmct/coder-templates/tree/main/aws-linux-ephemeral) of an ephemeral AWS instance. + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## code-server + +`code-server` is installed via the `startup_script` argument in the `coder_agent` +resource block. The `coder_app` resource is defined to access `code-server` through +the dashboard UI over `localhost:13337`. diff --git a/registry/coder/templates/gcp-windows/main.tf b/registry/coder/templates/gcp-windows/main.tf new file mode 100644 index 00000000..aea409ee --- /dev/null +++ b/registry/coder/templates/gcp-windows/main.tf @@ -0,0 +1,96 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + google = { + source = "hashicorp/google" + } + } +} + +provider "coder" {} + +variable "project_id" { + description = "Which Google Compute Project should your workspace live in?" +} + +# See https://registry.coder.com/modules/coder/gcp-region +module "gcp_region" { + source = "registry.coder.com/coder/gcp-region/coder" + + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + + regions = ["us", "europe"] + default = "us-central1-a" +} + +provider "google" { + zone = module.gcp_region.value + project = var.project_id +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "google_compute_default_service_account" "default" {} + +resource "google_compute_disk" "root" { + name = "coder-${data.coder_workspace.me.id}-root" + type = "pd-ssd" + zone = module.gcp_region.value + image = "projects/windows-cloud/global/images/windows-server-2022-dc-core-v20220215" + lifecycle { + ignore_changes = [name, image] + } +} + +resource "coder_agent" "main" { + auth = "google-instance-identity" + arch = "amd64" + os = "windows" +} + +resource "google_compute_instance" "dev" { + zone = module.gcp_region.value + count = data.coder_workspace.me.start_count + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}" + machine_type = "e2-medium" + network_interface { + network = "default" + access_config { + // Ephemeral public IP + } + } + boot_disk { + auto_delete = false + source = google_compute_disk.root.name + } + service_account { + email = data.google_compute_default_service_account.default.email + scopes = ["cloud-platform"] + } + metadata = { + windows-startup-script-ps1 = coder_agent.main.init_script + serial-port-enable = "TRUE" + } +} +resource "coder_metadata" "workspace_info" { + count = data.coder_workspace.me.start_count + resource_id = google_compute_instance.dev[0].id + + item { + key = "type" + value = google_compute_instance.dev[0].machine_type + } +} + +resource "coder_metadata" "home_info" { + resource_id = google_compute_disk.root.id + + item { + key = "size" + value = "${google_compute_disk.root.size} GiB" + } +} diff --git a/registry/coder/templates/incus/README.md b/registry/coder/templates/incus/README.md new file mode 100644 index 00000000..def594cd --- /dev/null +++ b/registry/coder/templates/incus/README.md @@ -0,0 +1,51 @@ +--- +display_name: Incus System Container with Docker +description: Develop in an Incus System Container with Docker using incus +icon: ../../../../.icons/lxc.svg +maintainer_github: coder +verified: true +tags: [local, incus, lxc, lxd] +--- + +# Incus System Container with Docker + +Develop in an Incus System Container and run nested Docker containers using Incus on your local infrastructure. + +## Prerequisites + +1. Install [Incus](https://linuxcontainers.org/incus/) on the same machine as Coder. +2. Allow Coder to access the Incus socket. + + - If you're running Coder as system service, run `sudo usermod -aG incus-admin coder` and restart the Coder service. + - If you're running Coder as a Docker Compose service, get the group ID of the `incus-admin` group by running `getent group incus-admin` and add the following to your `compose.yaml` file: + + ```yaml + services: + coder: + volumes: + - /var/lib/incus/unix.socket:/var/lib/incus/unix.socket + group_add: + - 996 # Replace with the group ID of the `incus-admin` group + ``` + +3. Create a storage pool named `coder` and `btrfs` as the driver by running `incus storage create coder btrfs`. + +## Usage + +> **Note:** this template requires using a container image with cloud-init installed such as `ubuntu/jammy/cloud/amd64`. + +1. Run `coder templates init -id incus` +1. Select this template +1. Follow the on-screen instructions + +## Extending this template + +See the [lxc/incus](https://registry.terraform.io/providers/lxc/incus/latest/docs) Terraform provider documentation to +add the following features to your Coder template: + +- HTTPS incus host +- Volume mounts +- Custom networks +- More + +We also welcome contributions! diff --git a/registry/coder/templates/incus/main.tf b/registry/coder/templates/incus/main.tf new file mode 100644 index 00000000..95e10a6d --- /dev/null +++ b/registry/coder/templates/incus/main.tf @@ -0,0 +1,317 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + incus = { + source = "lxc/incus" + } + } +} + +data "coder_provisioner" "me" {} + +provider "incus" {} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "image" { + name = "image" + display_name = "Image" + description = "The container image to use. Be sure to use a variant with cloud-init installed!" + default = "ubuntu/jammy/cloud/amd64" + icon = "/icon/image.svg" + mutable = true +} + +data "coder_parameter" "cpu" { + name = "cpu" + display_name = "CPU" + description = "The number of CPUs to allocate to the workspace (1-8)" + type = "number" + default = "1" + icon = "https://raw.githubusercontent.com/matifali/logos/main/cpu-3.svg" + mutable = true + validation { + min = 1 + max = 8 + } +} + +data "coder_parameter" "memory" { + name = "memory" + display_name = "Memory" + description = "The amount of memory to allocate to the workspace in GB (up to 16GB)" + type = "number" + default = "2" + icon = "/icon/memory.svg" + mutable = true + validation { + min = 1 + max = 16 + } +} + +data "coder_parameter" "git_repo" { + type = "string" + name = "Git repository" + default = "https://github.com/coder/coder" + description = "Clone a git repo into [base directory]" + mutable = true +} + +data "coder_parameter" "repo_base_dir" { + type = "string" + name = "Repository Base Directory" + default = "~" + description = "The directory specified will be created (if missing) and the specified repo will be cloned into [base directory]/{repo}🪄." + mutable = true +} + +resource "coder_agent" "main" { + count = data.coder_workspace.me.start_count + arch = data.coder_provisioner.me.arch + os = "linux" + dir = "/home/${local.workspace_user}" + env = { + CODER_WORKSPACE_ID = data.coder_workspace.me.id + } + + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path /home/${lower(data.coder_workspace_owner.me.name)}" + interval = 60 + timeout = 1 + } +} + +# https://registry.coder.com/modules/coder/git-clone +module "git-clone" { + source = "registry.coder.com/coder/git-clone/coder" + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + agent_id = local.agent_id + url = data.coder_parameter.git_repo.value + base_dir = local.repo_base_dir +} + +# https://registry.coder.com/modules/coder/code-server +module "code-server" { + source = "registry.coder.com/coder/code-server/coder" + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + agent_id = local.agent_id + folder = local.repo_base_dir +} + +# https://registry.coder.com/modules/coder/filebrowser +module "filebrowser" { + source = "registry.coder.com/coder/filebrowser/coder" + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + agent_id = local.agent_id +} + +# https://registry.coder.com/modules/coder/coder-login +module "coder-login" { + source = "registry.coder.com/coder/coder-login/coder" + # This ensures that the latest non-breaking version of the module gets downloaded, you can also pin the module version to prevent breaking changes in production. + version = "~> 1.0" + agent_id = local.agent_id +} + +resource "incus_volume" "home" { + name = "coder-${data.coder_workspace.me.id}-home" + pool = local.pool +} + +resource "incus_volume" "docker" { + name = "coder-${data.coder_workspace.me.id}-docker" + pool = local.pool +} + +resource "incus_cached_image" "image" { + source_remote = "images" + source_image = data.coder_parameter.image.value +} + +resource "incus_instance_file" "agent_token" { + count = data.coder_workspace.me.start_count + instance = incus_instance.dev.name + content = < **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## Caching + +To speed up your builds, you can use a container registry as a cache. +When creating the template, set the parameter `cache_repo`. + +See the [Envbuilder Terraform Provider Examples](https://github.com/coder/terraform-provider-envbuilder/blob/main/examples/resources/envbuilder_cached_image/envbuilder_cached_image_resource.tf/) for a more complete example of how the provider works. + +> [!NOTE] +> We recommend using a registry cache with authentication enabled. +> To allow Envbuilder to authenticate with the registry cache, specify the variable `cache_repo_dockerconfig_secret` +> with the name of a Kubernetes secret in the same namespace as Coder. The secret must contain the key `.dockerconfigjson`. diff --git a/registry/coder/templates/kubernetes-devcontainer/main.tf b/registry/coder/templates/kubernetes-devcontainer/main.tf new file mode 100644 index 00000000..8fc79fa2 --- /dev/null +++ b/registry/coder/templates/kubernetes-devcontainer/main.tf @@ -0,0 +1,464 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 2.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + } + envbuilder = { + source = "coder/envbuilder" + } + } +} + +provider "coder" {} +provider "kubernetes" { + # Authenticate via ~/.kube/config or a Coder-specific ServiceAccount, depending on admin preferences + config_path = var.use_kubeconfig == true ? "~/.kube/config" : null +} +provider "envbuilder" {} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +variable "use_kubeconfig" { + type = bool + description = <<-EOF + Use host kubeconfig? (true/false) + + Set this to false if the Coder host is itself running as a Pod on the same + Kubernetes cluster as you are deploying workspaces to. + + Set this to true if the Coder host is running outside the Kubernetes cluster + for workspaces. A valid "~/.kube/config" must be present on the Coder host. + EOF + default = false +} + +variable "namespace" { + type = string + default = "default" + description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces). If the Coder host is itself running as a Pod on the same Kubernetes cluster as you are deploying workspaces to, set this to the same namespace." +} + +variable "cache_repo" { + default = "" + description = "Use a container registry as a cache to speed up builds." + type = string +} + +variable "insecure_cache_repo" { + default = false + description = "Enable this option if your cache registry does not serve HTTPS." + type = bool +} + +data "coder_parameter" "cpu" { + type = "number" + name = "cpu" + display_name = "CPU" + description = "CPU limit (cores)." + default = "2" + icon = "/emojis/1f5a5.png" + mutable = true + validation { + min = 1 + max = 99999 + } + order = 1 +} + +data "coder_parameter" "memory" { + type = "number" + name = "memory" + display_name = "Memory" + description = "Memory limit (GiB)." + default = "2" + icon = "/icon/memory.svg" + mutable = true + validation { + min = 1 + max = 99999 + } + order = 2 +} + +data "coder_parameter" "workspaces_volume_size" { + name = "workspaces_volume_size" + display_name = "Workspaces volume size" + description = "Size of the `/workspaces` volume (GiB)." + default = "10" + type = "number" + icon = "/emojis/1f4be.png" + mutable = false + validation { + min = 1 + max = 99999 + } + order = 3 +} + +data "coder_parameter" "repo" { + description = "Select a repository to automatically clone and start working with a devcontainer." + display_name = "Repository (auto)" + mutable = true + name = "repo" + order = 4 + type = "string" +} + +data "coder_parameter" "fallback_image" { + default = "codercom/enterprise-base:ubuntu" + description = "This image runs if the devcontainer fails to build." + display_name = "Fallback Image" + mutable = true + name = "fallback_image" + order = 6 +} + +data "coder_parameter" "devcontainer_builder" { + description = <<-EOF +Image that will build the devcontainer. +We highly recommend using a specific release as the `:latest` tag will change. +Find the latest version of Envbuilder here: https://github.com/coder/envbuilder/pkgs/container/envbuilder +EOF + display_name = "Devcontainer Builder" + mutable = true + name = "devcontainer_builder" + default = "ghcr.io/coder/envbuilder:latest" + order = 7 +} + +variable "cache_repo_secret_name" { + default = "" + description = "Path to a docker config.json containing credentials to the provided cache repo, if required." + sensitive = true + type = string +} + +data "kubernetes_secret" "cache_repo_dockerconfig_secret" { + count = var.cache_repo_secret_name == "" ? 0 : 1 + metadata { + name = var.cache_repo_secret_name + namespace = var.namespace + } +} + +locals { + deployment_name = "coder-${lower(data.coder_workspace.me.id)}" + devcontainer_builder_image = data.coder_parameter.devcontainer_builder.value + git_author_name = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + git_author_email = data.coder_workspace_owner.me.email + repo_url = data.coder_parameter.repo.value + # The envbuilder provider requires a key-value map of environment variables. + envbuilder_env = { + "CODER_AGENT_TOKEN" : coder_agent.main.token, + # Use the docker gateway if the access URL is 127.0.0.1 + "CODER_AGENT_URL" : replace(data.coder_workspace.me.access_url, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), + # ENVBUILDER_GIT_URL and ENVBUILDER_CACHE_REPO will be overridden by the provider + # if the cache repo is enabled. + "ENVBUILDER_GIT_URL" : var.cache_repo == "" ? local.repo_url : "", + # Use the docker gateway if the access URL is 127.0.0.1 + "ENVBUILDER_INIT_SCRIPT" : replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal"), + "ENVBUILDER_FALLBACK_IMAGE" : data.coder_parameter.fallback_image.value, + "ENVBUILDER_DOCKER_CONFIG_BASE64" : base64encode(try(data.kubernetes_secret.cache_repo_dockerconfig_secret[0].data[".dockerconfigjson"], "")), + "ENVBUILDER_PUSH_IMAGE" : var.cache_repo == "" ? "" : "true" + # You may need to adjust this if you get an error regarding deleting files when building the workspace. + # For example, when testing in KinD, it was necessary to set `/product_name` and `/product_uuid` in + # addition to `/var/run`. + # "ENVBUILDER_IGNORE_PATHS": "/product_name,/product_uuid,/var/run", + } +} + +# Check for the presence of a prebuilt image in the cache repo +# that we can use instead. +resource "envbuilder_cached_image" "cached" { + count = var.cache_repo == "" ? 0 : data.coder_workspace.me.start_count + builder_image = local.devcontainer_builder_image + git_url = local.repo_url + cache_repo = var.cache_repo + extra_env = local.envbuilder_env + insecure = var.insecure_cache_repo +} + +resource "kubernetes_persistent_volume_claim" "workspaces" { + metadata { + name = "coder-${lower(data.coder_workspace.me.id)}-workspaces" + namespace = var.namespace + labels = { + "app.kubernetes.io/name" = "coder-${lower(data.coder_workspace.me.id)}-workspaces" + "app.kubernetes.io/instance" = "coder-${lower(data.coder_workspace.me.id)}-workspaces" + "app.kubernetes.io/part-of" = "coder" + //Coder-specific labels. + "com.coder.resource" = "true" + "com.coder.workspace.id" = data.coder_workspace.me.id + "com.coder.workspace.name" = data.coder_workspace.me.name + "com.coder.user.id" = data.coder_workspace_owner.me.id + "com.coder.user.username" = data.coder_workspace_owner.me.name + } + annotations = { + "com.coder.user.email" = data.coder_workspace_owner.me.email + } + } + wait_until_bound = false + spec { + access_modes = ["ReadWriteOnce"] + resources { + requests = { + storage = "${data.coder_parameter.workspaces_volume_size.value}Gi" + } + } + # storage_class_name = "local-path" # Configure the StorageClass to use here, if required. + } +} + +resource "kubernetes_deployment" "main" { + count = data.coder_workspace.me.start_count + depends_on = [ + kubernetes_persistent_volume_claim.workspaces + ] + wait_for_rollout = false + metadata { + name = local.deployment_name + namespace = var.namespace + labels = { + "app.kubernetes.io/name" = "coder-workspace" + "app.kubernetes.io/instance" = local.deployment_name + "app.kubernetes.io/part-of" = "coder" + "com.coder.resource" = "true" + "com.coder.workspace.id" = data.coder_workspace.me.id + "com.coder.workspace.name" = data.coder_workspace.me.name + "com.coder.user.id" = data.coder_workspace_owner.me.id + "com.coder.user.username" = data.coder_workspace_owner.me.name + } + annotations = { + "com.coder.user.email" = data.coder_workspace_owner.me.email + } + } + + spec { + replicas = 1 + selector { + match_labels = { + "app.kubernetes.io/name" = "coder-workspace" + } + } + strategy { + type = "Recreate" + } + + template { + metadata { + labels = { + "app.kubernetes.io/name" = "coder-workspace" + } + } + spec { + security_context {} + + container { + name = "dev" + image = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image + image_pull_policy = "Always" + security_context {} + + # Set the environment using cached_image.cached.0.env if the cache repo is enabled. + # Otherwise, use the local.envbuilder_env. + # You could alternatively write the environment variables to a ConfigMap or Secret + # and use that as `env_from`. + dynamic "env" { + for_each = nonsensitive(var.cache_repo == "" ? local.envbuilder_env : envbuilder_cached_image.cached.0.env_map) + content { + name = env.key + value = env.value + } + } + + resources { + requests = { + "cpu" = "250m" + "memory" = "512Mi" + } + limits = { + "cpu" = "${data.coder_parameter.cpu.value}" + "memory" = "${data.coder_parameter.memory.value}Gi" + } + } + volume_mount { + mount_path = "/workspaces" + name = "workspaces" + read_only = false + } + } + + volume { + name = "workspaces" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.workspaces.metadata.0.name + read_only = false + } + } + + affinity { + // This affinity attempts to spread out all workspace pods evenly across + // nodes. + pod_anti_affinity { + preferred_during_scheduling_ignored_during_execution { + weight = 1 + pod_affinity_term { + topology_key = "kubernetes.io/hostname" + label_selector { + match_expressions { + key = "app.kubernetes.io/name" + operator = "In" + values = ["coder-workspace"] + } + } + } + } + } + } + } + } + } +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + set -e + + # Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here + EOT + dir = "/workspaces" + + # These environment variables allow you to make Git commits right away after creating a + # workspace. Note that they take precedence over configuration defined in ~/.gitconfig! + # You can remove this block if you'd prefer to configure Git manually or using + # dotfiles. (see docs/dotfiles.md) + env = { + GIT_AUTHOR_NAME = local.git_author_name + GIT_AUTHOR_EMAIL = local.git_author_email + GIT_COMMITTER_NAME = local.git_author_name + GIT_COMMITTER_EMAIL = local.git_author_email + } + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Workspaces Disk" + key = "3_workspaces_disk" + script = "coder stat disk --path /workspaces" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = < + +## Prerequisites + +### Infrastructure + +**Cluster**: This template requires an existing Kubernetes cluster + +**Container Image**: This template uses the [codercom/enterprise-base:ubuntu image](https://github.com/coder/enterprise-images/tree/main/images/base) with some dev tools preinstalled. To add additional tools, extend this image or build it yourself. + +### Authentication + +This template authenticates using a `~/.kube/config`, if present on the server, or via built-in authentication if the Coder provisioner is running on Kubernetes with an authorized ServiceAccount. To use another [authentication method](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs#authentication), edit the template. + +## Architecture + +This template provisions the following resources: + +- Kubernetes pod (ephemeral) +- Kubernetes persistent volume claim (persistent on `/home/coder`) + +This means, when the workspace restarts, any tools or files outside of the home directory are not persisted. To pre-bake tools into the workspace (e.g. `python3`), modify the container image. Alternatively, individual developers can [personalize](https://coder.com/docs/dotfiles) their workspaces with dotfiles. + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. diff --git a/registry/coder/templates/kubernetes/main.tf b/registry/coder/templates/kubernetes/main.tf new file mode 100644 index 00000000..e1fdb12c --- /dev/null +++ b/registry/coder/templates/kubernetes/main.tf @@ -0,0 +1,345 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + kubernetes = { + source = "hashicorp/kubernetes" + } + } +} + +provider "coder" { +} + +variable "use_kubeconfig" { + type = bool + description = <<-EOF + Use host kubeconfig? (true/false) + + Set this to false if the Coder host is itself running as a Pod on the same + Kubernetes cluster as you are deploying workspaces to. + + Set this to true if the Coder host is running outside the Kubernetes cluster + for workspaces. A valid "~/.kube/config" must be present on the Coder host. + EOF + default = false +} + +variable "namespace" { + type = string + description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces). If the Coder host is itself running as a Pod on the same Kubernetes cluster as you are deploying workspaces to, set this to the same namespace." +} + +data "coder_parameter" "cpu" { + name = "cpu" + display_name = "CPU" + description = "The number of CPU cores" + default = "2" + icon = "/icon/memory.svg" + mutable = true + option { + name = "2 Cores" + value = "2" + } + option { + name = "4 Cores" + value = "4" + } + option { + name = "6 Cores" + value = "6" + } + option { + name = "8 Cores" + value = "8" + } +} + +data "coder_parameter" "memory" { + name = "memory" + display_name = "Memory" + description = "The amount of memory in GB" + default = "2" + icon = "/icon/memory.svg" + mutable = true + option { + name = "2 GB" + value = "2" + } + option { + name = "4 GB" + value = "4" + } + option { + name = "6 GB" + value = "6" + } + option { + name = "8 GB" + value = "8" + } +} + +data "coder_parameter" "home_disk_size" { + name = "home_disk_size" + display_name = "Home disk size" + description = "The size of the home disk in GB" + default = "10" + type = "number" + icon = "/emojis/1f4be.png" + mutable = false + validation { + min = 1 + max = 99999 + } +} + +provider "kubernetes" { + # Authenticate via ~/.kube/config or a Coder-specific ServiceAccount, depending on admin preferences + config_path = var.use_kubeconfig == true ? "~/.kube/config" : null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + os = "linux" + arch = "amd64" + startup_script = <<-EOT + set -e + + # Install the latest code-server. + # Append "--version x.x.x" to install a specific version of code-server. + curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server + + # Start code-server in the background. + /tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 & + EOT + + # The following metadata blocks are optional. They are used to display + # information about your workspace in the dashboard. You can remove them + # if you don't want to display any information. + # For basic resources, you can use the `coder stat` command. + # If you need more control, you can write your own script. + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } + + metadata { + display_name = "CPU Usage (Host)" + key = "4_cpu_usage_host" + script = "coder stat cpu --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Memory Usage (Host)" + key = "5_mem_usage_host" + script = "coder stat mem --host" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Load Average (Host)" + key = "6_load_host" + # get load avg scaled by number of cores + script = < + +> **Note** +> This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case. + +## Prerequisites + +- [Nomad](https://www.nomadproject.io/downloads) +- [Docker](https://docs.docker.com/get-docker/) + +## Setup + +### 1. Start the CSI Host Volume Plugin + +The CSI Host Volume plugin is used to mount host volumes into Nomad tasks. This is useful for development environments where you want to mount persistent volumes into your container workspace. + +1. Login to the Nomad server using SSH. + +2. Append the following stanza to your Nomad server configuration file and restart the nomad service. + + ```tf + plugin "docker" { + config { + allow_privileged = true + } + } + ``` + + ```shell + sudo systemctl restart nomad + ``` + +3. Create a file `hostpath.nomad` with following content: + + ```tf + job "hostpath-csi-plugin" { + datacenters = ["dc1"] + type = "system" + + group "csi" { + task "plugin" { + driver = "docker" + + config { + image = "registry.k8s.io/sig-storage/hostpathplugin:v1.10.0" + + args = [ + "--drivername=csi-hostpath", + "--v=5", + "--endpoint=${CSI_ENDPOINT}", + "--nodeid=node-${NOMAD_ALLOC_INDEX}", + ] + + privileged = true + } + + csi_plugin { + id = "hostpath" + type = "monolith" + mount_dir = "/csi" + } + + resources { + cpu = 256 + memory = 128 + } + } + } + } + ``` + +4. Run the job: + + ```shell + nomad job run hostpath.nomad + ``` + +### 2. Setup the Nomad Template + +1. Create the template by running the following command: + + ```shell + coder template init nomad-docker + cd nomad-docker + coder template push + ``` + +2. Set up Nomad server address and optional authentication: + +3. Create a new workspace and start developing. diff --git a/registry/coder/templates/nomad-docker/main.tf b/registry/coder/templates/nomad-docker/main.tf new file mode 100644 index 00000000..9fc50893 --- /dev/null +++ b/registry/coder/templates/nomad-docker/main.tf @@ -0,0 +1,193 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + nomad = { + source = "hashicorp/nomad" + } + } +} + +variable "nomad_provider_address" { + type = string + description = "Nomad provider address. e.g., http://IP:PORT" + default = "http://localhost:4646" +} + +variable "nomad_provider_http_auth" { + type = string + description = "Nomad provider http_auth in the form of `user:password`" + sensitive = true + default = "" +} + +provider "coder" {} + +provider "nomad" { + address = var.nomad_provider_address + http_auth = var.nomad_provider_http_auth == "" ? null : var.nomad_provider_http_auth + + # Fix reading the NOMAD_NAMESPACE and the NOMAD_REGION env var from the coder's allocation. + ignore_env_vars = { + "NOMAD_NAMESPACE" = true + "NOMAD_REGION" = true + } +} + +data "coder_parameter" "cpu" { + name = "cpu" + display_name = "CPU" + description = "The number of CPU cores" + default = "1" + icon = "/icon/memory.svg" + mutable = true + option { + name = "1 Cores" + value = "1" + } + option { + name = "2 Cores" + value = "2" + } + option { + name = "3 Cores" + value = "3" + } + option { + name = "4 Cores" + value = "4" + } +} + +data "coder_parameter" "memory" { + name = "memory" + display_name = "Memory" + description = "The amount of memory in GB" + default = "2" + icon = "/icon/memory.svg" + mutable = true + option { + name = "2 GB" + value = "2" + } + option { + name = "4 GB" + value = "4" + } + option { + name = "6 GB" + value = "6" + } + option { + name = "8 GB" + value = "8" + } +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + os = "linux" + arch = "amd64" + startup_script = <<-EOT + set -e + # install and start code-server + curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server + /tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 & + EOT + + metadata { + display_name = "Load Average (Host)" + key = "load_host" + # get load avg scaled by number of cores + script = < { + const proc = spawn(["docker", "rm", "-f", id], { + stderr: "pipe", + stdout: "pipe", + }); + const exitCode = await proc.exited; + const [stderr, stdout] = await Promise.all([ + readableStreamToText(proc.stderr ?? new ReadableStream()), + readableStreamToText(proc.stdout ?? new ReadableStream()), + ]); + if (exitCode !== 0) { + throw new Error(`${stderr}\n${stdout}`); + } +}; + export interface scriptOutput { exitCode: number; stdout: string[]; @@ -279,10 +294,33 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => { }; 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); + await writeFileContainer(id, "/usr/bin/coder", script, { + user: "root", + }); + const execResult = await execContainer( + id, + ["chmod", "755", "/usr/bin/coder"], + ["--user", "root"], + ); + expect(execResult.exitCode).toBe(0); +}; + +export const writeFileContainer = async ( + id: string, + path: string, + content: string, + options?: { + user?: string; + }, +) => { + const contentBase64 = Buffer.from(content).toString("base64"); + const proc = await execContainer( + id, + ["sh", "-c", `echo '${contentBase64}' | base64 -d > '${path}'`], + options?.user ? ["--user", options.user] : undefined, + ); + if (proc.exitCode !== 0) { + throw new Error(`Failed to write file: ${proc.stderr}`); + } + expect(proc.exitCode).toBe(0); };