Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 677318c8b4 | |||
| 1916bade3e | |||
| c053b891fc | |||
| 1fcfcab8dd | |||
| f24946503f | |||
| e950669f93 | |||
| 3d78f5e262 | |||
| 960ec18d35 | |||
| eae64160bd | |||
| b58bfebcf3 | |||
| 05124309ee | |||
| 6d1e99d6ae | |||
| 01b70dcbaa | |||
| e54ceb3b92 | |||
| f5bf6687e7 | |||
| 7cf60c4e59 | |||
| 1469373a50 | |||
| 1551c17413 | |||
| eac3e55537 | |||
| e9870049bb | |||
| c301da7e6b | |||
| 7d64e7ea84 | |||
| b5937c06a9 | |||
| d2b91ae1a8 | |||
| bd05d06a3b | |||
| e340affe95 | |||
| 16892d806e | |||
| 056f4b5a68 | |||
| 1c99c57b6e | |||
| a0c1a051ed | |||
| cc40d6c355 | |||
| 87310838d4 | |||
| 9e7ce393c5 | |||
| 13a25ff4af |
@@ -0,0 +1,39 @@
|
||||
## Description
|
||||
|
||||
<!-- Briefly describe what this PR does and why -->
|
||||
|
||||
---
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] New module
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature/enhancement
|
||||
- [ ] Documentation
|
||||
- [ ] Other
|
||||
|
||||
---
|
||||
|
||||
## Module Information
|
||||
|
||||
<!-- Delete this section if not applicable -->
|
||||
|
||||
**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
|
||||
|
||||
<!-- Link related issues or write "None" if not applicable -->
|
||||
|
||||
Closes #
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Version Bump Script
|
||||
# Usage: ./version-bump.sh <bump_type> [base_ref]
|
||||
# bump_type: patch, minor, or major
|
||||
# base_ref: base reference for diff (default: origin/main)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <bump_type> [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"
|
||||
|
||||
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 "$@"
|
||||
@@ -1,4 +1,7 @@
|
||||
[default.extend-words]
|
||||
muc = "muc" # For Munich location code
|
||||
Hashi = "Hashi"
|
||||
HashiCorp = "HashiCorp"
|
||||
HashiCorp = "HashiCorp"
|
||||
|
||||
[files]
|
||||
extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive
|
||||
@@ -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:
|
||||
|
||||
@@ -2,8 +2,6 @@ name: deploy-registry
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
# Matches release/<namespace>/<resource_name>/<semantic_version>
|
||||
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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: 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<<EOF"
|
||||
cat "$output_file"
|
||||
echo "EOF"
|
||||
} >> $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
|
||||
});
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg version="1.1" baseProfile="full" width="340" height="310" xmlns="http://www.w3.org/2000/svg">
|
||||
<g stroke="white" stroke-width="1.5">
|
||||
<g transform="rotate(30) skewX(30)">
|
||||
<rect x="110" y="-72" width="175" height="75" fill="#333333" transform="skewX(-50)"/>
|
||||
<rect x="110" y="3" width="87.5" height="75" fill="#CDCDCD" transform="skewX(-50)"/>
|
||||
<rect x="16.5" y="78.9" width="87.5" height="25" fill="#CDCDCD" />
|
||||
<rect x="16.5" y="104.5" width="175" height="25" fill="#888888" />
|
||||
<rect x="16.5" y="130" width="175" height="50" fill="#DD4814" />
|
||||
<rect x="104" y="166" width="89.5" height="25" fill="#CDCDCD" transform="skewY(-40)"/>
|
||||
<rect x="228.3" y="29.5" width="87.5" height="75" fill="#888888" transform="skewX(-50)"/>
|
||||
<rect x="191.8" y="266" width="89.5" height="25" fill="#888888" transform="skewY(-40)"/>
|
||||
<rect x="192" y="291" width="179.5" height="50" fill="#DD4814" transform="skewY(-40)"/>
|
||||
<rect x="282.3" y="240" width="89.1" height="50" fill="#333333" transform="skewY(-40)"/>
|
||||
<rect x="194" y="3.7" width="87.5" height="25" fill="#333333" />
|
||||
</g>
|
||||
<line x1="93" y1="57" x2="93" y2="88" />
|
||||
<line x1="169" y1="131" x2="92" y2="88" />
|
||||
<line x1="92" y1="88" x2="14" y2="128" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#06D092" d="M8 0L1 4v8l7 4 7-4V4L8 0zm3.119 8.797L9.254 9.863 7.001 8.65v2.549l-2.118 1.33v-5.33l1.68-1.018 2.332 1.216V4.794l2.23-1.322-.006 5.325z"/></svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -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 '<regex_pattern>'
|
||||
---
|
||||
|
||||
## 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/<USERNAME>/<REPO>.git//<MODULE-NAME>?ref=<BRANCH-NAME>"
|
||||
}
|
||||
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! 🚀
|
||||
|
||||
@@ -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.
|
||||
@@ -2,8 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
@@ -15,7 +15,14 @@ import (
|
||||
"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"`
|
||||
@@ -49,36 +56,36 @@ func validateCoderResourceDescription(description string) error {
|
||||
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, xerrors.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, xerrors.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, xerrors.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, 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))
|
||||
// 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 {
|
||||
@@ -89,9 +96,8 @@ func validateCoderResourceTags(tags []string) error {
|
||||
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) {
|
||||
@@ -105,16 +111,11 @@ func validateCoderResourceTags(tags []string) error {
|
||||
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
|
||||
@@ -159,15 +159,13 @@ 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
|
||||
@@ -250,7 +248,7 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
|
||||
}
|
||||
if len(yamlParsingErrs) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseReadmeParsing,
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlParsingErrs,
|
||||
}
|
||||
}
|
||||
@@ -264,7 +262,7 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseReadmeParsing,
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
@@ -274,7 +272,7 @@ 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(_ map[string]coderResourceReadme) error {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
@@ -50,6 +50,9 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
|
||||
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
|
||||
@@ -57,10 +60,6 @@ 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, xerrors.Errorf("email address %q is missing @ symbol", *email))
|
||||
@@ -110,21 +109,18 @@ func validateContributorStatus(status string) error {
|
||||
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, xerrors.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, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
|
||||
}
|
||||
@@ -132,7 +128,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
||||
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 {
|
||||
@@ -210,22 +206,21 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -241,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 {
|
||||
@@ -261,7 +257,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
|
||||
|
||||
if len(errs) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseFileLoad,
|
||||
phase: validationPhaseFile,
|
||||
errors: errs,
|
||||
}
|
||||
}
|
||||
@@ -269,18 +265,18 @@ 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.
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -291,10 +287,8 @@ func validateContributorRelativeUrls(contributors map[string]contributorProfileR
|
||||
continue
|
||||
}
|
||||
|
||||
absolutePath := strings.TrimSuffix(con.filePath, "README.md") +
|
||||
*con.frontmatter.AvatarURL
|
||||
_, err := os.Stat(absolutePath)
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -303,7 +297,7 @@ func validateContributorRelativeUrls(contributors map[string]contributorProfileR
|
||||
return nil
|
||||
}
|
||||
return validationPhaseError{
|
||||
phase: validationPhaseAssetCrossReference,
|
||||
phase: validationPhaseCrossReference,
|
||||
errors: errs,
|
||||
}
|
||||
}
|
||||
@@ -314,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
|
||||
}
|
||||
|
||||
@@ -6,10 +6,8 @@ import (
|
||||
"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.
|
||||
// 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
|
||||
|
||||
@@ -7,7 +7,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -19,12 +18,11 @@ var logger = slog.Make(sloghuman.Sink(os.Stdout))
|
||||
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.
|
||||
// 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 {
|
||||
log.Println(err)
|
||||
logger.Error(context.Background(), "error when validating the repo structure", "error", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,26 +2,55 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"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) (readmeFrontmatter string, readmeBody string, err error) {
|
||||
if readmeText == "" {
|
||||
@@ -29,8 +58,9 @@ func separateFrontmatter(readmeText string) (readmeFrontmatter string, readmeBod
|
||||
}
|
||||
|
||||
const fence = "---"
|
||||
fm := ""
|
||||
body := ""
|
||||
|
||||
var fm strings.Builder
|
||||
var body strings.Builder
|
||||
fenceCount := 0
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText)))
|
||||
@@ -40,36 +70,32 @@ func separateFrontmatter(readmeText string) (readmeFrontmatter string, readmeBod
|
||||
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 "", "", xerrors.New("README does not have two sets of frontmatter fences")
|
||||
}
|
||||
if fm == "" {
|
||||
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(`^(#+)(\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)
|
||||
|
||||
@@ -77,9 +103,8 @@ func validateReadmeBody(body string) []error {
|
||||
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{xerrors.New("README body must start with ATX-style h1 header (i.e., \"# \")")}
|
||||
}
|
||||
@@ -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,8 +133,8 @@ func validateReadmeBody(body string) []error {
|
||||
continue
|
||||
}
|
||||
|
||||
spaceAfterHeader := headerGroups[2]
|
||||
if spaceAfterHeader == "" {
|
||||
// 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"))
|
||||
}
|
||||
|
||||
@@ -121,8 +145,7 @@ 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, xerrors.New("READMEs cannot contain more than h1 header"))
|
||||
break
|
||||
@@ -132,43 +155,16 @@ func validateReadmeBody(body string) []error {
|
||||
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, 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"
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
@@ -13,40 +13,33 @@ import (
|
||||
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, xerrors.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, xerrors.Errorf("%q: 'README.md' does not exist", resourceReadmePath))
|
||||
} else {
|
||||
@@ -55,8 +48,7 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
}
|
||||
|
||||
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, xerrors.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath))
|
||||
} else {
|
||||
@@ -64,7 +56,6 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -83,8 +74,7 @@ func validateRegistryDirectory() []error {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -110,8 +99,7 @@ func validateRegistryDirectory() []error {
|
||||
}
|
||||
|
||||
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, xerrors.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
|
||||
|
||||
@@ -20,7 +20,7 @@ require (
|
||||
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.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/term v0.29.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
|
||||
)
|
||||
|
||||
@@ -51,17 +51,17 @@ go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiM
|
||||
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.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
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=
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 654 KiB After Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 526 KiB After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 85 KiB |
@@ -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.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -88,7 +88,7 @@ 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 = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -107,7 +107,7 @@ Run Claude Code as a standalone app in your workspace. This will install Claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
|
||||
@@ -3,8 +3,8 @@ terraform {
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,18 @@ variable "claude_code_version" {
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
variable "experiment_use_screen" {
|
||||
type = bool
|
||||
description = "Whether to use screen for running Claude Code in the background."
|
||||
@@ -87,6 +99,15 @@ variable "experiment_post_install_script" {
|
||||
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) : ""
|
||||
agentapi_start_command = <<-EOT
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# use low width to fit in the tasks UI sidebar. height is adjusted to ~match the default 80k (80x1000) characters
|
||||
# visible in the terminal screen.
|
||||
agentapi server --term-width 67 --term-height 1190 -- bash -c "claude --dangerously-skip-permissions \"$(cat ~/.claude-code-prompt)\""
|
||||
EOT
|
||||
agentapi_start_command_base64 = base64encode(local.agentapi_start_command)
|
||||
}
|
||||
|
||||
# Install and Initialize Claude Code
|
||||
@@ -131,6 +152,37 @@ resource "coder_script" "claude_code" {
|
||||
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
|
||||
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
|
||||
wget "https://github.com/coder/agentapi/releases/download/${var.agentapi_version}/$binary_name"
|
||||
chmod +x "$binary_name"
|
||||
sudo mv "$binary_name" /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
|
||||
|
||||
echo -n "$CODER_MCP_CLAUDE_TASK_PROMPT" > ~/.claude-code-prompt
|
||||
echo -n "${local.agentapi_start_command_base64}" | base64 -d > ~/.agentapi-start-command
|
||||
chmod +x ~/.agentapi-start-command
|
||||
|
||||
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} --ai-agentapi-url http://localhost:3284
|
||||
fi
|
||||
|
||||
# Run post-install script if provided
|
||||
if [ -n "${local.encoded_post_install_script}" ]; then
|
||||
echo "Running post-install script..."
|
||||
@@ -139,11 +191,6 @@ 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."
|
||||
@@ -166,15 +213,25 @@ resource "coder_script" "claude_code" {
|
||||
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
|
||||
# use low width to fit in the tasks UI sidebar. height is adjusted to ~match the default 80k (80x1000) characters
|
||||
# visible in the terminal screen.
|
||||
tmux new-session -d -s claude-code-agentapi -c ${var.folder} '~/.agentapi-start-command; exec bash'
|
||||
echo "Waiting for agentapi server to start on port 3284..."
|
||||
for i in $(seq 1 15); do
|
||||
if lsof -i :3284 | grep -q 'LISTEN'; then
|
||||
echo "agentapi server started on port 3284."
|
||||
break
|
||||
fi
|
||||
echo "Waiting... ($i/15)"
|
||||
sleep 1
|
||||
done
|
||||
if ! lsof -i :3284 | grep -q 'LISTEN'; then
|
||||
echo "Error: agentapi server did not start on port 3284 after 15 seconds."
|
||||
exit 1
|
||||
fi
|
||||
tmux new-session -d -s claude-code -c ${var.folder} "agentapi attach"
|
||||
|
||||
|
||||
fi
|
||||
|
||||
# Run with screen if enabled
|
||||
@@ -209,15 +266,9 @@ resource "coder_script" "claude_code" {
|
||||
|
||||
screen -U -dmS claude-code bash -c '
|
||||
cd ${var.folder}
|
||||
claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"
|
||||
~/.agentapi-start-command
|
||||
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
|
||||
@@ -229,6 +280,20 @@ resource "coder_script" "claude_code" {
|
||||
run_on_start = true
|
||||
}
|
||||
|
||||
resource "coder_app" "claude_code_web" {
|
||||
slug = "claude-code-web"
|
||||
display_name = "Claude Code Web"
|
||||
agent_id = var.agent_id
|
||||
url = "http://localhost:3284/"
|
||||
icon = var.icon
|
||||
subdomain = true
|
||||
healthcheck {
|
||||
url = "http://localhost:3284/status"
|
||||
interval = 5
|
||||
threshold = 3
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_app" "claude_code" {
|
||||
slug = "claude-code"
|
||||
display_name = "Claude Code"
|
||||
@@ -241,12 +306,18 @@ resource "coder_app" "claude_code" {
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
if [ "${var.experiment_use_tmux}" = "true" ]; then
|
||||
|
||||
if ! tmux has-session -t claude-code-agentapi 2>/dev/null; then
|
||||
echo "Starting a new Claude Code agentapi tmux session." | tee -a "$HOME/.claude-code.log"
|
||||
tmux new-session -d -s claude-code-agentapi -c ${var.folder} '~/.agentapi-start-command; exec bash'
|
||||
fi
|
||||
|
||||
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"
|
||||
tmux new-session -s claude-code -c ${var.folder} "agentapi attach; exec bash"
|
||||
fi
|
||||
elif [ "${var.experiment_use_screen}" = "true" ]; then
|
||||
if screen -list | grep -q "claude-code"; then
|
||||
@@ -254,7 +325,7 @@ resource "coder_app" "claude_code" {
|
||||
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'
|
||||
screen -S claude-code bash -c 'agentapi attach; exec bash'
|
||||
fi
|
||||
else
|
||||
cd ${var.folder}
|
||||
@@ -265,3 +336,9 @@ resource "coder_app" "claude_code" {
|
||||
order = var.order
|
||||
group = var.group
|
||||
}
|
||||
|
||||
resource "coder_ai_task" "claude_code" {
|
||||
sidebar_app {
|
||||
id = coder_app.claude_code.id
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 94 KiB |
@@ -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.
|
||||

|
||||
|
||||
<!-- TODO: Add screenshot -->
|
||||
|
||||
## 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).
|
||||
|
After Width: | Height: | Size: 124 KiB |
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
sudo -u '${linux_user}' sh -c '${init_script}'
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
<!-- TODO: Add screenshot -->
|
||||
|
||||
## 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`.
|
||||
@@ -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 = <<EOT
|
||||
<powershell>
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
${coder_agent.main.init_script}
|
||||
</powershell>
|
||||
<persist>true</persist>
|
||||
EOT
|
||||
|
||||
user_data_end = <<EOT
|
||||
<powershell>
|
||||
shutdown /s
|
||||
</powershell>
|
||||
<persist>true</persist>
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
<!-- TODO: Add screenshot -->
|
||||
|
||||
## 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=<id>`
|
||||
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}"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<FirstLogonCommands>
|
||||
<SynchronousCommand>
|
||||
<CommandLine>cmd /c "copy C:\AzureData\CustomData.bin C:\AzureData\Initialize.ps1"</CommandLine>
|
||||
<Description>Copy Initialize.ps1 to file from CustomData</Description>
|
||||
<Order>3</Order>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand>
|
||||
<CommandLine>powershell.exe -sta -ExecutionPolicy Unrestricted -Command "C:\AzureData\Initialize.ps1 *> C:\AzureData\Initialize.log"</CommandLine>
|
||||
<Description>Execute Initialize.ps1 script</Description>
|
||||
<Order>4</Order>
|
||||
</SynchronousCommand>
|
||||
</FirstLogonCommands>
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
<!-- TODO: Add screenshot -->
|
||||
|
||||
## 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=<id>`
|
||||
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}"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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 = "<AutoLogon><Password><Value>${random_password.admin_password.result}</Value></Password><Enabled>true</Enabled><LogonCount>1</LogonCount><Username>${local.admin_username}</Username></AutoLogon>"
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
display_name: DigitalOcean Droplet (Linux)
|
||||
description: Provision DigitalOcean Droplets as Coder workspaces
|
||||
icon: ../../../../.icons/do.png
|
||||
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.
|
||||
|
||||
<!-- TODO: Add screenshot -->
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
display_name: Docker (Devcontainer)
|
||||
description: Provision envbuilder containers as Coder workspaces
|
||||
icon: ../../../../.icons/docker.png
|
||||
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.
|
||||
@@ -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 = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Swap Usage (Host)"
|
||||
key = "7_swap_host"
|
||||
script = <<EOT
|
||||
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
|
||||
EOT
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
# 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", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"]
|
||||
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
|
||||
}
|
||||
|
||||
resource "coder_metadata" "container_info" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
resource_id = coder_agent.main.id
|
||||
item {
|
||||
key = "workspace image"
|
||||
value = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image
|
||||
}
|
||||
item {
|
||||
key = "git url"
|
||||
value = local.repo_url
|
||||
}
|
||||
item {
|
||||
key = "cache repo"
|
||||
value = var.cache_repo == "" ? "not enabled" : var.cache_repo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
display_name: Docker Containers
|
||||
description: Provision Docker containers as Coder workspaces
|
||||
icon: ../../../../.icons/docker.png
|
||||
maintainer_github: coder
|
||||
verified: true
|
||||
tags: [docker, container]
|
||||
---
|
||||
|
||||
# Remote Development on Docker Containers
|
||||
|
||||
Provision Docker containers as [Coder workspaces](https://coder.com/docs/workspaces) with this example template.
|
||||
|
||||
<!-- TODO: Add screenshot -->
|
||||
|
||||
## 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.
|
||||
@@ -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 = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Swap Usage (Host)"
|
||||
key = "7_swap_host"
|
||||
script = <<EOT
|
||||
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
|
||||
EOT
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
# 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", "PS", "WS", "PY", "CL", "GO", "RM", "RD", "RR"]
|
||||
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 "docker_volume" "home_volume" {
|
||||
name = "coder-${data.coder_workspace.me.id}-home"
|
||||
# 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
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_container" "workspace" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
image = "codercom/enterprise-base:ubuntu"
|
||||
# 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 docker gateway if the access URL is 127.0.0.1
|
||||
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
|
||||
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
|
||||
host {
|
||||
host = "host.docker.internal"
|
||||
ip = "host-gateway"
|
||||
}
|
||||
volumes {
|
||||
container_path = "/home/coder"
|
||||
volume_name = docker_volume.home_volume.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
display_name: Google Compute Engine (Devcontainer)
|
||||
description: Provision a Devcontainer on Google Compute Engine instances as Coder workspaces
|
||||
icon: ../../../../.icons/gcp.svg
|
||||
maintainer_github: coder
|
||||
verified: true
|
||||
tags: [vm, linux, gcp, devcontainer]
|
||||
---
|
||||
|
||||
# Remote Development in a Devcontainer on Google Compute Engine
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||
- 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.
|
||||
|
After Width: | Height: | Size: 124 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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 = <<EOMETA
|
||||
#!/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
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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!
|
||||
@@ -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 = <<EOF
|
||||
CODER_AGENT_TOKEN=${local.agent_token}
|
||||
EOF
|
||||
create_directories = true
|
||||
target_path = "/opt/coder/init.env"
|
||||
}
|
||||
|
||||
resource "incus_instance" "dev" {
|
||||
running = data.coder_workspace.me.start_count == 1
|
||||
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
|
||||
image = incus_cached_image.image.fingerprint
|
||||
|
||||
config = {
|
||||
"security.nesting" = true
|
||||
"security.syscalls.intercept.mknod" = true
|
||||
"security.syscalls.intercept.setxattr" = true
|
||||
"boot.autostart" = true
|
||||
"cloud-init.user-data" = <<EOF
|
||||
#cloud-config
|
||||
hostname: ${lower(data.coder_workspace.me.name)}
|
||||
users:
|
||||
- name: ${local.workspace_user}
|
||||
uid: 1000
|
||||
gid: 1000
|
||||
groups: sudo
|
||||
packages:
|
||||
- curl
|
||||
shell: /bin/bash
|
||||
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
||||
write_files:
|
||||
- path: /opt/coder/init
|
||||
permissions: "0755"
|
||||
encoding: b64
|
||||
content: ${base64encode(local.agent_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=${local.workspace_user}
|
||||
EnvironmentFile=/opt/coder/init.env
|
||||
ExecStart=/opt/coder/init
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
TimeoutStopSec=90
|
||||
KillMode=process
|
||||
|
||||
OOMScoreAdjust=-900
|
||||
SyslogIdentifier=coder-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
- path: /etc/systemd/system/coder-agent-watcher.service
|
||||
permissions: "0644"
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Coder Agent Watcher
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/systemctl restart coder-agent.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
- path: /etc/systemd/system/coder-agent-watcher.path
|
||||
permissions: "0644"
|
||||
content: |
|
||||
[Path]
|
||||
PathModified=/opt/coder/init.env
|
||||
Unit=coder-agent-watcher.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
runcmd:
|
||||
- chown -R ${local.workspace_user}:${local.workspace_user} /home/${local.workspace_user}
|
||||
- |
|
||||
#!/bin/bash
|
||||
apt-get update && apt-get install -y curl docker.io
|
||||
usermod -aG docker ${local.workspace_user}
|
||||
newgrp docker
|
||||
- systemctl enable coder-agent.service coder-agent-watcher.service coder-agent-watcher.path
|
||||
- systemctl start coder-agent.service coder-agent-watcher.service coder-agent-watcher.path
|
||||
EOF
|
||||
}
|
||||
|
||||
limits = {
|
||||
cpu = data.coder_parameter.cpu.value
|
||||
memory = "${data.coder_parameter.cpu.value}GiB"
|
||||
}
|
||||
|
||||
device {
|
||||
name = "home"
|
||||
type = "disk"
|
||||
properties = {
|
||||
path = "/home/${local.workspace_user}"
|
||||
pool = local.pool
|
||||
source = incus_volume.home.name
|
||||
}
|
||||
}
|
||||
|
||||
device {
|
||||
name = "docker"
|
||||
type = "disk"
|
||||
properties = {
|
||||
path = "/var/lib/docker"
|
||||
pool = local.pool
|
||||
source = incus_volume.docker.name
|
||||
}
|
||||
}
|
||||
|
||||
device {
|
||||
name = "root"
|
||||
type = "disk"
|
||||
properties = {
|
||||
path = "/"
|
||||
pool = local.pool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
workspace_user = lower(data.coder_workspace_owner.me.name)
|
||||
pool = "coder"
|
||||
repo_base_dir = data.coder_parameter.repo_base_dir.value == "~" ? "/home/${local.workspace_user}" : replace(data.coder_parameter.repo_base_dir.value, "/^~\\//", "/home/${local.workspace_user}/")
|
||||
repo_dir = module.git-clone.repo_dir
|
||||
agent_id = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].id : ""
|
||||
agent_token = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].token : ""
|
||||
agent_init_script = data.coder_workspace.me.start_count == 1 ? coder_agent.main[0].init_script : ""
|
||||
}
|
||||
|
||||
resource "coder_metadata" "info" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
resource_id = incus_instance.dev.name
|
||||
item {
|
||||
key = "memory"
|
||||
value = incus_instance.dev.limits.memory
|
||||
}
|
||||
item {
|
||||
key = "cpus"
|
||||
value = incus_instance.dev.limits.cpu
|
||||
}
|
||||
item {
|
||||
key = "instance"
|
||||
value = incus_instance.dev.name
|
||||
}
|
||||
item {
|
||||
key = "image"
|
||||
value = "${incus_cached_image.image.source_remote}:${incus_cached_image.image.source_image}"
|
||||
}
|
||||
item {
|
||||
key = "image_fingerprint"
|
||||
value = substr(incus_cached_image.image.fingerprint, 0, 12)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
display_name: Kubernetes (Devcontainer)
|
||||
description: Provision envbuilder pods as Coder workspaces
|
||||
icon: ../../../../.icons/k8s.png
|
||||
maintainer_github: coder
|
||||
verified: true
|
||||
tags: [container, kubernetes, devcontainer]
|
||||
---
|
||||
|
||||
# Remote Development on Kubernetes Pods (with Devcontainers)
|
||||
|
||||
Provision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces) on Kubernetes with this example template.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Infrastructure
|
||||
|
||||
**Cluster**: This template requires an existing Kubernetes cluster.
|
||||
|
||||
**Container Image**: This template uses the [envbuilder image](https://github.com/coder/envbuilder) to build a Devcontainer from a `devcontainer.json`.
|
||||
|
||||
**(Optional) Cache Registry**: Envbuilder can utilize a Docker registry as a cache to speed up workspace builds. The [envbuilder Terraform provider](https://github.com/coder/terraform-provider-envbuilder) will check the contents of the cache to determine if a prebuilt image exists. In the case of some missing layers in the registry (partial cache miss), Envbuilder can still utilize some of the build cache from the registry.
|
||||
|
||||
### 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
|
||||
|
||||
Coder supports devcontainers with [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:
|
||||
|
||||
- Kubernetes deployment (ephemeral)
|
||||
- Kubernetes persistent volume claim (persistent on `/workspaces`)
|
||||
- Envbuilder cached image (optional, persistent).
|
||||
|
||||
This template will fetch a Git repo containing a `devcontainer.json` specified by the `repo` parameter, and builds it
|
||||
with [`envbuilder`](https://github.com/coder/envbuilder).
|
||||
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.
|
||||
As you might suspect, 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.
|
||||
|
||||
## 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`.
|
||||
@@ -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 = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Swap Usage (Host)"
|
||||
key = "7_swap_host"
|
||||
script = <<EOT
|
||||
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
|
||||
EOT
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
# 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 "coder_metadata" "container_info" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
resource_id = coder_agent.main.id
|
||||
item {
|
||||
key = "workspace image"
|
||||
value = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image
|
||||
}
|
||||
item {
|
||||
key = "git url"
|
||||
value = local.repo_url
|
||||
}
|
||||
item {
|
||||
key = "cache repo"
|
||||
value = var.cache_repo == "" ? "not enabled" : var.cache_repo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
display_name: Kubernetes (Envbox)
|
||||
description: Provision envbox pods as Coder workspaces
|
||||
icon: ../../../../.icons/k8s.png
|
||||
maintainer_github: coder
|
||||
verified: true
|
||||
tags: [kubernetes, containers, docker-in-docker]
|
||||
---
|
||||
|
||||
# envbox
|
||||
|
||||
## Introduction
|
||||
|
||||
`envbox` is an image that enables creating non-privileged containers capable of running system-level software (e.g. `dockerd`, `systemd`, etc) in Kubernetes.
|
||||
|
||||
It mainly acts as a wrapper for the excellent [sysbox runtime](https://github.com/nestybox/sysbox/) developed by [Nestybox](https://www.nestybox.com/). For more details on the security of `sysbox` containers see sysbox's [official documentation](https://github.com/nestybox/sysbox/blob/master/docs/user-guide/security.md).
|
||||
|
||||
## Envbox Configuration
|
||||
|
||||
The following environment variables can be used to configure various aspects of the inner and outer container.
|
||||
|
||||
| env | usage | required |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| `CODER_INNER_IMAGE` | The image to use for the inner container. | True |
|
||||
| `CODER_INNER_USERNAME` | The username to use for the inner container. | True |
|
||||
| `CODER_AGENT_TOKEN` | The [Coder Agent](https://coder.com/docs/about/architecture#agents) token to pass to the inner container. | True |
|
||||
| `CODER_INNER_ENVS` | The environment variables to pass to the inner container. A wildcard can be used to match a prefix. Ex: `CODER_INNER_ENVS=KUBERNETES_*,MY_ENV,MY_OTHER_ENV` | false |
|
||||
| `CODER_INNER_HOSTNAME` | The hostname to use for the inner container. | false |
|
||||
| `CODER_IMAGE_PULL_SECRET` | The docker credentials to use when pulling the inner container. The recommended way to do this is to create an [Image Pull Secret](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/#registry-secret-existing-credentials) and then reference the secret using an [environment variable](https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure/#define-container-environment-variables-using-secret-data). | false |
|
||||
| `CODER_DOCKER_BRIDGE_CIDR` | The bridge CIDR to start the Docker daemon with. | false |
|
||||
| `CODER_MOUNTS` | A list of mounts to mount into the inner container. Mounts default to `rw`. Ex: `CODER_MOUNTS=/home/coder:/home/coder,/var/run/mysecret:/var/run/mysecret:ro` | false |
|
||||
| `CODER_USR_LIB_DIR` | The mountpoint of the host `/usr/lib` directory. Only required when using GPUs. | false |
|
||||
| `CODER_ADD_TUN` | If `CODER_ADD_TUN=true` add a TUN device to the inner container. | false |
|
||||
| `CODER_ADD_FUSE` | If `CODER_ADD_FUSE=true` add a FUSE device to the inner container. | false |
|
||||
| `CODER_ADD_GPU` | If `CODER_ADD_GPU=true` add detected GPUs and related files to the inner container. Requires setting `CODER_USR_LIB_DIR` and mounting in the hosts `/usr/lib/` directory. | false |
|
||||
| `CODER_CPUS` | Dictates the number of CPUs to allocate the inner container. It is recommended to set this using the Kubernetes [Downward API](https://kubernetes.io/docs/tasks/inject-data-application/environment-variable-expose-pod-information/#use-container-fields-as-values-for-environment-variables). | false |
|
||||
| `CODER_MEMORY` | Dictates the max memory (in bytes) to allocate the inner container. It is recommended to set this using the Kubernetes [Downward API](https://kubernetes.io/docs/tasks/inject-data-application/environment-variable-expose-pod-information/#use-container-fields-as-values-for-environment-variables). | false |
|
||||
|
||||
## Migrating Existing Envbox Templates
|
||||
|
||||
Due to the [deprecation and removal of legacy parameters](https://coder.com/docs/templates/parameters#legacy)
|
||||
it may be necessary to migrate existing envbox templates on newer versions of
|
||||
Coder. Consult the [migration](https://coder.com/docs/templates/parameters#migration)
|
||||
documentation for details on how to do so.
|
||||
|
||||
To supply values to existing existing Terraform variables you can specify the
|
||||
`-V` flag. For example
|
||||
|
||||
```bash
|
||||
coder templates push envbox --var namespace="mynamespace" --var max_cpus=2 --var min_cpus=1 --var max_memory=4 --var min_memory=1
|
||||
```
|
||||
|
||||
## Version Pinning
|
||||
|
||||
The template sets the image tag as `latest`. We highly recommend pinning the image to a specific release of envbox, as the `latest` tag may change.
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are welcome and can be made against the [envbox repo](https://github.com/coder/envbox).
|
||||
@@ -0,0 +1,322 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
kubernetes = {
|
||||
source = "hashicorp/kubernetes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "home_disk" {
|
||||
name = "Disk Size"
|
||||
description = "How large should the disk storing the home directory be?"
|
||||
icon = "https://cdn-icons-png.flaticon.com/512/2344/2344147.png"
|
||||
type = "number"
|
||||
default = 10
|
||||
mutable = true
|
||||
validation {
|
||||
min = 10
|
||||
max = 100
|
||||
}
|
||||
}
|
||||
|
||||
variable "use_kubeconfig" {
|
||||
type = bool
|
||||
default = true
|
||||
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
|
||||
}
|
||||
|
||||
provider "coder" {}
|
||||
|
||||
variable "namespace" {
|
||||
type = string
|
||||
description = "The namespace to create workspaces in (must exist prior to creating workspaces)"
|
||||
}
|
||||
|
||||
variable "create_tun" {
|
||||
type = bool
|
||||
description = "Add a TUN device to the workspace."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "create_fuse" {
|
||||
type = bool
|
||||
description = "Add a FUSE device to the workspace."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "max_cpus" {
|
||||
type = string
|
||||
description = "Max number of CPUs the workspace may use (e.g. 2)."
|
||||
}
|
||||
|
||||
variable "min_cpus" {
|
||||
type = string
|
||||
description = "Minimum number of CPUs the workspace may use (e.g. .1)."
|
||||
}
|
||||
|
||||
variable "max_memory" {
|
||||
type = string
|
||||
description = "Maximum amount of memory to allocate the workspace (in GB)."
|
||||
}
|
||||
|
||||
variable "min_memory" {
|
||||
type = string
|
||||
description = "Minimum amount of memory to allocate the workspace (in GB)."
|
||||
}
|
||||
|
||||
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
|
||||
#!/bin/bash
|
||||
# home folder can be empty, so copying default bash settings
|
||||
if [ ! -f ~/.profile ]; then
|
||||
cp /etc/skel/.profile $HOME
|
||||
fi
|
||||
if [ ! -f ~/.bashrc ]; then
|
||||
cp /etc/skel/.bashrc $HOME
|
||||
fi
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
resource "kubernetes_persistent_volume_claim" "home" {
|
||||
metadata {
|
||||
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-home"
|
||||
namespace = var.namespace
|
||||
}
|
||||
wait_until_bound = false
|
||||
spec {
|
||||
access_modes = ["ReadWriteOnce"]
|
||||
resources {
|
||||
requests = {
|
||||
storage = "${data.coder_parameter.home_disk.value}Gi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_pod" "main" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
|
||||
metadata {
|
||||
name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}"
|
||||
namespace = var.namespace
|
||||
}
|
||||
|
||||
spec {
|
||||
restart_policy = "Never"
|
||||
|
||||
container {
|
||||
name = "dev"
|
||||
# We highly recommend pinning this to a specific release of envbox, as the latest tag may change.
|
||||
image = "ghcr.io/coder/envbox:latest"
|
||||
image_pull_policy = "Always"
|
||||
command = ["/envbox", "docker"]
|
||||
|
||||
security_context {
|
||||
privileged = true
|
||||
}
|
||||
|
||||
resources {
|
||||
requests = {
|
||||
"cpu" : "${var.min_cpus}"
|
||||
"memory" : "${var.min_memory}G"
|
||||
}
|
||||
|
||||
limits = {
|
||||
"cpu" : "${var.max_cpus}"
|
||||
"memory" : "${var.max_memory}G"
|
||||
}
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_AGENT_TOKEN"
|
||||
value = coder_agent.main.token
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_AGENT_URL"
|
||||
value = data.coder_workspace.me.access_url
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_INNER_IMAGE"
|
||||
value = "index.docker.io/codercom/enterprise-base:ubuntu-20240812"
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_INNER_USERNAME"
|
||||
value = "coder"
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_BOOTSTRAP_SCRIPT"
|
||||
value = coder_agent.main.init_script
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_MOUNTS"
|
||||
value = "/home/coder:/home/coder"
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_ADD_FUSE"
|
||||
value = var.create_fuse
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_INNER_HOSTNAME"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_ADD_TUN"
|
||||
value = var.create_tun
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_CPUS"
|
||||
value_from {
|
||||
resource_field_ref {
|
||||
resource = "limits.cpu"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
env {
|
||||
name = "CODER_MEMORY"
|
||||
value_from {
|
||||
resource_field_ref {
|
||||
resource = "limits.memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
mount_path = "/home/coder"
|
||||
name = "home"
|
||||
read_only = false
|
||||
sub_path = "home"
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
mount_path = "/var/lib/coder/docker"
|
||||
name = "home"
|
||||
sub_path = "cache/docker"
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
mount_path = "/var/lib/coder/containers"
|
||||
name = "home"
|
||||
sub_path = "cache/containers"
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
mount_path = "/var/lib/sysbox"
|
||||
name = "sysbox"
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
mount_path = "/var/lib/containers"
|
||||
name = "home"
|
||||
sub_path = "envbox/containers"
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
mount_path = "/var/lib/docker"
|
||||
name = "home"
|
||||
sub_path = "envbox/docker"
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
mount_path = "/usr/src"
|
||||
name = "usr-src"
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
mount_path = "/lib/modules"
|
||||
name = "lib-modules"
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "home"
|
||||
persistent_volume_claim {
|
||||
claim_name = kubernetes_persistent_volume_claim.home.metadata.0.name
|
||||
read_only = false
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "sysbox"
|
||||
empty_dir {}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "usr-src"
|
||||
host_path {
|
||||
path = "/usr/src"
|
||||
type = ""
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "lib-modules"
|
||||
host_path {
|
||||
path = "/lib/modules"
|
||||
type = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
display_name: Kubernetes (Deployment)
|
||||
description: Provision Kubernetes Deployments as Coder workspaces
|
||||
icon: ../../../../.icons/k8s.png
|
||||
maintainer_github: coder
|
||||
verified: true
|
||||
tags: [kubernetes, container]
|
||||
---
|
||||
|
||||
# Remote Development on Kubernetes Pods
|
||||
|
||||
Provision Kubernetes Pods as [Coder workspaces](https://coder.com/docs/workspaces) with this example template.
|
||||
|
||||
<!-- TODO: Add screenshot -->
|
||||
|
||||
## 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.
|
||||
@@ -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 = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
# code-server
|
||||
resource "coder_app" "code-server" {
|
||||
agent_id = coder_agent.main.id
|
||||
slug = "code-server"
|
||||
display_name = "code-server"
|
||||
icon = "/icon/code.svg"
|
||||
url = "http://localhost:13337?folder=/home/coder"
|
||||
subdomain = false
|
||||
share = "owner"
|
||||
|
||||
healthcheck {
|
||||
url = "http://localhost:13337/healthz"
|
||||
interval = 3
|
||||
threshold = 10
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_persistent_volume_claim" "home" {
|
||||
metadata {
|
||||
name = "coder-${data.coder_workspace.me.id}-home"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-pvc"
|
||||
"app.kubernetes.io/instance" = "coder-pvc-${data.coder_workspace.me.id}"
|
||||
"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.home_disk_size.value}Gi"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_deployment" "main" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
depends_on = [
|
||||
kubernetes_persistent_volume_claim.home
|
||||
]
|
||||
wait_for_rollout = false
|
||||
metadata {
|
||||
name = "coder-${data.coder_workspace.me.id}"
|
||||
namespace = var.namespace
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"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"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"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
|
||||
}
|
||||
}
|
||||
strategy {
|
||||
type = "Recreate"
|
||||
}
|
||||
|
||||
template {
|
||||
metadata {
|
||||
labels = {
|
||||
"app.kubernetes.io/name" = "coder-workspace"
|
||||
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
|
||||
"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
|
||||
}
|
||||
}
|
||||
spec {
|
||||
security_context {
|
||||
run_as_user = 1000
|
||||
fs_group = 1000
|
||||
run_as_non_root = true
|
||||
}
|
||||
|
||||
container {
|
||||
name = "dev"
|
||||
image = "codercom/enterprise-base:ubuntu"
|
||||
image_pull_policy = "Always"
|
||||
command = ["sh", "-c", coder_agent.main.init_script]
|
||||
security_context {
|
||||
run_as_user = "1000"
|
||||
}
|
||||
env {
|
||||
name = "CODER_AGENT_TOKEN"
|
||||
value = coder_agent.main.token
|
||||
}
|
||||
resources {
|
||||
requests = {
|
||||
"cpu" = "250m"
|
||||
"memory" = "512Mi"
|
||||
}
|
||||
limits = {
|
||||
"cpu" = "${data.coder_parameter.cpu.value}"
|
||||
"memory" = "${data.coder_parameter.memory.value}Gi"
|
||||
}
|
||||
}
|
||||
volume_mount {
|
||||
mount_path = "/home/coder"
|
||||
name = "home"
|
||||
read_only = false
|
||||
}
|
||||
}
|
||||
|
||||
volume {
|
||||
name = "home"
|
||||
persistent_volume_claim {
|
||||
claim_name = kubernetes_persistent_volume_claim.home.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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
display_name: Nomad
|
||||
description: Provision Nomad Jobs as Coder workspaces
|
||||
icon: ../../../../.icons/nomad.svg
|
||||
maintainer_github: coder
|
||||
verified: true
|
||||
tags: [nomad, container]
|
||||
---
|
||||
|
||||
# Remote Development on Nomad
|
||||
|
||||
Provision Nomad Jobs as [Coder workspaces](https://coder.com/docs/workspaces) with this example template. This example shows how to use Nomad service tasks to be used as a development environment using docker and host csi volumes.
|
||||
|
||||
<!-- TODO: Add screenshot -->
|
||||
|
||||
> **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.
|
||||
@@ -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 = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
locals {
|
||||
workspace_tag = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}"
|
||||
home_volume_name = "coder_${data.coder_workspace.me.id}_home"
|
||||
}
|
||||
|
||||
resource "nomad_namespace" "coder_workspace" {
|
||||
name = local.workspace_tag
|
||||
description = "Coder workspace"
|
||||
meta = {
|
||||
owner = data.coder_workspace_owner.me.name
|
||||
}
|
||||
}
|
||||
|
||||
data "nomad_plugin" "hostpath" {
|
||||
plugin_id = "hostpath"
|
||||
wait_for_healthy = true
|
||||
}
|
||||
|
||||
resource "nomad_csi_volume" "home_volume" {
|
||||
depends_on = [data.nomad_plugin.hostpath]
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = all
|
||||
}
|
||||
plugin_id = "hostpath"
|
||||
volume_id = local.home_volume_name
|
||||
name = local.home_volume_name
|
||||
namespace = nomad_namespace.coder_workspace.name
|
||||
|
||||
capability {
|
||||
access_mode = "single-node-writer"
|
||||
attachment_mode = "file-system"
|
||||
}
|
||||
|
||||
mount_options {
|
||||
fs_type = "ext4"
|
||||
}
|
||||
}
|
||||
|
||||
resource "nomad_job" "workspace" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
depends_on = [nomad_csi_volume.home_volume]
|
||||
jobspec = templatefile("${path.module}/workspace.nomad.tpl", {
|
||||
coder_workspace_owner = data.coder_workspace_owner.me.name
|
||||
coder_workspace_name = data.coder_workspace.me.name
|
||||
workspace_tag = local.workspace_tag
|
||||
cores = tonumber(data.coder_parameter.cpu.value)
|
||||
memory_mb = tonumber(data.coder_parameter.memory.value * 1024)
|
||||
coder_init_script = coder_agent.main.init_script
|
||||
coder_agent_token = coder_agent.main.token
|
||||
workspace_name = data.coder_workspace.me.name
|
||||
home_volume_name = local.home_volume_name
|
||||
})
|
||||
deregister_on_destroy = true
|
||||
purge_on_destroy = true
|
||||
}
|
||||
|
||||
resource "coder_metadata" "workspace_info" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
resource_id = nomad_job.workspace[0].id
|
||||
item {
|
||||
key = "CPU (Cores)"
|
||||
value = data.coder_parameter.cpu.value
|
||||
}
|
||||
item {
|
||||
key = "Memory (GiB)"
|
||||
value = data.coder_parameter.memory.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
job "workspace" {
|
||||
datacenters = ["dc1"]
|
||||
namespace = "${workspace_tag}"
|
||||
type = "service"
|
||||
group "workspace" {
|
||||
volume "home_volume" {
|
||||
type = "csi"
|
||||
source = "${home_volume_name}"
|
||||
read_only = false
|
||||
attachment_mode = "file-system"
|
||||
access_mode = "single-node-writer"
|
||||
}
|
||||
network {
|
||||
port "http" {}
|
||||
}
|
||||
task "workspace" {
|
||||
driver = "docker"
|
||||
config {
|
||||
image = "codercom/enterprise-base:ubuntu"
|
||||
ports = ["http"]
|
||||
labels {
|
||||
name = "${workspace_tag}"
|
||||
managed_by = "coder"
|
||||
}
|
||||
hostname = "${workspace_name}"
|
||||
entrypoint = ["sh", "-c", "sudo chown coder:coder -R /home/coder && echo '${base64encode(coder_init_script)}' | base64 --decode | sh"]
|
||||
}
|
||||
volume_mount {
|
||||
volume = "home_volume"
|
||||
destination = "/home/coder"
|
||||
}
|
||||
resources {
|
||||
cores = ${cores}
|
||||
memory = ${memory_mb}
|
||||
}
|
||||
env {
|
||||
CODER_AGENT_TOKEN = "${coder_agent_token}"
|
||||
}
|
||||
meta {
|
||||
tag = "${workspace_tag}"
|
||||
managed_by = "coder"
|
||||
}
|
||||
}
|
||||
meta {
|
||||
tag = "${workspace_tag}"
|
||||
managed_by = "coder"
|
||||
}
|
||||
}
|
||||
meta {
|
||||
tag = "${workspace_tag}"
|
||||
managed_by = "coder"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
display_name: Scratch
|
||||
description: A minimal starter template for Coder
|
||||
icon: ../../../../.icons/1f4e6.png
|
||||
maintainer_github: coder
|
||||
verified: true
|
||||
tags: []
|
||||
---
|
||||
|
||||
# A minimal Scaffolding for a Coder Template
|
||||
|
||||
Use this starter template as a basis to create your own unique template from scratch.
|
||||
@@ -0,0 +1,66 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_provisioner" "me" {}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
arch = data.coder_provisioner.me.arch
|
||||
os = data.coder_provisioner.me.os
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
# Use this to set environment variables in your workspace
|
||||
# details: https://registry.terraform.io/providers/coder/coder/latest/docs/resources/env
|
||||
resource "coder_env" "welcome_message" {
|
||||
agent_id = coder_agent.main.id
|
||||
name = "WELCOME_MESSAGE"
|
||||
value = "Welcome to your Coder workspace!"
|
||||
}
|
||||
|
||||
# Adds code-server
|
||||
# See all available modules at https://registry.coder.com/modules
|
||||
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
|
||||
}
|
||||
|
||||
# Runs a script at workspace start/stop or on a cron schedule
|
||||
# details: https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script
|
||||
resource "coder_script" "startup_script" {
|
||||
agent_id = coder_agent.main.id
|
||||
display_name = "Startup Script"
|
||||
script = <<-EOF
|
||||
#!/bin/sh
|
||||
set -e
|
||||
# Run programs at workspace startup
|
||||
EOF
|
||||
run_on_start = true
|
||||
start_blocks_login = true
|
||||
}
|
||||